diff --git a/src/CodeActionUtil.ts b/src/CodeActionUtil.ts index 76b4b71ee..35ca6edf1 100644 --- a/src/CodeActionUtil.ts +++ b/src/CodeActionUtil.ts @@ -20,7 +20,11 @@ export class CodeActionUtil { TextEdit.insert(change.position, change.newText) ); } else if (change.type === 'replace') { - TextEdit.replace(change.range, change.newText); + edit.changes[uri].push( + TextEdit.replace(change.range, change.newText) + ); + } else if (change.type === 'delete') { + TextEdit.del(change.range); } } return CodeAction.create(obj.title, edit); @@ -32,7 +36,7 @@ export interface CodeActionShorthand { diagnostics?: Diagnostic[]; kind?: CodeActionKind; isPreferred?: boolean; - changes: Array; + changes: Array; } export interface InsertChange { @@ -49,4 +53,10 @@ export interface ReplaceChange { range: Range; } +export interface DeleteChange { + filePath: string; + type: 'delete'; + range: Range; +} + export const codeActionUtil = new CodeActionUtil(); diff --git a/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts b/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts index a1353a990..17ad79a43 100644 --- a/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts +++ b/src/bscPlugin/codeActions/CodeActionsProcessor.spec.ts @@ -225,6 +225,84 @@ describe('CodeActionsProcessor', () => { `import "pkg:/source/Animals.bs"` ]); }); + + it('suggests callfunc operator refactor (parameterless)', () => { + const file = program.addOrReplaceFile('components/main.bs', ` + sub doSomething() + someComponent.callfunc("doThing") + end sub + `); + program.validate(); + + expectCodeActions(() => { + program.getCodeActions( + file.pathAbsolute, + util.createRange(2, 39, 2, 39) + ); + }, [{ + title: `Refactor to use callfunc operator`, + isPreferred: false, + kind: 'quickfix', + changes: [{ + filePath: file.pathAbsolute, + newText: '@.doThing(', + type: 'replace', + range: util.createRange(2, 33, 2, 52) + }] + }]); + }); + + it('suggests callfunc operator refactor (parameters)', () => { + const file = program.addOrReplaceFile('components/main.bs', ` + sub doSomething() + someComponent.callfunc("doOtherThing", someIdentifier, 2) + end sub + `); + program.validate(); + + expectCodeActions(() => { + program.getCodeActions( + file.pathAbsolute, + util.createRange(2, 39, 2, 39) + ); + }, [{ + title: `Refactor to use callfunc operator`, + isPreferred: false, + kind: 'quickfix', + changes: [{ + filePath: file.pathAbsolute, + newText: '@.doOtherThing(', + type: 'replace', + range: util.createRange(2, 33, 2, 59) + }] + }]); + }); + + it('suggests callfunc operator refactor (remove "invalid" first param)', () => { + const file = program.addOrReplaceFile('components/main.bs', ` + sub doSomething() + someComponent.callfunc("doYetAnotherThing", invalid) + end sub + `); + program.validate(); + + expectCodeActions(() => { + program.getCodeActions( + file.pathAbsolute, + util.createRange(2, 39, 2, 39) + ); + }, [{ + title: `Refactor to use callfunc operator`, + isPreferred: false, + kind: 'quickfix', + changes: [{ + filePath: file.pathAbsolute, + newText: '@.doYetAnotherThing(', + type: 'replace', + range: util.createRange(2, 33, 2, 71) + }] + }]); + }); }); }); diff --git a/src/bscPlugin/codeActions/CodeActionsProcessor.ts b/src/bscPlugin/codeActions/CodeActionsProcessor.ts index 8de1e3b77..7af80f93b 100644 --- a/src/bscPlugin/codeActions/CodeActionsProcessor.ts +++ b/src/bscPlugin/codeActions/CodeActionsProcessor.ts @@ -1,11 +1,14 @@ -import type { Diagnostic } from 'vscode-languageserver'; +import type { Diagnostic, Position } from 'vscode-languageserver'; import { CodeActionKind } from 'vscode-languageserver'; +import { isBrsFile } from '../../astUtils'; import { codeActionUtil } from '../../CodeActionUtil'; import type { DiagnosticMessageType } from '../../DiagnosticMessages'; import { DiagnosticCodeMap } from '../../DiagnosticMessages'; import type { BrsFile } from '../../files/BrsFile'; import type { XmlFile } from '../../files/XmlFile'; import type { BscFile, OnGetCodeActionsEvent } from '../../interfaces'; +import type { Token } from '../../lexer/Token'; +import { TokenKind } from '../../lexer/TokenKind'; import { ParseMode } from '../../parser'; import { util } from '../../util'; @@ -26,6 +29,59 @@ export class CodeActionsProcessor { this.addMissingExtends(diagnostic as any); } } + + if (isBrsFile(this.event.file)) { + const token = this.event.file.getTokenAt(this.event.range.start); + const previousToken = this.event.file.getPreviousToken(token); + const lowerText = token?.text?.toLowerCase(); + + //brighterscript-specific code actions + if (this.event.file.extension === '.bs') { + if (lowerText === 'callfunc' && previousToken.kind === TokenKind.Dot) { + this.suggestCallfuncOperator(token, previousToken); + } + } + } + } + + /** + * Suggest refactoring callfunc calls to use the callfunc operator instead + */ + private suggestCallfuncOperator(token: Token, previousToken: Token) { + const file = this.event.file as BrsFile; + const openParenToken = file.getNextToken(token); + const functionNameToken = file.getNextToken(openParenToken); + + const comma = file.getNextToken(functionNameToken, TokenKind.Comma); + const invalidLiteral = file.getNextToken(comma, TokenKind.Invalid); + let endPosition: Position; + const endToken = invalidLiteral ?? comma; + //consume the comma and any space up until the next token + if (endToken) { + const tokenAfter = file.getNextToken(endToken); + endPosition = tokenAfter?.range.start ?? comma?.range.end; + } else { + endPosition = functionNameToken.range.end; + } + + //if we have the necessary tokens + if (openParenToken?.kind === TokenKind.LeftParen && functionNameToken?.kind === TokenKind.StringLiteral) { + //replace leading and trailing quotes + const functionName = functionNameToken.text.replace(/^"/, '').replace(/"$/, ''); + this.event.codeActions.push( + codeActionUtil.createCodeAction({ + title: `Refactor to use callfunc operator`, + isPreferred: false, + kind: CodeActionKind.QuickFix, + changes: [{ + type: 'replace', + filePath: this.event.file.pathAbsolute, + newText: `@.${functionName}(`, + range: util.createRangeFromPositions(previousToken.range.start, endPosition) + }] + }) + ); + } } private suggestedImports = new Set(); diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts index 6cc9ed503..164511cb2 100644 --- a/src/files/BrsFile.ts +++ b/src/files/BrsFile.ts @@ -1092,6 +1092,21 @@ export class BrsFile { return parser.tokens[idx - 1]; } + /** + * Get the token after the given token. + * If `requiredTokenKind` is specified, then the next token's type is checked, and if no match, undefined is returned. + */ + public getNextToken(token: Token, requiredTokenKind?: TokenKind) { + const parser = this.parser; + let idx = parser.tokens.indexOf(token); + const result = parser.tokens[idx + 1]; + if (!requiredTokenKind) { + return result; + } else if (result?.kind === requiredTokenKind) { + return result; + } + } + /** * Find the first scope that has a namespace with this name. * Returns false if no namespace was found with that name