diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 67e36dc0..e14ae4e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' + - uses: Swatinem/rust-cache@v2 - run: npm ci - run: npm run compile - run: npm test diff --git a/package-lock.json b/package-lock.json index f38451a5..5bc81325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-motoko", - "version": "0.13.8", + "version": "0.13.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-motoko", - "version": "0.13.8", + "version": "0.13.9", "dependencies": { "@wasmer/wasi": "1.2.2", "change-case": "4.1.2", diff --git a/package.json b/package.json index 2fae43a5..9b2da3e4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-motoko", "displayName": "Motoko", "description": "Motoko language support", - "version": "0.13.8", + "version": "0.13.9", "publisher": "dfinity-foundation", "repository": "https://github.com/dfinity/vscode-motoko", "engines": { diff --git a/src/server/ast.ts b/src/server/ast.ts index b2e2f67e..4ca2bfc4 100644 --- a/src/server/ast.ts +++ b/src/server/ast.ts @@ -1,5 +1,5 @@ import { AST } from 'motoko/lib/ast'; -import { getContext } from './context'; +import { Context } from './context'; import { Program, fromAST } from './syntax'; import { resolveVirtualPath, tryGetFileText } from './utils'; @@ -16,12 +16,14 @@ export interface AstImport { field?: string; } -const globalCache = new Map(); // Share non-typed ASTs across all contexts +export const globalASTCache = new Map(); // Share non-typed ASTs across all contexts export default class AstResolver { - private readonly _cache = globalCache; + private readonly _cache = globalASTCache; private readonly _typedCache = new Map(); + constructor(private readonly context: Context) {} + clear() { this._cache.clear(); this._typedCache.clear(); @@ -55,7 +57,7 @@ export default class AstResolver { status.text = text; } try { - const { motoko } = getContext(uri); + const { motoko } = this.context; const virtualPath = resolveVirtualPath(uri); let ast: AST; try { diff --git a/src/server/context.ts b/src/server/context.ts index 6fe12dec..784aedc4 100644 --- a/src/server/context.ts +++ b/src/server/context.ts @@ -18,7 +18,7 @@ export class Context { constructor(uri: string, motoko: Motoko) { this.uri = uri; this.motoko = motoko; - this.astResolver = new AstResolver(); + this.astResolver = new AstResolver(this); this.importResolver = new ImportResolver(this); } } diff --git a/src/server/imports.ts b/src/server/imports.ts index 17e52788..ca7aee18 100644 --- a/src/server/imports.ts +++ b/src/server/imports.ts @@ -12,8 +12,6 @@ interface ResolvedField { } export default class ImportResolver { - public readonly context: Context; - // module name -> uri private readonly _moduleNameUriMap = new MultiMap(Set); // uri -> resolved field @@ -21,9 +19,7 @@ export default class ImportResolver { // import path -> file system uri private readonly _fileSystemMap = new Map(); - constructor(context: Context) { - this.context = context; - } + constructor(private readonly context: Context) {} clear() { this._moduleNameUriMap.clear(); @@ -39,7 +35,7 @@ export default class ImportResolver { this._fileSystemMap.set(importUri, uri); if (program?.export) { // Resolve field names - const { ast } = program.export; + const ast = program.export; const node = matchNode(ast, 'LetD', (_pat: Node, exp: Node) => exp) || // Named matchNode(ast, 'ExpD', (exp: Node) => exp); // Unnamed @@ -58,7 +54,6 @@ export default class ImportResolver { return; } const [dec, visibility] = field.args!; - // TODO: `system` visibility if (visibility !== 'Public') { return; } diff --git a/src/server/navigation.ts b/src/server/navigation.ts index 1b562c39..5c429d8a 100644 --- a/src/server/navigation.ts +++ b/src/server/navigation.ts @@ -65,6 +65,11 @@ export function findMostSpecificNodeForPosition( return node as (Node & { start: Span; end: Span }) | undefined; } +export function defaultRange(): Range { + const pos = Position.create(0, 0); + return Range.create(pos, pos); +} + export function rangeFromNode( node: Node | undefined, multiLineFromBeginning = false, diff --git a/src/server/server.ts b/src/server/server.ts index a3ebe805..b988c792 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -14,6 +14,7 @@ import { CompletionList, Diagnostic, DiagnosticSeverity, + DocumentSymbol, FileChangeType, InitializeResult, Location, @@ -23,11 +24,13 @@ import { Range, ReferenceParams, SignatureHelp, + SymbolKind, TextDocumentPositionParams, TextDocumentSyncKind, TextDocuments, TextEdit, WorkspaceFolder, + WorkspaceSymbol, createConnection, } from 'vscode-languageserver/node'; import { URI } from 'vscode-uri'; @@ -38,6 +41,7 @@ import { TestResult, } from '../common/connectionTypes'; import { watchGlob as virtualFilePattern } from '../common/watchConfig'; +import { globalASTCache } from './ast'; import { Context, addContext, @@ -49,13 +53,14 @@ import DfxResolver from './dfx'; import { organizeImports } from './imports'; import { getAstInformation } from './information'; import { + defaultRange, findDefinition, findMostSpecificNodeForPosition, locationFromDefinition, rangeFromNode, } from './navigation'; import { deployPlayground } from './playground'; -import { Program, asNode, findNodes } from './syntax'; +import { Field, ObjBlock, Program, asNode, findNodes } from './syntax'; import { formatMotoko, getFileText, @@ -434,7 +439,8 @@ connection.onInitialize((event): InitializeResult => { }, hoverProvider: true, // executeCommandProvider: { commands: [] }, - // workspaceSymbolProvider: true, + workspaceSymbolProvider: true, + documentSymbolProvider: true, // diagnosticProvider: { // documentSelector: ['motoko'], // interFileDependencies: true, @@ -1189,9 +1195,60 @@ connection.onDefinition( // }, // ); -// connection.onWorkspaceSymbol((_event) => { -// return []; -// }); +connection.onWorkspaceSymbol((event) => { + if (!event.query.length) { + return []; + } + const results: WorkspaceSymbol[] = []; + const visitDocumentSymbol = ( + uri: string, + symbol: DocumentSymbol, + parent?: DocumentSymbol, + ) => { + results.push({ + name: symbol.name, + kind: symbol.kind, + location: Location.create(uri, symbol.range), + containerName: parent?.name, + }); + symbol.children?.forEach((s) => visitDocumentSymbol(uri, s, symbol)); + }; + globalASTCache.forEach((status) => { + status.program?.namedExports.forEach((field) => { + visitDocumentSymbol(status.uri, getDocumentSymbol(field)); + }); + }); + return results; +}); + +connection.onDocumentSymbol((event) => { + const { uri } = event.textDocument; + const results: DocumentSymbol[] = []; + const status = getContext(uri).astResolver.request(uri); + status?.program?.namedExports.forEach((field) => { + results.push(getDocumentSymbol(field)); + }); + return results; +}); + +function getDocumentSymbol(field: Field): DocumentSymbol { + const range = rangeFromNode(asNode(field.ast)) || defaultRange(); + const kind = + field.exp instanceof ObjBlock ? SymbolKind.Module : SymbolKind.Field; + const children: DocumentSymbol[] = []; + if (field.exp instanceof ObjBlock) { + field.exp.fields.forEach((field) => { + children.push(getDocumentSymbol(field)); + }); + } + return { + name: field.name, + kind, + range, + selectionRange: rangeFromNode(asNode(field.pat?.ast)) || range, + children, + }; +} connection.onReferences( async (_event: ReferenceParams): Promise => { diff --git a/src/server/syntax.ts b/src/server/syntax.ts index 9e4b0423..8ab7f325 100644 --- a/src/server/syntax.ts +++ b/src/server/syntax.ts @@ -79,15 +79,52 @@ export function fromAST(ast: AST): Syntax { }); if (ast.args.length) { const export_ = ast.args[ast.args.length - 1]; - prog.export = fromAST(export_); + if (export_) { + prog.export = export_; + prog.namedExports.push(...getFieldsFromAST(export_)); + } } } return prog; + } else if (ast.name === 'ObjBlockE' && ast.args) { + const sort = ast.args[0] as ObjSort; + const fields = ast.args.slice(1) as Node[]; + + const obj = new ObjBlock(ast, sort); + fields.forEach((field) => { + if (field.name !== 'DecField') { + console.error( + 'Error: expected `DecField`, received', + field.name, + ); + return; + } + const [dec, _visibility] = field.args!; + // if (visibility !== 'Public') { + // return; + // } + obj.fields.push(...getFieldsFromAST(dec)); + }); + return obj; } else { return new Syntax(ast); } } +function getFieldsFromAST(ast: AST): Field[] { + const fields: [string, Node, Node][] = + matchNode(ast, 'LetD', (pat: Node, exp: Node) => { + const name = matchNode(pat, 'VarP', (field: string) => field); + return name ? [[name, pat, exp]] : undefined; + }) || []; + return fields.map(([name, pat, exp]) => { + const field = new Field(ast, name); + field.pat = fromAST(pat); + field.exp = fromAST(exp); + return field; + }); +} + export function asNode(ast: AST | undefined): Node | undefined { return ast && typeof ast === 'object' && !Array.isArray(ast) ? ast @@ -121,17 +158,35 @@ export class Syntax { export class Program extends Syntax { imports: Import[] = []; - export: Syntax | undefined; + namedExports: Field[] = []; + export: AST | undefined; +} + +export type ObjSort = 'Object' | 'Actor' | 'Module' | 'Memory'; + +export class ObjBlock extends Syntax { + fields: Field[] = []; + + constructor(ast: AST, public sort: ObjSort) { + super(ast); + } +} + +export class Field extends Syntax { + pat: Syntax | undefined; + exp: Syntax | undefined; + + constructor(ast: AST, public name: string) { + super(ast); + } } export class Import extends Syntax { name: string | undefined; fields: [string, string][] = []; // [name, alias] - path: string; - constructor(ast: AST, path: string) { + constructor(ast: AST, public path: string) { super(ast); - this.path = path; } }