diff --git a/.vscode/launch.json b/.vscode/launch.json index aca18b56..3c518722 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,6 +15,7 @@ "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "outFiles": ["${workspaceFolder}/out/**/*.js"], + "autoAttachChildProcesses": true, "preLaunchTask": { "type": "npm", "script": "compile" @@ -25,7 +26,9 @@ "request": "attach", "name": "Attach to Server", "port": 6009, + "address": "localhost", "restart": true, + "sourceMaps": true, "outFiles": ["${workspaceFolder}/out/**/*.js"] }, { diff --git a/README.md b/README.md index ee6e9fa4..702d6094 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # Wollok IDE @@ -40,6 +40,6 @@ Do you want to contribute? Great, you are always welcome! ## 👥 Contributors -ivojawer fdodino PalumboN npasserini dependabot[bot] Miranda-03 FerRomMu  +ivojawer PalumboN fdodino npasserini dependabot[bot] Miranda-03 FerRomMu  diff --git a/package.json b/package.json index be921bde..2daf6cb2 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "renameProvider": { "prepareProvider": true }, - "hoverProvider": true + "hoverProvider": true, + "codeActionProvider": true }, "main": "./out/client/src/extension", "configurationDefaults": { @@ -400,10 +401,11 @@ "lint-staged": "lint-staged" }, "dependencies": { - "wollok-ts": "4.2.0" + "wollok-ts": "../wollok-ts" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/node": "^18.14.1", "@types/source-map-support": "^0", "@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/parser": "^5.53.0", diff --git a/packages/client/package.json b/packages/client/package.json index 2d9ba151..fc2b1c09 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -17,7 +17,6 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "^18.14.1", "@types/sinon": "^10.0.13", "@types/vscode": "^1.80.0", "@vscode/test-electron": "^2.3.9", diff --git a/packages/client/src/test/code-actions.test.ts b/packages/client/src/test/code-actions.test.ts new file mode 100644 index 00000000..d7af47fc --- /dev/null +++ b/packages/client/src/test/code-actions.test.ts @@ -0,0 +1,63 @@ +import * as assert from 'assert' +import { commands, Range, Uri, CodeLens, CodeAction, Position, CodeActionKind, WorkspaceEdit } from 'vscode' +import { getDocumentURI, activate } from './helper' + +suite('Should do code actions', () => { + const missingReferenceDoc = getDocumentURI('missingReference.wlk') + const codeActionsDoc = getDocumentURI('codeActions.wlk') + + test('Gets quick fixes for missingReference', async () => { + const quickfix = new CodeAction('Import from imported.wlk', CodeActionKind.QuickFix) + quickfix.edit = new WorkspaceEdit() + quickfix.edit.insert(missingReferenceDoc, new Position(0, 0), `import imported.obj\n`) + quickfix.isPreferred = true + await testCodeActions( + missingReferenceDoc, + new Range(new Position(2, 13), new Position(2, 13)), + [quickfix] + ) + }) + + test('Gets quick fixes for shouldDefineConstInsteadOfVar', async () => { + const quickfix = new CodeAction('Convert to const', CodeActionKind.QuickFix) + quickfix.edit = new WorkspaceEdit() + quickfix.edit.replace(codeActionsDoc, new Range(new Position(1, 2), new Position(1, 9)), `const bar`) + quickfix.isPreferred = true + await testCodeActions( + codeActionsDoc, + new Range(new Position(1, 8), new Position(1, 8)), + [quickfix] + ) + }) + + test('Gets quick fixes for shouldNotReassignConst', async () => { + const quickfix = new CodeAction('Convert quux to var', CodeActionKind.QuickFix) + quickfix.edit = new WorkspaceEdit() + quickfix.edit.replace(codeActionsDoc, new Range(new Position(8, 4), new Position(8, 18)), `var quux = 2`) + quickfix.isPreferred = true + await testCodeActions( + codeActionsDoc, + new Range(new Position(9, 7), new Position(9, 7)), + [quickfix] + ) + }) +}) +async function testCodeActions( + docUri: Uri, + triggerRange: Range, + expectedCodeActionList: CodeAction[], +) { + await activate(docUri) + docUri['_fsPath'] = docUri.fsPath + const actualCodeActions = (await commands.executeCommand( + 'vscode.executeCodeActionProvider', + docUri, + triggerRange, + )) as CodeLens[] | null + + assert.deepEqual( + actualCodeActions, + expectedCodeActionList, + 'Code actions mismatch', + ) +} diff --git a/packages/client/testFixture/codeActions.wlk b/packages/client/testFixture/codeActions.wlk new file mode 100644 index 00000000..23ef202f --- /dev/null +++ b/packages/client/testFixture/codeActions.wlk @@ -0,0 +1,12 @@ +object foo{ + var bar + method a() { + return bar + } +} +object baz { + method qux() { + const quux = 2 + quux = 3 + } +} \ No newline at end of file diff --git a/packages/client/testFixture/missingReference.wlk b/packages/client/testFixture/missingReference.wlk new file mode 100644 index 00000000..8d83b958 --- /dev/null +++ b/packages/client/testFixture/missingReference.wlk @@ -0,0 +1,5 @@ +object foo { + method bar(){ + return obj + } +} \ No newline at end of file diff --git a/packages/debug-adapter/package.json b/packages/debug-adapter/package.json index ed0c7641..42b603b3 100644 --- a/packages/debug-adapter/package.json +++ b/packages/debug-adapter/package.json @@ -19,7 +19,6 @@ "devDependencies": { "@types/chai": "^4", "@types/mocha": "^10", - "@types/node": "^18.14.1", "@types/vscode": "^1.92.0", "@vscode/debugadapter-testsupport": "^1.67.0", "chai": "4.3.10", diff --git a/packages/debug-adapter/src/test/debug-adapter.test.ts b/packages/debug-adapter/src/test/debug-adapter.test.ts index c9c55e56..c8899613 100644 --- a/packages/debug-adapter/src/test/debug-adapter.test.ts +++ b/packages/debug-adapter/src/test/debug-adapter.test.ts @@ -181,7 +181,7 @@ describe('debug adapter', function () { }).then(async () => { await Promise.all([ dc.assertOutput('stderr', "My exception message", 3000), - dc.waitForEvent('terminated', 2000) + dc.waitForEvent('terminated', 2000), ]) resolve("Finished") }) @@ -202,7 +202,7 @@ describe('debug adapter', function () { }).then(async () => { await Promise.all([ dc.assertOutput('stdout', "Finished executing without errors", 1000), - dc.waitForEvent('terminated', 1000) + dc.waitForEvent('terminated', 1000), ]) resolve("Finished") }) diff --git a/packages/server/package.json b/packages/server/package.json index 77eadd8b..e5975d9b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,7 +23,6 @@ "devDependencies": { "@types/expect": "^24.3.0", "@types/mocha": "^10", - "@types/node": "^18.14.1", "@types/sinon": "^10.0.13", "expect": "^29.7.0", "jest": "^29.7.0", diff --git a/packages/server/src/functionalities/autocomplete/autocomplete.ts b/packages/server/src/functionalities/autocomplete/autocomplete.ts index 09e18cae..feb06f67 100644 --- a/packages/server/src/functionalities/autocomplete/autocomplete.ts +++ b/packages/server/src/functionalities/autocomplete/autocomplete.ts @@ -7,6 +7,7 @@ import { completionsForNode } from './node-completion' import { completeMessages } from './send-completion' import { match, when } from 'wollok-ts/dist/extensions' import { logger } from '../../utils/logger' +import { writeImportFor } from '../../utils/imports' export const completions = (environment: Environment) => ( params: CompletionParams, @@ -45,7 +46,7 @@ export const fieldCompletionItem: CompletionItemMapper = namedCompletionI export const singletonCompletionItem: CompletionItemMapper = moduleCompletionItem(CompletionItemKind.Class) -export const withImport = (mapper: CompletionItemMapper) => (relativeTo: Node): CompletionItemMapper => (node) => { +export const withImport = (mapper: CompletionItemMapper) => (relativeTo: Node): CompletionItemMapper => (node) => { const importedPackage = node.parentPackage! const originalPackage = relativeTo.parentPackage! @@ -57,7 +58,7 @@ export const withImport = (mapper: CompletionItemMapper) => ( ) { result.detail = `Add import ${importedPackage.fileName ? relativeFilePath(packageToURI(importedPackage)) : importedPackage.name}${result.detail ? ` - ${result.detail}` : ''}` result.additionalTextEdits = (result.additionalTextEdits ?? []).concat( - TextEdit.insert(Position.create(0, 0), `import ${importedPackage.name}.*\n`) + TextEdit.insert(Position.create(0, 0), `${writeImportFor(importedPackage)}\n`) ) } diff --git a/packages/server/src/functionalities/code-actions.ts b/packages/server/src/functionalities/code-actions.ts new file mode 100644 index 00000000..b70dc8d8 --- /dev/null +++ b/packages/server/src/functionalities/code-actions.ts @@ -0,0 +1,69 @@ +import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic } from 'vscode-languageserver' +import { Assignment, Class, Environment, Field, Mixin, Node, possiblyReferenced, print, Problem, Reference, Singleton, validate, Variable } from 'wollok-ts' +import { writeImportFor } from '../utils/imports' +import { packageFromURI, rangeIncludes, toVSCRange, uriFromRelativeFilePath } from '../utils/text-documents' + +type CodeActionResponse = Array + +export const codeActions = (environment: Environment) => (params: CodeActionParams): CodeActionResponse => { + const problems = validate(packageFromURI(params.textDocument.uri, environment)) + const problemsInRange = problems.filter(problem => rangeIncludes(toVSCRange(problem.sourceMap), params.range)) + if(problemsInRange.length === 0) return null + return problemsInRange.flatMap(problem => { + const fixer = fixers[problem.code] + if (!fixer) return [] + const diagnostics = matchDiagnostics(problem, params.context.diagnostics) + return fixer(problem.node).map(action => ({ ...action, diagnostics: diagnostics })) + }) +} + +// FIXERS // +type Fixer = (node: Node) => CodeActionResponse +const fixers: Record = { + missingReference: fixByImporting, + shouldReferenceToObjects: fixByImporting, + shouldDefineConstInsteadOfVar: (variable: Field | Variable) => changeConstantValue(variable, true, false), + shouldNotReassignConst: (assignment: Assignment) => changeConstantValue(assignment.variable.target, false, true), +} + +function changeConstantValue(variable: Variable | Field, newValue: boolean, displayVarInTitle = false): CodeActionResponse { + const copiedVar = variable.copy({ isConstant: newValue }) + return [{ + title: `Convert ${displayVarInTitle ? variable.name + ' ' : ''}to ${newValue ? 'const' : 'var'}`, + kind: CodeActionKind.QuickFix, + isPreferred: true, + edit: { + changes: { + [uriFromRelativeFilePath(variable.sourceFileName)]: [{ + newText: print(copiedVar), + range: toVSCRange(variable.sourceMap!), + }], + }, + }, + }] +} + +function matchDiagnostics(problem: Problem, diagnostics: Diagnostic[]): Diagnostic[] { + return diagnostics.filter(diagnostic => diagnostic.code === problem.code) +} + +const isImportableNode = (node: Node): node is Singleton | Class | Mixin => node.is(Singleton) || node.is(Class) || node.is(Mixin) + +function fixByImporting(node: Reference): CodeActionResponse { + const targets = possiblyReferenced(node, node.environment).filter(isImportableNode) + + return targets.map(target => { + return { + title: `Import from ${target.sourceFileName}`, + kind: CodeActionKind.QuickFix, + isPreferred: true, + edit: { + changes: { + [uriFromRelativeFilePath(node.sourceFileName)]: [{ + newText: `${writeImportFor(target)}\n`, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }], + }, + }, + }}) +} \ No newline at end of file diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8ff4f731..e41c2ff9 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -31,6 +31,7 @@ import { setWorkspaceUri, WORKSPACE_URI } from './utils/text-documents' import { EnvironmentProvider } from './utils/vm/environment' import { completions } from './functionalities/autocomplete/autocomplete' import { ERROR_MISSING_WORKSPACE_FOLDER, getLSPMessage, SERVER_PROCESSING_REQUEST } from './functionalities/reporter' +import { codeActions } from './functionalities/code-actions' export type ClientConfigurations = { formatter: { abbreviateAssignments: boolean, maxWidth: number } @@ -86,6 +87,7 @@ connection.onInitialize((params: InitializeParams) => { renameProvider: { prepareProvider: true }, documentFormattingProvider: true, documentRangeFormattingProvider: true, + codeActionProvider: true, }, } if (hasWorkspaceFolderCapability) { @@ -212,6 +214,7 @@ const handlers: readonly [ [connection.onRenameRequest, rename(documents)], [connection.onHover, typeDescriptionOnHover], [connection.onReferences, references], + [connection.onCodeAction, codeActions], ] try { diff --git a/packages/server/src/utils/imports.ts b/packages/server/src/utils/imports.ts new file mode 100644 index 00000000..d0b52b7b --- /dev/null +++ b/packages/server/src/utils/imports.ts @@ -0,0 +1,3 @@ +import { Entity, Import, Package, print, Reference } from 'wollok-ts' + +export const writeImportFor = (node: Entity): string => print(new Import({ entity: new Reference({ name: node.fullyQualifiedName }), isGeneric: node.is(Package) })) diff --git a/packages/server/src/utils/text-documents.ts b/packages/server/src/utils/text-documents.ts index cd677f9f..15669aba 100644 --- a/packages/server/src/utils/text-documents.ts +++ b/packages/server/src/utils/text-documents.ts @@ -51,6 +51,13 @@ export const toVSCPosition = (position: SourceIndex): Position => export const toVSCRange = (sourceMap: SourceMap): Range => Range.create(toVSCPosition(sourceMap.start), toVSCPosition(sourceMap.end)) + +export function rangeIncludes(range: Range, included: Range): boolean { + const start = range.start + const end = range.end + return between(included.start, start, end) && between(included.end, start, end) +} + export const nodeToLocation = (node: Node): Location => { if(!node.sourceFileName) throw new Error('No source file found for node') diff --git a/yarn.lock b/yarn.lock index f6a653ea..e7b94d70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8936,7 +8936,6 @@ __metadata: dependencies: "@types/chai": "npm:^4" "@types/mocha": "npm:^10" - "@types/node": "npm:^18.14.1" "@types/vscode": "npm:^1.92.0" "@vscode/debugadapter": "npm:^1.66.0" "@vscode/debugadapter-testsupport": "npm:^1.67.0" @@ -8953,7 +8952,6 @@ __metadata: resolution: "wollok-lsp-ide-client@workspace:packages/client" dependencies: "@types/mocha": "npm:^10.0.1" - "@types/node": "npm:^18.14.1" "@types/sinon": "npm:^10.0.13" "@types/vscode": "npm:^1.80.0" "@vscode/test-electron": "npm:^2.3.9" @@ -8973,7 +8971,6 @@ __metadata: dependencies: "@types/expect": "npm:^24.3.0" "@types/mocha": "npm:^10" - "@types/node": "npm:^18.14.1" "@types/sinon": "npm:^10.0.13" expect: "npm:^29.7.0" jest: "npm:^29.7.0" @@ -8993,6 +8990,7 @@ __metadata: resolution: "wollok-lsp-ide@workspace:." dependencies: "@istanbuljs/nyc-config-typescript": "npm:^1.0.2" + "@types/node": "npm:^18.14.1" "@types/source-map-support": "npm:^0" "@typescript-eslint/eslint-plugin": "npm:^5.53.0" "@typescript-eslint/parser": "npm:^5.53.0" @@ -9009,21 +9007,21 @@ __metadata: source-map-support: "npm:^0.5.21" ts-node: "npm:^10.9.1" typescript: "npm:^4.9.5" - wollok-ts: "npm:4.2.0" + wollok-ts: ../wollok-ts yarn-run-all: "npm:^3.1.1" languageName: unknown linkType: soft -"wollok-ts@npm:4.2.0": - version: 4.2.0 - resolution: "wollok-ts@npm:4.2.0" +"wollok-ts@file:../wollok-ts::locator=wollok-lsp-ide%40workspace%3A.": + version: 4.2.1 + resolution: "wollok-ts@file:../wollok-ts#../wollok-ts::hash=50fd94&locator=wollok-lsp-ide%40workspace%3A." dependencies: "@types/parsimmon": "npm:^1.10.8" parsimmon: "npm:^1.18.1" prettier-printer: "npm:^1.1.4" unraw: "npm:^3.0.0" uuid: "npm:^9.0.1" - checksum: 10c0/5606c36a79ab486127122ba804e3df6c686cb4b1212c4dd64a1be30f58e4c787d50965b1625a8f4213116c32dfb955176a6d4722f03cf6bf03d0ba98af86f122 + checksum: 10c0/50512b6c4ad75f5c19f0d031e55a2a1957f51eb338ad9f8d08f1f1781b2938d4d93a170761b1f07425087ef74b4be7a6be08b366230ec25862f39d238d9aa2f2 languageName: node linkType: hard