Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add codeAction for bs imports #347

Merged
merged 10 commits into from
Mar 10, 2021
35 changes: 35 additions & 0 deletions src/CodeActionUtil.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect } from 'chai';
import { codeActionUtil } from './CodeActionUtil';
import util from './util';

describe('CodeActionUtil', () => {
describe('dedupe', () => {
it('removes duplicate code actions', () => {
const changes = [{
type: 'insert',
filePath: 'file1',
newText: 'step 1',
position: util.createPosition(1, 1)
}] as any;
const deduped = codeActionUtil.dedupe([
codeActionUtil.createCodeAction({
title: 'Step 1',
changes: changes
}),
codeActionUtil.createCodeAction({
title: 'Step 2',
changes: changes
}),
codeActionUtil.createCodeAction({
title: 'Step 1',
changes: changes
})
]);
//the duplicate step2 should have been removed
expect(deduped.map(x => x.title).sort()).to.eql([
'Step 1',
'Step 2'
]);
});
});
});
60 changes: 59 additions & 1 deletion src/CodeActionUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CodeActionKind, Diagnostic, Position, Range, WorkspaceEdit } from 'vscode-languageserver';
import { CodeAction, TextEdit } from 'vscode-languageserver';
import { CodeAction, TextEdit, CreateFile, DeleteFile, RenameFile, TextDocumentEdit } from 'vscode-languageserver';
import { URI } from 'vscode-uri';

export class CodeActionUtil {
Expand All @@ -25,6 +25,64 @@ export class CodeActionUtil {
}
return CodeAction.create(obj.title, edit);
}

private rangeToKey(range: Range) {
return `${range?.start?.line}:${range?.start?.character},${range?.end?.line}:${range?.end?.character}`;
}

/**
* Generate a unique key for each code action, and only keep one of each action
*/
public dedupe(codeActions: CodeAction[]) {
TwitchBronBron marked this conversation as resolved.
Show resolved Hide resolved
const resultMap = {} as Record<string, CodeAction>;

for (const codeAction of codeActions) {
const keyParts = [
`${codeAction.command}-${codeAction.isPreferred}-${codeAction.kind}-${codeAction.title}`
];
for (let diagnostic of codeAction.diagnostics ?? []) {
keyParts.push(
`${diagnostic.code}-${diagnostic.message}-${diagnostic.severity}-${diagnostic.source}-${diagnostic.tags?.join('-')}-${this.rangeToKey(diagnostic.range)}`
);
}
const edits = [] as TextEdit[];
for (let changeKey of Object.keys(codeAction.edit?.changes ?? {}).sort()) {
edits.push(
...codeAction.edit.changes[changeKey]
);
}
for (let change of codeAction?.edit.documentChanges ?? []) {
if (TextDocumentEdit.is(change)) {
keyParts.push(change.textDocument.uri);
edits.push(
...change.edits
);
} else if (CreateFile.is(change)) {
keyParts.push(
`${change.kind}-${change.uri}-${change.options.ignoreIfExists}-${change.options.overwrite}`
);
} else if (RenameFile.is(change)) {
keyParts.push(
`${change.kind}-${change.oldUri}-${change.newUri}-${change.options.ignoreIfExists}-${change.options.overwrite}`
);
} else if (DeleteFile.is(change)) {
keyParts.push(
`${change.kind}-${change.uri}-${change.options.ignoreIfNotExists}-${change.options.recursive}`
);
}

}
for (let edit of edits) {
keyParts.push(
`${edit.newText}-${this.rangeToKey(edit.range)}`
);
}

const key = keyParts.sort().join('|');
resultMap[key] = codeAction;
}
return Object.values(resultMap);
}
}

export interface CodeActionShorthand {
Expand Down
8 changes: 5 additions & 3 deletions src/DiagnosticMessages.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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<K extends keyof D, D extends Record<string, (...args: any) => any> = typeof DiagnosticMessages> = ReturnType<D[K]>;
export type DiagnosticMessageType<K extends keyof D, D extends Record<string, (...args: any) => any> = typeof DiagnosticMessages> =
ReturnType<D[K]> &
//include the missing properties from BsDiagnostic
Pick<BsDiagnostic, 'range' | 'file' | 'relatedInformation' | 'tags'>;
23 changes: 11 additions & 12 deletions src/LanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,6 +41,7 @@ import { Throttler } from './Throttler';
import { KeyedThrottler } from './KeyedThrottler';
import { DiagnosticCollection } from './DiagnosticCollection';
import { isBrsFile } from './astUtils/reflection';
import { codeActionUtil } from './CodeActionUtil';

export class LanguageServer {
//cast undefined as any to get around strictNullChecks...it's ok in this case
Expand Down Expand Up @@ -565,7 +565,15 @@ export class LanguageServer {
.filter(x => x.builder.program.hasFile(filePath))
.flatMap(workspace => workspace.builder.program.getCodeActions(filePath, params.range));

return codeActions;
const distinctCodeActions = codeActionUtil.dedupe(codeActions);

//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 distinctCodeActions) {
if (codeAction.diagnostics) {
codeAction.diagnostics = codeAction.diagnostics.map(x => util.toDiagnostic(x));
}
}
return distinctCodeActions;
}

/**
Expand Down Expand Up @@ -1051,16 +1059,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(),
Expand Down
19 changes: 19 additions & 0 deletions src/Program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,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
*/
Expand Down
10 changes: 9 additions & 1 deletion src/bscPlugin/BscPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { CodeAction, Range } from 'vscode-languageserver';
import { isXmlFile } from '../astUtils/reflection';
import { isBrsFile, isXmlFile } from '../astUtils/reflection';
import type { BscFile, BsDiagnostic, CompilerPlugin } from '../interfaces';
import type { Scope } from '../Scope';
import { ScopeBrsFileCodeActionsProcessor } from './codeActions/ScopeBrsFileCodeActionsProcessor';
import { XmlFileCodeActionsProcessor } from './codeActions/XmlFileCodeActionsProcessor';

export class BscPlugin implements CompilerPlugin {
Expand All @@ -11,4 +13,10 @@ export class BscPlugin implements CompilerPlugin {
new XmlFileCodeActionsProcessor(file, range, diagnostics, codeActions).process();
}
}

public onScopeGetCodeActions(scope: Scope, file: BscFile, range: Range, diagnostics: BsDiagnostic[], codeActions: CodeAction[]) {
if (isBrsFile(file)) {
new ScopeBrsFileCodeActionsProcessor(scope, file, range, diagnostics, codeActions).process();
}
}
}
57 changes: 57 additions & 0 deletions src/bscPlugin/codeActions/ScopeBrsFileCodeActionsProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CodeAction, Range } from 'vscode-languageserver';
import { CodeActionKind } from 'vscode-languageserver';
import { codeActionUtil } from '../../CodeActionUtil';
import type { DiagnosticMessageType } from '../../DiagnosticMessages';
import { DiagnosticCodeMap } from '../../DiagnosticMessages';
import type { BrsFile } from '../../files/BrsFile';
import type { BsDiagnostic } from '../../interfaces';
import type { Scope } from '../../Scope';
import { util } from '../../util';

