diff --git a/src/CodeActionUtil.ts b/src/CodeActionUtil.ts new file mode 100644 index 000000000..76b4b71ee --- /dev/null +++ b/src/CodeActionUtil.ts @@ -0,0 +1,52 @@ +import type { CodeActionKind, Diagnostic, Position, Range, WorkspaceEdit } from 'vscode-languageserver'; +import { CodeAction, TextEdit } from 'vscode-languageserver'; +import { URI } from 'vscode-uri'; + +export class CodeActionUtil { + + public createCodeAction(obj: CodeActionShorthand) { + const edit = { + changes: {} + } as WorkspaceEdit; + for (const change of obj.changes) { + const uri = URI.file(change.filePath).toString(); + + //create the edit changes array for this uri + if (!edit.changes[uri]) { + edit.changes[uri] = []; + } + if (change.type === 'insert') { + edit.changes[uri].push( + TextEdit.insert(change.position, change.newText) + ); + } else if (change.type === 'replace') { + TextEdit.replace(change.range, change.newText); + } + } + return CodeAction.create(obj.title, edit); + } +} + +export interface CodeActionShorthand { + title: string; + diagnostics?: Diagnostic[]; + kind?: CodeActionKind; + isPreferred?: boolean; + changes: Array; +} + +export interface InsertChange { + filePath: string; + newText: string; + type: 'insert'; + position: Position; +} + +export interface ReplaceChange { + filePath: string; + newText: string; + type: 'replace'; + range: Range; +} + +export const codeActionUtil = new CodeActionUtil(); diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 2ffb9a14d..5e075a822 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -17,6 +17,9 @@ export let DiagnosticMessages = { callToUnknownFunction: (name: string, scopeName: string) => ({ message: `Cannot find function with name '${name}' when this file is included in scope '${scopeName}'`, code: 1001, + data: { + functionName: name + }, severity: DiagnosticSeverity.Error }), mismatchArgumentCount: (expectedCount: number | string, actualCount: number) => ({ @@ -623,15 +626,22 @@ export let DiagnosticMessages = { }) }; -let allCodes = [] as number[]; +export const DiagnosticCodeMap = {} as Record; +export let diagnosticCodes = [] as number[]; for (let key in DiagnosticMessages) { - allCodes.push(DiagnosticMessages[key]().code); + diagnosticCodes.push(DiagnosticMessages[key]().code); + DiagnosticCodeMap[key] = DiagnosticMessages[key]().code; } -export let diagnosticCodes = allCodes; - export interface DiagnosticInfo { message: string; code: number; severity: DiagnosticSeverity; } + +/** + * Provides easy type support for the return value of any DiagnosticMessage function. + * 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; diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts index 2178d1159..15787343e 100644 --- a/src/LanguageServer.spec.ts +++ b/src/LanguageServer.spec.ts @@ -52,6 +52,7 @@ describe('LanguageServer', () => { sendNotification: () => null, sendDiagnostics: () => null, onExecuteCommand: () => null, + onCodeAction: () => null, onDidOpenTextDocument: () => null, onDidChangeTextDocument: () => null, onDidCloseTextDocument: () => null, diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index c591e2c78..e7ad949a6 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -17,7 +17,8 @@ import type { DocumentSymbolParams, ReferenceParams, SignatureHelp, - SignatureHelpParams + SignatureHelpParams, + CodeActionParams } from 'vscode-languageserver'; import { createConnection, @@ -25,7 +26,8 @@ import { FileChangeType, ProposedFeatures, TextDocuments, - TextDocumentSyncKind + TextDocumentSyncKind, + CodeActionKind } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import { TextDocument } from 'vscode-languageserver-textdocument'; @@ -143,6 +145,8 @@ export class LanguageServer { this.connection.onReferences(this.onReferences.bind(this)); + this.connection.onCodeAction(this.onCodeAction.bind(this)); + /* this.connection.onDidOpenTextDocument((params) => { // A text document got opened in VSCode. @@ -196,6 +200,9 @@ export class LanguageServer { documentSymbolProvider: true, workspaceSymbolProvider: true, referencesProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.Refactor] + }, signatureHelpProvider: { triggerCharacters: ['(', ','] }, @@ -543,6 +550,22 @@ export class LanguageServer { return item; } + private async onCodeAction(params: CodeActionParams) { + //ensure programs are initialized + await this.waitAllProgramFirstRuns(); + + let filePath = util.uriToPath(params.textDocument.uri); + + //wait until the file has settled + await this.keyedThrottler.onIdleOnce(filePath, true); + + let codeActions = this + .getWorkspaces() + .flatMap(workspace => workspace.builder.program.getCodeActions(filePath, params.range)); + + return codeActions; + } + /** * Reload all specified workspaces, or all workspaces if no workspaces are specified */ diff --git a/src/PluginInterface.ts b/src/PluginInterface.ts index f0d4526ba..64215d035 100644 --- a/src/PluginInterface.ts +++ b/src/PluginInterface.ts @@ -26,6 +26,18 @@ export default class PluginInterface } } + /** + * Add a plugin to the beginning of the list of plugins + */ + public addFirst(plugin: CompilerPlugin) { + if (!this.has(plugin)) { + this.plugins.unshift(plugin); + } + } + + /** + * Add a plugin to the end of the list of plugins + */ public add(plugin: CompilerPlugin) { if (!this.has(plugin)) { this.plugins.push(plugin); diff --git a/src/Program.ts b/src/Program.ts index 7138bf433..dd20729c2 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; import * as path from 'path'; -import type { CompletionItem, Position, SignatureInformation } from 'vscode-languageserver'; +import type { CodeAction, CompletionItem, Position, Range, SignatureInformation } from 'vscode-languageserver'; import { Location, CompletionItemKind } from 'vscode-languageserver'; import type { BsConfig } from './BsConfig'; import { Scope } from './Scope'; @@ -24,6 +24,7 @@ import { isBrsFile, isXmlFile, isClassMethodStatement, isXmlScope } from './astU import type { FunctionStatement, Statement } from './parser/Statement'; import { ParseMode } from './parser'; import { TokenKind } from './lexer'; +import { BscPlugin } from './bscPlugin/BscPlugin'; const startOfSourcePkgPath = `source${path.sep}`; export interface SourceObj { @@ -67,7 +68,10 @@ export class Program { ) { this.options = util.normalizeConfig(options); this.logger = logger || new Logger(options.logLevel as LogLevel); - this.plugins = plugins || new PluginInterface([], undefined); + this.plugins = plugins || new PluginInterface([], this.logger); + + //inject the bsc plugin as the first plugin in the stack. + this.plugins.addFirst(new BscPlugin()); //normalize the root dir path this.options.rootDir = util.getRootDir(this.options); @@ -734,6 +738,32 @@ export class Program { return Promise.resolve(file.getHover(position)); } + /** + * Compute code actions for the given file and range + */ + public getCodeActions(pathAbsolute: string, range: Range) { + const codeActions = [] as CodeAction[]; + const file = this.getFile(pathAbsolute); + + 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); + } + } + + this.plugins.emit('afterProgramGetCodeActions', this, file, range, codeActions); + return codeActions; + } + public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] { let file: BrsFile = this.getFile(filepath); if (!file || !isBrsFile(file)) { diff --git a/src/Scope.ts b/src/Scope.ts index 80da7e3fc..aedc786e1 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -1,4 +1,4 @@ -import type { CompletionItem, Position, Range } from 'vscode-languageserver'; +import type { CodeAction, CompletionItem, Position, Range } from 'vscode-languageserver'; import { CompletionItemKind, Location } from 'vscode-languageserver'; import chalk from 'chalk'; import type { DiagnosticInfo } from './DiagnosticMessages'; @@ -243,6 +243,11 @@ 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 ecad3d547..da26d43c2 100644 --- a/src/XmlScope.ts +++ b/src/XmlScope.ts @@ -1,4 +1,4 @@ -import type { Location, Position } from 'vscode-languageserver'; +import type { CodeAction, Location, Position, Range } from 'vscode-languageserver'; import { Scope } from './Scope'; import { DiagnosticMessages } from './DiagnosticMessages'; import type { XmlFile } from './files/XmlFile'; @@ -153,6 +153,11 @@ 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 new file mode 100644 index 000000000..15d0d9d7d --- /dev/null +++ b/src/bscPlugin/BscPlugin.ts @@ -0,0 +1,14 @@ +import type { CodeAction, Range } from 'vscode-languageserver'; +import { isXmlFile } from '../astUtils/reflection'; +import type { BscFile, BsDiagnostic, CompilerPlugin } from '../interfaces'; +import { XmlFileCodeActionsProcessor } from './codeActions/XmlFileCodeActionsProcessor'; + +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(); + } + } +} diff --git a/src/bscPlugin/codeActions/XmlFileCodeActionsProcessor.ts b/src/bscPlugin/codeActions/XmlFileCodeActionsProcessor.ts new file mode 100644 index 000000000..a1962a2aa --- /dev/null +++ b/src/bscPlugin/codeActions/XmlFileCodeActionsProcessor.ts @@ -0,0 +1,71 @@ +import type { CodeAction, Range } from 'vscode-languageserver'; +import { CodeActionKind } from 'vscode-languageserver'; +import { codeActionUtil } from '../../CodeActionUtil'; +import { DiagnosticCodeMap } from '../../DiagnosticMessages'; +import type { XmlFile } from '../../files/XmlFile'; +import type { BsDiagnostic } from '../../interfaces'; + +export class XmlFileCodeActionsProcessor { + public constructor( + public file: XmlFile, + public range: Range, + public diagnostics: BsDiagnostic[], + public codeActions: CodeAction[] + ) { + + } + + public process() { + for (const diagnostic of this.diagnostics) { + if (diagnostic.code === DiagnosticCodeMap.xmlComponentMissingExtendsAttribute) { + this.addMissingExtends(); + } + } + } + + private addMissingExtends() { + const { component } = this.file.parser.ast; + //inject new attribute after the final attribute, or after the ` x.range?.start.line === range.start.line); + this.program.plugins.emit('onFileGetCodeActions', this, range, relevantDiagnostics, codeActions); + } + public getSignatureHelpForNamespaceMethods(callableName: string, dottedGetText: string, scope: Scope): { key: string; signature: SignatureInformation }[] { if (!dottedGetText) { return []; diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts index 8ecec84a0..78ca30cdf 100644 --- a/src/files/XmlFile.spec.ts +++ b/src/files/XmlFile.spec.ts @@ -1,7 +1,7 @@ import { assert, expect } from 'chai'; import * as path from 'path'; import * as sinonImport from 'sinon'; -import type { CompletionItem } from 'vscode-languageserver'; +import type { CodeAction, CompletionItem } from 'vscode-languageserver'; import { CompletionItemKind, Position, Range, DiagnosticSeverity } from 'vscode-languageserver'; import * as fsExtra from 'fs-extra'; import { DiagnosticMessages } from '../DiagnosticMessages'; @@ -9,9 +9,10 @@ import type { BsDiagnostic, FileReference } from '../interfaces'; import { Program } from '../Program'; import { BrsFile } from './BrsFile'; import { XmlFile } from './XmlFile'; -import { standardizePath as s } from '../util'; +import util, { standardizePath as s } from '../util'; import { getTestTranspile } from './BrsFile.spec'; -import { trim, trimMap } from '../testHelpers.spec'; +import { expectCodeActions, trim, trimMap } from '../testHelpers.spec'; +import { URI } from 'vscode-uri'; describe('XmlFile', () => { const tempDir = s`${process.cwd()}/.tmp`; @@ -1034,4 +1035,69 @@ describe('XmlFile', () => { `); expect(file.scriptTagImports[0]?.text).to.eql('SingleQuotedFile.brs'); }); + + describe('getCodeActions', () => { + it('suggests `extends=Group`', () => { + const file = program.addOrReplaceFile('components/comp1.xml', trim` + + + + `); + expectCodeActions(() => { + file.getCodeActions( + // + 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 = [] as CodeAction[]; + file.getCodeActions( + // + util.createRange(1, 5, 1, 5), codeActions + ); + expect( + codeActions[0].edit.changes[URI.file(s`${rootDir}/components/comp1.xml`).toString()][0].range + ).to.eql( + util.createRange(1, 51, 1, 51) + ); + }); + }); }); diff --git a/src/files/XmlFile.ts b/src/files/XmlFile.ts index 9ee4c68bf..171dbb8ed 100644 --- a/src/files/XmlFile.ts +++ b/src/files/XmlFile.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import type { CodeWithSourceMap } from 'source-map'; import { SourceNode } from 'source-map'; -import type { CompletionItem, Hover, Location, Position, Range } from 'vscode-languageserver'; +import type { CodeAction, CompletionItem, Hover, Location, Position, Range } from 'vscode-languageserver'; import { DiagnosticMessages } from '../DiagnosticMessages'; import type { FunctionScope } from '../FunctionScope'; import type { Callable, BsDiagnostic, File, FileReference, FunctionCall } from '../interfaces'; @@ -369,6 +369,11 @@ export class XmlFile { return null; } + public getCodeActions(range: Range, codeActions: CodeAction[]) { + const relevantDiagnostics = this.diagnostics.filter(x => x.range?.start.line === range.start.line); + this.program.plugins.emit('onFileGetCodeActions', this, range, relevantDiagnostics, codeActions); + } + public getReferences(position: Position): Promise { //eslint-disable-line //TODO implement return null; diff --git a/src/interfaces.ts b/src/interfaces.ts index ffddcaf9c..5083f4c34 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type { Range, Diagnostic } from 'vscode-languageserver'; +import type { Range, Diagnostic, CodeAction } from 'vscode-languageserver'; import type { Scope } from './Scope'; import type { BrsFile } from './files/BrsFile'; import type { XmlFile } from './files/XmlFile'; @@ -14,6 +14,10 @@ import type { BscType } from './types/BscType'; export interface BsDiagnostic extends Diagnostic { file: File; + /** + * A generic data container where additional details of the diagnostic can be stored. These are stripped out before being sent to a languageclient, and not printed to the console. + */ + data?: any; } export type BscFile = BrsFile | XmlFile; @@ -177,6 +181,7 @@ export type CompilerPluginFactory = () => CompilerPlugin; export interface CompilerPlugin { name: string; + //program events beforeProgramCreate?: (builder: ProgramBuilder) => void; beforePrepublish?: (builder: ProgramBuilder, files: FileObj[]) => void; afterPrepublish?: (builder: ProgramBuilder, files: FileObj[]) => void; @@ -187,11 +192,16 @@ export interface CompilerPlugin { afterProgramValidate?: (program: Program) => void; beforeProgramTranspile?: (program: Program, entries: TranspileObj[]) => void; afterProgramTranspile?: (program: Program, entries: TranspileObj[]) => void; + beforeProgramGetCodeActions?: (program: Program, file: BscFile, range: Range, codeActions: CodeAction[]) => void; + afterProgramGetCodeActions?: (program: Program, file: BscFile, range: Range, codeActions: CodeAction[]) => void; + //scope events afterScopeCreate?: (scope: Scope) => void; beforeScopeDispose?: (scope: Scope) => void; afterScopeDispose?: (scope: Scope) => void; beforeScopeValidate?: ValidateHandler; afterScopeValidate?: ValidateHandler; + onScopeGetCodeActions?: (scope: Scope, file: BscFile, range: Range, diagnostics: BsDiagnostic[], codeActions: CodeAction[]) => void; + //file events beforeFileParse?: (source: SourceObj) => void; afterFileParse?: (file: BscFile) => void; afterFileValidate?: (file: BscFile) => void; @@ -199,6 +209,7 @@ export interface CompilerPlugin { afterFileTranspile?: (entry: TranspileObj) => void; beforeFileDispose?: (file: BscFile) => void; afterFileDispose?: (file: BscFile) => void; + onFileGetCodeActions?: (file: BscFile, range: Range, diagnostics: BsDiagnostic[], codeActions: CodeAction[]) => void; } export interface TypedefProvider { diff --git a/src/testHelpers.spec.ts b/src/testHelpers.spec.ts index f333be092..ff7a94ca4 100644 --- a/src/testHelpers.spec.ts +++ b/src/testHelpers.spec.ts @@ -1,7 +1,10 @@ import type { BsDiagnostic } from './interfaces'; import * as assert from 'assert'; import type { Diagnostic } from 'vscode-languageserver'; - +import { createSandbox } from 'sinon'; +import { expect } from 'chai'; +import type { CodeActionShorthand } from './CodeActionUtil'; +import { codeActionUtil } from './CodeActionUtil'; /** * Trim leading whitespace for every line (to make test writing cleaner */ @@ -81,3 +84,17 @@ export function expectZeroDiagnostics(arg: { getDiagnostics(): Array export function trimMap(source: string) { return source.replace(/('|