diff --git a/docs/plugins.md b/docs/plugins.md index a84e3fe85..f94fbabbf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -93,11 +93,7 @@ After file addition/removal (note: throttled/debounced): - `afterProgramValidate` Code Actions - - `beforeProgramGetCodeActions` - - `onFileGetCodeActions` - - for each scope that includes the file - - `onScopeGetCodeActions` - - `afterProgramGetCodeActions` + - `onProgramGetCodeActions` ## Compiler API diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 5e075a822..448bee61a 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -1,7 +1,6 @@ -/* eslint-disable camelcase */ - import type { Position } from 'vscode-languageserver'; import { DiagnosticSeverity } from 'vscode-languageserver'; +import type { BsDiagnostic } from './interfaces'; import type { TokenKind } from './lexer/TokenKind'; /** @@ -644,4 +643,7 @@ export interface DiagnosticInfo { * The second type parameter is optional, but allows plugins to pass in their own * DiagnosticMessages-like object in order to get the same type support */ -export type DiagnosticMessageType any> = typeof DiagnosticMessages> = ReturnType; +export type DiagnosticMessageType any> = typeof DiagnosticMessages> = + ReturnType & + //include the missing properties from BsDiagnostic + Pick; diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index fc00c95f6..8074fc4e8 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -31,7 +31,6 @@ import { } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import { TextDocument } from 'vscode-languageserver-textdocument'; - import type { BsConfig } from './BsConfig'; import { Deferred } from './deferred'; import { DiagnosticMessages } from './DiagnosticMessages'; @@ -554,17 +553,23 @@ export class LanguageServer { //ensure programs are initialized await this.waitAllProgramFirstRuns(); - let filePath = util.uriToPath(params.textDocument.uri); + let srcPath = util.uriToPath(params.textDocument.uri); //wait until the file has settled - await this.keyedThrottler.onIdleOnce(filePath, true); + await this.keyedThrottler.onIdleOnce(srcPath, true); - let codeActions = this + const codeActions = this .getWorkspaces() //skip programs that don't have this file - .filter(x => x.builder.program.hasFile(filePath)) - .flatMap(workspace => workspace.builder.program.getCodeActions(filePath, params.range)); + .filter(x => x.builder?.program?.hasFile(srcPath)) + .flatMap(workspace => workspace.builder.program.getCodeActions(srcPath, params.range)); + //clone the diagnostics for each code action, since certain diagnostics can have circular reference properties that kill the language server if serialized + for (const codeAction of codeActions) { + if (codeAction.diagnostics) { + codeAction.diagnostics = codeAction.diagnostics.map(x => util.toDiagnostic(x)); + } + } return codeActions; } @@ -1051,16 +1056,7 @@ export class LanguageServer { const patch = await this.diagnosticCollection.getPatch(this.workspaces); for (let filePath in patch) { - const diagnostics = patch[filePath].map(d => { - return { - severity: d.severity, - range: d.range, - message: d.message, - relatedInformation: d.relatedInformation, - code: d.code, - source: 'brs' - }; - }); + const diagnostics = patch[filePath].map(d => util.toDiagnostic(d)); this.connection.sendDiagnostics({ uri: URI.file(filePath).toString(), diff --git a/src/Program.ts b/src/Program.ts index ad67bc72e..cb3b621bf 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -783,23 +783,24 @@ export class Program { const codeActions = [] as CodeAction[]; const file = this.getFile(pathAbsolute); if (file) { + const diagnostics = this + //get all current diagnostics (filtered by diagnostic filters) + .getDiagnostics() + //only keep diagnostics related to this file + .filter(x => x.file === file) + //only keep diagnostics that touch this range + .filter(x => util.rangesIntersect(x.range, range)); - this.plugins.emit('beforeProgramGetCodeActions', this, file, range, codeActions); - - //get code actions from the file - file.getCodeActions(range, codeActions); - - //get code actions from every scope this file is a member of - for (let key in this.scopes) { - let scope = this.scopes[key]; - - if (scope.hasFile(file)) { - //get code actions from each scope this file is a member of - scope.getCodeActions(file, range, codeActions); - } - } + const scopes = this.getScopesForFile(file); - this.plugins.emit('afterProgramGetCodeActions', this, file, range, codeActions); + this.plugins.emit('onGetCodeActions', { + program: this, + file: file, + range: range, + diagnostics: diagnostics, + scopes: scopes, + codeActions: codeActions + }); } return codeActions; } @@ -1179,6 +1180,25 @@ export class Program { this.plugins.emit('afterProgramTranspile', this, entries); } + /** + * Find a list of files in the program that have a function with the given name (case INsensitive) + */ + public findFilesForFunction(functionName: string) { + const files = [] as BscFile[]; + const lowerFunctionName = functionName.toLowerCase(); + //find every file with this function defined + for (const file of Object.values(this.files)) { + if (isBrsFile(file)) { + //TODO handle namespace-relative function calls + //if the file has a function with this name + if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) { + files.push(file); + } + } + } + return files; + } + /** * Get a map of the manifest information */ diff --git a/src/Scope.ts b/src/Scope.ts index 30f58e29b..c3a9b2787 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -1,4 +1,4 @@ -import type { CodeAction, CompletionItem, Position, Range } from 'vscode-languageserver'; +import type { CompletionItem, Position, Range } from 'vscode-languageserver'; import * as path from 'path'; import { CompletionItemKind, Location } from 'vscode-languageserver'; import chalk from 'chalk'; @@ -244,11 +244,6 @@ export class Scope { this.diagnostics.push(...diagnostics); } - public getCodeActions(file: BscFile, range: Range, codeActions: CodeAction[]) { - const diagnostics = this.diagnostics.filter(x => x.range?.start.line === range.start.line); - this.program.plugins.emit('onScopeGetCodeActions', this, file, range, diagnostics, codeActions); - } - /** * Get the list of callables available in this scope (either declared in this scope or in a parent scope) */ diff --git a/src/XmlScope.ts b/src/XmlScope.ts index da26d43c2..ecad3d547 100644 --- a/src/XmlScope.ts +++ b/src/XmlScope.ts @@ -1,4 +1,4 @@ -import type { CodeAction, Location, Position, Range } from 'vscode-languageserver'; +import type { Location, Position } from 'vscode-languageserver'; import { Scope } from './Scope'; import { DiagnosticMessages } from './DiagnosticMessages'; import type { XmlFile } from './files/XmlFile'; @@ -153,11 +153,6 @@ export class XmlScope extends Scope { }); } - public getCodeActions(file: BscFile, range: Range, codeActions: CodeAction[]) { - const relevantDiagnostics = this.diagnostics.filter(x => x.range?.start.line === range.start.line); - this.program.plugins.emit('onScopeGetCodeActions', this, file, range, relevantDiagnostics, codeActions); - } - /** * Get the definition (where was this thing first defined) of the symbol under the position */ diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts index 15d0d9d7d..a60c38197 100644 --- a/src/bscPlugin/BscPlugin.ts +++ b/src/bscPlugin/BscPlugin.ts @@ -1,14 +1,10 @@ -import type { CodeAction, Range } from 'vscode-languageserver'; -import { isXmlFile } from '../astUtils/reflection'; -import type { BscFile, BsDiagnostic, CompilerPlugin } from '../interfaces'; -import { XmlFileCodeActionsProcessor } from './codeActions/XmlFileCodeActionsProcessor'; +import type { OnGetCodeActionsEvent, CompilerPlugin } from '../interfaces'; +import { CodeActionsProcessor } from './codeActions/CodeActionsProcessor'; export class BscPlugin implements CompilerPlugin { public name = 'BscPlugin'; - public onFileGetCodeActions(file: BscFile, range: Range, diagnostics: BsDiagnostic[], codeActions: CodeAction[]) { - if (isXmlFile(file)) { - new XmlFileCodeActionsProcessor(file, range, diagnostics, codeActions).process(); - } + public onGetCodeActions(event: OnGetCodeActionsEvent) { + new CodeActionsProcessor(event).process(); } } diff --git a/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts b/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts new file mode 100644 index 000000000..20c82dddc --- /dev/null +++ b/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai'; +import { URI } from 'vscode-uri'; +import { Program } from '../../Program'; +import { expectCodeActions, trim } from '../../testHelpers.spec'; +import { standardizePath as s, util } from '../../util'; + +const rootDir = s`${process.cwd()}/.tmp/rootDir`; +describe('CodeActionsProcessor', () => { + let program: Program; + beforeEach(() => { + program = new Program({ + rootDir: rootDir + }); + }); + afterEach(() => { + program.dispose(); + }); + + describe('getCodeActions', () => { + it('suggests `extends=Group`', () => { + const file = program.addOrReplaceFile('components/comp1.xml', trim` + + + + `); + expectCodeActions(() => { + program.getCodeActions( + file.pathAbsolute, + // + util.createRange(1, 5, 1, 5) + ); + }, [{ + title: `Extend "Group"`, + isPreferred: true, + kind: 'quickfix', + changes: [{ + filePath: s`${rootDir}/components/comp1.xml`, + newText: ' extends="Group"', + type: 'insert', + // + position: util.createPosition(1, 23) + }] + }, { + title: `Extend "Task"`, + kind: 'quickfix', + changes: [{ + filePath: s`${rootDir}/components/comp1.xml`, + newText: ' extends="Task"', + type: 'insert', + // + position: util.createPosition(1, 23) + }] + }, { + title: `Extend "ContentNode"`, + kind: 'quickfix', + changes: [{ + filePath: s`${rootDir}/components/comp1.xml`, + newText: ' extends="ContentNode"', + type: 'insert', + // + position: util.createPosition(1, 23) + }] + }]); + }); + + it('adds attribute at end of component with multiple attributes`', () => { + const file = program.addOrReplaceFile('components/comp1.xml', trim` + + + + `); + const codeActions = program.getCodeActions( + file.pathAbsolute, + // + util.createRange(1, 5, 1, 5) + ); + + expect( + codeActions[0].edit.changes[URI.file(s`${rootDir}/components/comp1.xml`).toString()][0].range + ).to.eql( + util.createRange(1, 51, 1, 51) + ); + }); + }); + + it('does not produce duplicate code actions for bs imports', () => { + //define the function in two files + program.addOrReplaceFile('components/lib1.brs', ` + sub doSomething() + end sub + `); + program.addOrReplaceFile('components/lib2.brs', ` + sub doSomething() + end sub + `); + + //use the function in this file + const componentCommonFile = program.addOrReplaceFile('components/ComponentCommon.bs', ` + sub init() + doSomething() + end sub + `); + + //import the file in two scopes + program.addOrReplaceFile('components/comp1.xml', trim` + + +