export class ScopeBrsFileCodeActionsProcessor {
public constructor(
public scope: Scope,
public file: BrsFile,
public range: Range,
public diagnostics: BsDiagnostic[],
public codeActions: CodeAction[]
) {

}

public process() {
const isBsFile = this.file.extension.endsWith('.bs');
for (const diagnostic of this.diagnostics) {
//certain brighterscript-specific code actions
if (isBsFile) {
if (diagnostic.code === DiagnosticCodeMap.callToUnknownFunction) {
this.suggestImports(diagnostic as any);
}
}
}
}

private suggestImports(diagnostic: DiagnosticMessageType<'callToUnknownFunction'>) {
TwitchBronBron marked this conversation as resolved.
Show resolved Hide resolved
//find the position of the first import statement, or the top of the file if there is none
const insertPosition = this.file.parser.references.importStatements[0]?.importToken.range?.start ?? util.createPosition(0, 0);

//find all files that reference this function
for (const file of this.file.program.findFilesForFunction(diagnostic.data.functionName)) {
const pkgPath = util.getRokuPkgPath(file.pkgPath);
this.codeActions.push(
codeActionUtil.createCodeAction({
title: `import "${pkgPath}"`,
diagnostics: [diagnostic],
isPreferred: false,
kind: CodeActionKind.QuickFix,
changes: [{
type: 'insert',
filePath: this.file.pathAbsolute,
position: insertPosition,
newText: `import "${pkgPath}"\n`
}]
})
);
}
}
}
11 changes: 6 additions & 5 deletions src/bscPlugin/codeActions/XmlFileCodeActionsProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CodeAction, Range } from 'vscode-languageserver';
import { CodeActionKind } from 'vscode-languageserver';
import { codeActionUtil } from '../../CodeActionUtil';
import type { DiagnosticMessageType } from '../../DiagnosticMessages';
import { DiagnosticCodeMap } from '../../DiagnosticMessages';
import type { XmlFile } from '../../files/XmlFile';
import type { BsDiagnostic } from '../../interfaces';
Expand All @@ -18,19 +19,19 @@ export class XmlFileCodeActionsProcessor {
public process() {
for (const diagnostic of this.diagnostics) {
if (diagnostic.code === DiagnosticCodeMap.xmlComponentMissingExtendsAttribute) {
this.addMissingExtends();
this.addMissingExtends(diagnostic as any);
}
}
}

private addMissingExtends() {
private addMissingExtends(diagnostic: DiagnosticMessageType<'xmlComponentMissingExtendsAttribute'>) {
const { component } = this.file.parser.ast;
//inject new attribute after the final attribute, or after the `<component` if there are no attributes
const pos = (component.attributes[component.attributes.length - 1] ?? component.tag).range.end;
this.codeActions.push(
codeActionUtil.createCodeAction({
title: `Extend "Group"`,
// diagnostics: [diagnostic],
diagnostics: [diagnostic],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can send diagnostics for these actions now that we are sanitizing them before sending them in the language server.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the diagnostic give?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually know. I looked through the docs and they don't explain it at all.

isPreferred: true,
kind: CodeActionKind.QuickFix,
changes: [{
Expand All @@ -44,7 +45,7 @@ export class XmlFileCodeActionsProcessor {
this.codeActions.push(
codeActionUtil.createCodeAction({
title: `Extend "Task"`,
// diagnostics: [diagnostic],
diagnostics: [diagnostic],
kind: CodeActionKind.QuickFix,
changes: [{
type: 'insert',
Expand All @@ -57,7 +58,7 @@ export class XmlFileCodeActionsProcessor {
this.codeActions.push(
codeActionUtil.createCodeAction({
title: `Extend "ContentNode"`,
// diagnostics: [diagnostic],
diagnostics: [diagnostic],
kind: CodeActionKind.QuickFix,
changes: [{
type: 'insert',
Expand Down
Loading