From 84578b4bd870958375b80e72c8efec88e0ae863d Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 10:49:03 -0700 Subject: [PATCH 1/8] Add 'organize imports' quick fix action --- src/server/imports.ts | 84 +++++++++++++++++++++++++++++++++++++++++-- src/server/server.ts | 34 ++++++++++++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/server/imports.ts b/src/server/imports.ts index d2b655ae..cad807a3 100644 --- a/src/server/imports.ts +++ b/src/server/imports.ts @@ -2,8 +2,8 @@ import { pascalCase } from 'change-case'; import { MultiMap } from 'mnemonist'; import { AST, Node } from 'motoko/lib/ast'; import { Context, getContext } from './context'; -import { Program, matchNode } from './syntax'; -import { getRelativeUri } from './utils'; +import { Import, Program, matchNode } from './syntax'; +import { formatMotoko, getRelativeUri } from './utils'; interface ResolvedField { name: string; @@ -198,3 +198,83 @@ function getImportInfo( } return [getImportName(uri), uri]; } + +const importGroups: { + prefix: string; +}[] = [ + { + // IC imports + prefix: 'ic:', + }, + { + // Canister alias imports + prefix: 'canister:', + }, + { + // Package imports + prefix: 'mo:', + }, + { + // Everything else + prefix: '', + }, +]; + +export function organizeImports(imports: Import[]): string { + const groupParts: string[][] = importGroups.map(() => []); + + // Combine imports with the same path + const combinedImports: Record< + string, + { names: string[]; fields: [string, string][] } + > = {}; + imports.forEach((x) => { + const combined = + combinedImports[x.path] || + (combinedImports[x.path] = { names: [], fields: [] }); + if (x.name) { + combined.names.push(x.name); + } + combined.fields.push(...x.fields); + }); + + // Sort and print imports + Object.entries(combinedImports) + .sort( + // Sort by import path + (a, b) => a[0].localeCompare(b[0]), + ) + .forEach(([path, { names, fields }]) => { + const parts = + groupParts[ + importGroups.findIndex((g) => path.startsWith(g.prefix)) + ] || groupParts[groupParts.length - 1]; + names.forEach((name) => { + parts.push(`import ${name} ${JSON.stringify(path)};`); + }); + if (fields.length) { + parts.push( + `import { ${fields + .sort( + // Sort by name, then alias + (a, b) => + a[0].localeCompare(b[0]) || + (a[1] || a[0]).localeCompare(b[1] || b[0]), + ) + .map(([name, alias]) => + !alias || name === alias + ? name + : `${name} = ${alias}`, + ) + .join('; ')} } ${JSON.stringify(path)};`, + ); + } + }); + + return formatMotoko( + groupParts + .map((p) => p.join('\n')) + .join('\n\n') + .trim(), + ); +} diff --git a/src/server/server.ts b/src/server/server.ts index 3a6fc197..a8f6093b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -19,6 +19,7 @@ import { MarkupKind, Position, ProposedFeatures, + Range, ReferenceParams, SignatureHelp, TextDocumentPositionParams, @@ -53,6 +54,7 @@ import { resolveFilePath, resolveVirtualPath, } from './utils'; +import { organizeImports } from './imports'; interface Settings { motoko: MotokoSettings; @@ -795,11 +797,39 @@ function deleteVirtual(path: string) { } connection.onCodeAction((event) => { + const uri = event.textDocument.uri; const results: CodeAction[] = []; - // Automatic imports + // Organize imports + const status = getContext(uri).astResolver.request(uri); + const imports = status?.program?.imports; + if (imports?.length) { + const start = rangeFromNode(asNode(imports[0].ast))?.start; + const end = rangeFromNode(asNode(imports[imports.length - 1].ast))?.end; + if (!start || !end) { + console.warn('Unexpected import AST range format'); + return; + } + const range = Range.create(start, end); + const source = organizeImports(imports); + [CodeActionKind.SourceOrganizeImports, CodeActionKind.QuickFix].forEach( + (kind) => { + results.push({ + kind, + title: 'Organize imports', + isPreferred: kind === CodeActionKind.SourceOrganizeImports, + edit: { + changes: { + [uri]: [TextEdit.replace(range, source)], + }, + }, + }); + }, + ); + } + + // Import quick-fix actions event.context?.diagnostics?.forEach((diagnostic) => { - const uri = event.textDocument.uri; const name = /unbound variable ([a-z0-9_]+)/i.exec( diagnostic.message, )?.[1]; From 6b7aac1e156d8b363533e75411e7e2c602023823 Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 10:50:28 -0700 Subject: [PATCH 2/8] Update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 071bb6e4..b32961dd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This extension provides syntax highlighting, type checking, and code formatting - Automatic imports - Snippets ([contributions welcome](https://github.com/dfinity/node-motoko/blob/main/contrib/snippets.json)) - Go-to-definition +- Organize imports - Documentation tooltips ## Installation From f7ff1049296c3d823f4d31568ab78b718d566251 Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 12:16:26 -0700 Subject: [PATCH 3/8] Refactor to 'restartLanguageServer()' function --- src/extension.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 05a29504..bf4ce80d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -69,7 +69,7 @@ export function startServer(context: ExtensionContext) { // Cross-platform language server const module = context.asAbsolutePath(path.join('out', 'server.js')); - launchClient(context, { + restartLanguageServer(context, { run: { module, transport: TransportKind.ipc }, debug: { module, @@ -92,7 +92,10 @@ function launchDfxProject(context: ExtensionContext, dfxConfig: DfxConfig) { command: getDfxPath(), args: ['_language-service', canister], }; - launchClient(context, { run: serverCommand, debug: serverCommand }); + restartLanguageServer(context, { + run: serverCommand, + debug: serverCommand, + }); }; const canister = config.get('canister'); @@ -114,7 +117,10 @@ function launchDfxProject(context: ExtensionContext, dfxConfig: DfxConfig) { } } -function launchClient(context: ExtensionContext, serverOptions: ServerOptions) { +function restartLanguageServer( + context: ExtensionContext, + serverOptions: ServerOptions, +) { if (client) { console.log('Restarting Motoko language server'); client.stop().catch((err) => console.error(err.stack || err)); From 74ae488507674319fc9ed2991d08c09cf1134728 Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 12:23:17 -0700 Subject: [PATCH 4/8] Adjust command info in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b32961dd..e81b1e07 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Get this extension through the [VS Marketplace](https://marketplace.visualstudio ## Extension Commands -- `motoko.startService`: Starts (or restarts) the language service +- `Motoko: Restart language server`: Starts (or restarts) the language server ## Extension Settings From f5edc4e036061c8ad7cc00117f9857e4a1566274 Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 12:23:49 -0700 Subject: [PATCH 5/8] Use generic 'Organize imports' key binding instead of quick-fix action --- src/server/server.ts | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index a8f6093b..4a91e994 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -47,7 +47,7 @@ import { rangeFromNode, } from './navigation'; import { vesselSources } from './rust'; -import { Program, findNodes, asNode } from './syntax'; +import { Program, asNode, findNodes } from './syntax'; import { formatMotoko, getFileText, @@ -389,8 +389,14 @@ connection.onInitialize((event): InitializeResult => { definitionProvider: true, // declarationProvider: true, // referencesProvider: true, - codeActionProvider: true, + codeActionProvider: { + codeActionKinds: [ + CodeActionKind.QuickFix, + CodeActionKind.SourceOrganizeImports, + ], + }, hoverProvider: true, + // executeCommandProvider: { commands: [] }, // workspaceSymbolProvider: true, // diagnosticProvider: { // documentSelector: ['motoko'], @@ -812,20 +818,16 @@ connection.onCodeAction((event) => { } const range = Range.create(start, end); const source = organizeImports(imports); - [CodeActionKind.SourceOrganizeImports, CodeActionKind.QuickFix].forEach( - (kind) => { - results.push({ - kind, - title: 'Organize imports', - isPreferred: kind === CodeActionKind.SourceOrganizeImports, - edit: { - changes: { - [uri]: [TextEdit.replace(range, source)], - }, - }, - }); + results.push({ + title: 'Organize imports', + kind: CodeActionKind.SourceOrganizeImports, + isPreferred: true, + edit: { + changes: { + [uri]: [TextEdit.replace(range, source)], + }, }, - ); + }); } // Import quick-fix actions @@ -838,9 +840,10 @@ connection.onCodeAction((event) => { context.importResolver.getImportPaths(name, uri).forEach((path) => { // Add import suggestion results.push({ + title: `Import "${path}"`, kind: CodeActionKind.QuickFix, isPreferred: true, - title: `Import "${path}"`, + diagnostics: [diagnostic], edit: { changes: { [uri]: [ From 243fd832af746508b46e413422d21c31e04466ce Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 13:00:18 -0700 Subject: [PATCH 6/8] Add format-on-save instructions to readme --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index e81b1e07..b53aa135 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,22 @@ Get this extension through the [VS Marketplace](https://marketplace.visualstudio - `motoko.formatter`: The formatter used by the extension - `motoko.legacyDfxSupport`: Uses legacy `dfx`-dependent features when a relevant `dfx.json` file is available +## Advanced Configuration + +If you want VS Code to automatically format Motoko files on save, consider adding the following to your `settings.json` configuration: + +```json +{ + "[motoko]": { + "editor.defaultFormatter": "dfinity-foundation.vscode-motoko", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + } +} +``` + ## Recent Changes Projects using `dfx >= 0.11.1` use a new, experimental language server. From 3ba5705025ca6d75d563b32c68f3cef9dfd3eaaa Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 13:01:08 -0700 Subject: [PATCH 7/8] Adjust replacement range logic to account for imports with semicolons --- src/server/imports.ts | 7 +------ src/server/server.ts | 7 +++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/server/imports.ts b/src/server/imports.ts index cad807a3..1f8986e5 100644 --- a/src/server/imports.ts +++ b/src/server/imports.ts @@ -271,10 +271,5 @@ export function organizeImports(imports: Import[]): string { } }); - return formatMotoko( - groupParts - .map((p) => p.join('\n')) - .join('\n\n') - .trim(), - ); + return formatMotoko(groupParts.map((p) => p.join('\n')).join('\n\n')); } diff --git a/src/server/server.ts b/src/server/server.ts index 4a91e994..011b9da2 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -816,8 +816,11 @@ connection.onCodeAction((event) => { console.warn('Unexpected import AST range format'); return; } - const range = Range.create(start, end); - const source = organizeImports(imports); + const range = Range.create( + Position.create(start.line, 0), + Position.create(end.line + 1, 0), + ); + const source = organizeImports(imports).trim() + '\n'; results.push({ title: 'Organize imports', kind: CodeActionKind.SourceOrganizeImports, From ea99592bdb65fcc1ad973432b446cb36b25a8620 Mon Sep 17 00:00:00 2001 From: rvanasa Date: Mon, 2 Jan 2023 13:47:43 -0700 Subject: [PATCH 8/8] Remove extra line wrapping in import group configuration --- src/server/imports.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/server/imports.ts b/src/server/imports.ts index 1f8986e5..17e52788 100644 --- a/src/server/imports.ts +++ b/src/server/imports.ts @@ -202,22 +202,14 @@ function getImportInfo( const importGroups: { prefix: string; }[] = [ - { - // IC imports - prefix: 'ic:', - }, - { - // Canister alias imports - prefix: 'canister:', - }, - { - // Package imports - prefix: 'mo:', - }, - { - // Everything else - prefix: '', - }, + // IC imports + { prefix: 'ic:' }, + // Canister alias imports + { prefix: 'canister:' }, + // Package imports + { prefix: 'mo:' }, + // Everything else + { prefix: '' }, ]; export function organizeImports(imports: Import[]): string {