Skip to content

Commit

Permalink
Allow plugins to contribute completions (#647)
Browse files Browse the repository at this point in the history
* Initial commit.

* Fix test

* Update src/Program.spec.ts

Co-authored-by: christopher Dwyer-Perkins <chrisdwyerperkins@gmail.com>

* update plugin docs

Co-authored-by: christopher Dwyer-Perkins <chrisdwyerperkins@gmail.com>
  • Loading branch information
TwitchBronBron and chrisdp committed Jul 21, 2022
1 parent 0b3d31a commit 5cc5ea3
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 41 deletions.
37 changes: 37 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ After file addition/removal (note: throttled/debounced):
Code Actions
- `onGetCodeActions`

Completions
- `beforeProvideCompletions`
- `provideCompletions`
- `afterProvideCompletions`

Hovers
- `beforeProvideHover`
- `provideHover`
- `afterProvideHover`

Semantic Tokens
- `onGetSemanticTokens`

Expand Down Expand Up @@ -149,6 +159,33 @@ export interface CompilerPlugin {
beforeProgramTranspile?: (program: Program, entries: TranspileObj[], editor: AstEditor) => void;
afterProgramTranspile?: (program: Program, entries: TranspileObj[], editor: AstEditor) => void;
onGetCodeActions?: PluginHandler<OnGetCodeActionsEvent>;

/**
* Emitted before the program starts collecting completions
*/
beforeProvideCompletions?: PluginHandler<BeforeProvideCompletionsEvent>;
/**
* Use this event to contribute completions
*/
provideCompletions?: PluginHandler<ProvideCompletionsEvent>;
/**
* Emitted after the program has finished collecting completions, but before they are sent to the client
*/
afterProvideCompletions?: PluginHandler<AfterProvideCompletionsEvent>;

/**
* Called before the `provideHover` hook. Use this if you need to prepare any of the in-memory objects before the `provideHover` gets called
*/
beforeProvideHover?: PluginHandler<BeforeProvideHoverEvent>;
/**
* Called when bsc looks for hover information. Use this if your plugin wants to contribute hover information.
*/
provideHover?: PluginHandler<ProvideHoverEvent>;
/**
* Called after the `provideHover` hook. Use this if you want to intercept or sanitize the hover data (even from other plugins) before it gets sent to the client.
*/
afterProvideHover?: PluginHandler<AfterProvideHoverEvent>;

onGetSemanticTokens?: PluginHandler<OnGetSemanticTokensEvent>;
//scope events
afterScopeCreate?: (scope: Scope) => void;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/Program.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1542,7 +1542,7 @@ describe('Program', () => {

describe('getCompletions', () => {
it('returns all functions in scope', () => {
program.setFile({ src: `${rootDir}/source/main.brs`, dest: 'source/main.brs' }, `
program.setFile('source/main.brs', `
sub Main()
end sub
Expand All @@ -1559,7 +1559,7 @@ describe('Program', () => {

let completions = program
//get completions
.getCompletions(`${rootDir}/source/main.brs`, Position.create(2, 10))
.getCompletions(`${rootDir}/source/main.brs`, util.createPosition(2, 10))
//only keep the label property for this test
.map(x => pick(x, 'label'));

Expand All @@ -1569,7 +1569,7 @@ describe('Program', () => {
});

it('returns all variables in scope', () => {
program.setFile({ src: `${rootDir}/source/main.brs`, dest: 'source/main.brs' }, `
program.setFile('source/main.brs', `
sub Main()
name = "bob"
age = 20
Expand All @@ -1585,7 +1585,7 @@ describe('Program', () => {

program.validate();

let completions = program.getCompletions(`${rootDir}/source/main.brs`, Position.create(2, 10));
let completions = program.getCompletions(`${rootDir}/source/main.brs`, util.createPosition(2, 10));
let labels = completions.map(x => pick(x, 'label'));

expect(labels).to.deep.include({ label: 'Main' });
Expand All @@ -1608,7 +1608,7 @@ describe('Program', () => {
});

it('finds parameters', () => {
program.setFile({ src: `${rootDir}/source/main.brs`, dest: 'source/main.brs' }, `
program.setFile('source/main.brs', `
sub Main(count = 1)
firstName = "bob"
age = 21
Expand Down
45 changes: 15 additions & 30 deletions src/Program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Scope } from './Scope';
import { DiagnosticMessages } from './DiagnosticMessages';
import { BrsFile } from './files/BrsFile';
import { XmlFile } from './files/XmlFile';
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent } from './interfaces';
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent } from './interfaces';
import { standardizePath as s, util } from './util';
import { XmlScope } from './XmlScope';
import { DiagnosticFilterer } from './DiagnosticFilterer';
Expand Down Expand Up @@ -836,43 +836,28 @@ export class Program {
if (!file) {
return [];
}
let result = [] as CompletionItem[];

if (isBrsFile(file) && file.isPositionNextToTokenKind(position, TokenKind.Callfunc)) {
// is next to a @. callfunc invocation - must be an interface method
for (const scope of this.getScopes().filter((s) => isXmlScope(s))) {
let fileLinks = this.getStatementsForXmlFile(scope as XmlScope);
for (let fileLink of fileLinks) {

result.push(scope.createCompletionFromFunctionStatement(fileLink.item));
}
}
//no other result is possible in this case
return result;
}

//find the scopes for this file
let scopes = this.getScopesForFile(file);

//if there are no scopes, include the global scope so we at least get the built-in functions
scopes = scopes.length > 0 ? scopes : [this.globalScope];

//get the completions from all scopes for this file
let allCompletions = util.flatMap(
scopes.map(ctx => file.getCompletions(position, ctx)),
c => c
);
const event: ProvideCompletionsEvent = {
program: this,
file: file,
scopes: scopes,
position: position,
completions: []
};

//only keep completions common to every scope for this file
let keyCounts = {} as Record<string, number>;
for (let completion of allCompletions) {
let key = `${completion.label}-${completion.kind}`;
keyCounts[key] = keyCounts[key] ? keyCounts[key] + 1 : 1;
if (keyCounts[key] === scopes.length) {
result.push(completion);
}
}
return result;
this.plugins.emit('beforeProvideCompletions', event);

this.plugins.emit('provideCompletions', event);

this.plugins.emit('afterProvideCompletions', event);

return event.completions;
}

/**
Expand Down
10 changes: 7 additions & 3 deletions src/bscPlugin/BscPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { isBrsFile } from '../astUtils/reflection';
import type { BrsFile } from '../files/BrsFile';
import type { BeforeFileTranspileEvent, CompilerPlugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent } from '../interfaces';
import type { BeforeFileTranspileEvent, CompilerPlugin, OnFileValidateEvent, OnGetCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent } from '../interfaces';
import type { Program } from '../Program';
import { CodeActionsProcessor } from './codeActions/CodeActionsProcessor';
import { CompletionsProcessor } from './completions/CompletionsProcessor';
import { HoverProcessor } from './hover/HoverProcessor';
import { BrsFileSemanticTokensProcessor } from './semanticTokens/BrsFileSemanticTokensProcessor';
import { BrsFilePreTranspileProcessor } from './transpile/BrsFilePreTranspileProcessor';
Expand All @@ -20,6 +20,10 @@ export class BscPlugin implements CompilerPlugin {
return new HoverProcessor(event).process();
}

public provideCompletions(event: ProvideCompletionsEvent) {
new CompletionsProcessor(event).process();
}

public onGetSemanticTokens(event: OnGetSemanticTokensEvent) {
if (isBrsFile(event.file)) {
return new BrsFileSemanticTokensProcessor(event as any).process();
Expand All @@ -28,7 +32,7 @@ export class BscPlugin implements CompilerPlugin {

public onFileValidate(event: OnFileValidateEvent) {
if (isBrsFile(event.file)) {
return new BrsFileValidator(event as OnFileValidateEvent<BrsFile>).process();
return new BrsFileValidator(event as any).process();
}
}

Expand Down
53 changes: 53 additions & 0 deletions src/bscPlugin/completions/CompletionsProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { isBrsFile, isXmlScope } from '../../astUtils/reflection';
import type { ProvideCompletionsEvent } from '../../interfaces';
import { TokenKind } from '../../lexer/TokenKind';
import type { XmlScope } from '../../XmlScope';
import { util } from '../../util';

export class CompletionsProcessor {
constructor(
private event: ProvideCompletionsEvent
) {

}

public process() {
if (isBrsFile(this.event.file) && this.event.file.isPositionNextToTokenKind(this.event.position, TokenKind.Callfunc)) {
const xmlScopes = this.event.program.getScopes().filter((s) => isXmlScope(s)) as XmlScope[];
// is next to a @. callfunc invocation - must be an interface method.
//TODO refactor this to utilize the actual variable's component type (when available)
for (const scope of xmlScopes) {
let fileLinks = this.event.program.getStatementsForXmlFile(scope);
for (let fileLink of fileLinks) {
this.event.completions.push(scope.createCompletionFromFunctionStatement(fileLink.item));
}
}
//no other result is possible in this case
return;
}

//find the scopes for this file
let scopesForFile = this.event.program.getScopesForFile(this.event.file);

//if there are no scopes, include the global scope so we at least get the built-in functions
scopesForFile = scopesForFile.length > 0 ? scopesForFile : [this.event.program.globalScope];

//get the completions from all scopes for this file
let allCompletions = util.flatMap(
scopesForFile.map(scope => {
return this.event.file.getCompletions(this.event.position, scope);
}),
c => c
);

//only keep completions common to every scope for this file
let keyCounts = new Map<string, number>();
for (let completion of allCompletions) {
let key = `${completion.label}-${completion.kind}`;
keyCounts.set(key, keyCounts.has(key) ? keyCounts.get(key) + 1 : 1);
if (keyCounts.get(key) === scopesForFile.length) {
this.event.completions.push(completion);
}
}
}
}
25 changes: 24 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Range, Diagnostic, CodeAction, SemanticTokenTypes, SemanticTokenModifiers, Position, Hover } from 'vscode-languageserver';
import type { Range, Diagnostic, CodeAction, SemanticTokenTypes, SemanticTokenModifiers, Position, Hover, CompletionItem } from 'vscode-languageserver';
import type { Scope } from './Scope';
import type { BrsFile } from './files/BrsFile';
import type { XmlFile } from './files/XmlFile';
Expand Down Expand Up @@ -202,6 +202,19 @@ export interface CompilerPlugin {
afterProgramTranspile?: (program: Program, entries: TranspileObj[], editor: AstEditor) => void;
onGetCodeActions?: PluginHandler<OnGetCodeActionsEvent>;

/**
* Emitted before the program starts collecting completions
*/
beforeProvideCompletions?: PluginHandler<BeforeProvideCompletionsEvent>;
/**
* Use this event to contribute completions
*/
provideCompletions?: PluginHandler<ProvideCompletionsEvent>;
/**
* Emitted after the program has finished collecting completions, but before they are sent to the client
*/
afterProvideCompletions?: PluginHandler<AfterProvideCompletionsEvent>;

/**
* Called before the `provideHover` hook. Use this if you need to prepare any of the in-memory objects before the `provideHover` gets called
*/
Expand Down Expand Up @@ -254,6 +267,16 @@ export interface OnGetCodeActionsEvent {
codeActions: CodeAction[];
}

export interface ProvideCompletionsEvent<TFile extends BscFile = BscFile> {
program: Program;
file: TFile;
scopes: Scope[];
position: Position;
completions: CompletionItem[];
}
export type BeforeProvideCompletionsEvent<TFile extends BscFile = BscFile> = ProvideCompletionsEvent<TFile>;
export type AfterProvideCompletionsEvent<TFile extends BscFile = BscFile> = ProvideCompletionsEvent<TFile>;

export interface ProvideHoverEvent {
program: Program;
file: BscFile;
Expand Down

0 comments on commit 5cc5ea3

Please sign in to comment.