diff --git a/src/LanguageServer.spec.ts b/src/LanguageServer.spec.ts index ad88ae30e..ccf609750 100644 --- a/src/LanguageServer.spec.ts +++ b/src/LanguageServer.spec.ts @@ -63,6 +63,11 @@ describe('LanguageServer', () => { onWillSaveTextDocumentWaitUntil: () => null, onDidSaveTextDocument: () => null, onRequest: () => null, + languages: { + inlayHint: { + on: () => null + } + }, workspace: { getWorkspaceFolders: () => { return workspaceFolders.map( diff --git a/src/LanguageServer.ts b/src/LanguageServer.ts index 92b6d7122..1c945a691 100644 --- a/src/LanguageServer.ts +++ b/src/LanguageServer.ts @@ -30,7 +30,9 @@ import type { SelectionRangeParams, RenameFilesParams, WorkspaceEdit, - TextEdit + TextEdit, + InlayHint, + InlayHintParams } from 'vscode-languageserver/node'; import { SemanticTokensRequest, @@ -177,6 +179,9 @@ export class LanguageServer { //Register semantic token requests. TODO switch to a more specific connection function call once they actually add it this.connection.onRequest(SemanticTokensRequest.method, this.onFullSemanticTokens.bind(this)); + //Register inlay hint requests. Inlay hints live under connection.languages and aren't picked up by the on* auto-bind loop above + this.connection.languages.inlayHint.on(this.onInlayHint.bind(this)); + //file-operation requests live under connection.workspace, so they aren't picked up by the on* auto-bind loop above this.connection.workspace.onWillRenameFiles(this.onWillRenameFiles.bind(this)); @@ -239,6 +244,7 @@ export class LanguageServer { definitionProvider: true, hoverProvider: true, selectionRangeProvider: true, + inlayHintProvider: true, executeCommandProvider: { commands: [ CustomCommands.TranspileFile @@ -631,6 +637,15 @@ export class LanguageServer { return this.projectManager.getSelectionRanges({ srcPath: srcPath, positions: params.positions }); } + @AddStackToErrorMessage + public async onInlayHint(params: InlayHintParams): Promise { + this.logger.debug('onInlayHint', params); + + const srcPath = util.uriToPath(params.textDocument.uri); + const result = await this.projectManager.getInlayHints({ srcPath: srcPath, range: params.range }); + return result ?? []; + } + @AddStackToErrorMessage public async onDocumentSymbol(params: DocumentSymbolParams) { this.logger.debug('onDocumentSymbol', params); diff --git a/src/Program.ts b/src/Program.ts index b687c9c84..7785b0a40 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 { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange } from 'vscode-languageserver'; +import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange, InlayHint } from 'vscode-languageserver'; import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver'; import type { BsConfig, FinalizedBsConfig } from './BsConfig'; import { Scope } from './Scope'; @@ -10,7 +10,7 @@ import { SymbolTable } from './SymbolTable'; 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, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent, OnGetSourceFixAllCodeActionsEvent } from './interfaces'; +import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent, ProvideInlayHintsEvent, OnGetSourceFixAllCodeActionsEvent } from './interfaces'; import type { SourceFixAllCodeAction } from './CodeActionUtil'; import { codeActionUtil } from './CodeActionUtil'; import { standardizePath as s, util } from './util'; @@ -1233,6 +1233,27 @@ export class Program { return []; } + /** + * Get inlay hints for the given file and range. + */ + public getInlayHints(srcPath: string, range: Range): InlayHint[] { + const file = this.getFile(srcPath); + if (file) { + const event: ProvideInlayHintsEvent = { + program: this, + file: file, + range: range, + scopes: this.getScopesForFile(file), + inlayHints: [] + }; + this.plugins.emit('beforeProvideInlayHints', event); + this.plugins.emit('provideInlayHints', event); + this.plugins.emit('afterProvideInlayHints', event); + return event.inlayHints; + } + return []; + } + /** * Compute code actions for the given file and range */ diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts index d9c338dfa..57c3605ee 100644 --- a/src/bscPlugin/BscPlugin.ts +++ b/src/bscPlugin/BscPlugin.ts @@ -1,5 +1,5 @@ import { isBrsFile, isXmlFile } from '../astUtils/reflection'; -import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, OnGetSourceFixAllCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent } from '../interfaces'; +import type { BeforeFileTranspileEvent, Plugin, OnFileValidateEvent, OnGetCodeActionsEvent, OnGetSourceFixAllCodeActionsEvent, ProvideHoverEvent, OnGetSemanticTokensEvent, OnScopeValidateEvent, ProvideCompletionsEvent, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent, ProvideInlayHintsEvent } from '../interfaces'; import type { Program } from '../Program'; import { CodeActionsProcessor } from './codeActions/CodeActionsProcessor'; import { FixAllCodeActionsProcessor } from './codeActions/FixAllCodeActionsProcessor'; @@ -16,6 +16,7 @@ import { ScopeValidator } from './validation/ScopeValidator'; import { XmlFileValidator } from './validation/XmlFileValidator'; import { WorkspaceSymbolProcessor } from './symbols/WorkspaceSymbolProcessor'; import { SelectionRangesProcessor } from './selectionRanges/SelectionRangesProcessor'; +import { InlayHintProcessor } from './inlayHints/InlayHintProcessor'; export class BscPlugin implements Plugin { public name = 'BscPlugin'; @@ -56,6 +57,10 @@ export class BscPlugin implements Plugin { new SelectionRangesProcessor(event).process(); } + public provideInlayHints(event: ProvideInlayHintsEvent) { + new InlayHintProcessor(event).process(); + } + public onGetSemanticTokens(event: OnGetSemanticTokensEvent) { if (isBrsFile(event.file)) { return new BrsFileSemanticTokensProcessor(event as any).process(); diff --git a/src/bscPlugin/inlayHints/InlayHintProcessor.spec.ts b/src/bscPlugin/inlayHints/InlayHintProcessor.spec.ts new file mode 100644 index 000000000..a22d2db47 --- /dev/null +++ b/src/bscPlugin/inlayHints/InlayHintProcessor.spec.ts @@ -0,0 +1,162 @@ +import { expect } from '../../chai-config.spec'; +import { Program } from '../../Program'; +import { util } from '../../util'; +import { rootDir, trim } from '../../testHelpers.spec'; +import type { InlayHint } from 'vscode-languageserver-types'; +import { InlayHintKind } from 'vscode-languageserver-types'; + +describe('InlayHintProcessor', () => { + let program: Program; + + beforeEach(() => { + program = new Program({ rootDir: rootDir }); + }); + + afterEach(() => { + program.dispose(); + }); + + function getInlayHints(filePath: string, code: string): InlayHint[] { + const file = program.setFile(filePath, code); + program.validate(); + const lines = code.split('\n'); + const range = util.createRange(0, 0, lines.length, 0); + return program.getInlayHints(file.pathAbsolute, range); + } + + it('returns empty array when no calls exist', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + x = 1 + end sub + `); + expect(hints).to.eql([]); + }); + + it('emits parameter name hints for a regular function call', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + sayHello("world", 42) + end sub + sub sayHello(name as string, age as integer) + end sub + `); + expect(hints.map(h => h.label)).to.eql(['name:', 'age:']); + expect(hints.every(h => h.kind === InlayHintKind.Parameter)).to.be.true; + expect(hints.every(h => h.paddingRight === true)).to.be.true; + }); + + it('positions hints at the start of each argument', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + sayHello("world", 42) + end sub + sub sayHello(name as string, age as integer) + end sub + `); + expect(hints[0].position).to.eql(util.createPosition(1, 13)); + //"world" is 7 chars, so 42 starts at column 13 + 7 + 2 = 22 + expect(hints[1].position).to.eql(util.createPosition(1, 22)); + }); + + it('skips hints when the argument is a variable matching the parameter name', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + name = "world" + sayHello(name, 42) + end sub + sub sayHello(name as string, age as integer) + end sub + `); + //first arg matches param name and should be suppressed; second arg should still emit + expect(hints.map(h => h.label)).to.eql(['age:']); + }); + + it('limits hints to the requested range', () => { + const code = trim` + sub main() + sayHello("world", 42) + sayHello("again", 99) + end sub + sub sayHello(name as string, age as integer) + end sub + `; + const file = program.setFile('source/main.bs', code); + program.validate(); + //range only covers line 1 (the first call) + const range = util.createRange(1, 0, 1, 100); + const hints = program.getInlayHints(file.pathAbsolute, range); + expect(hints.map(h => h.label)).to.eql(['name:', 'age:']); + expect(hints.every(h => h.position.line === 1)).to.be.true; + }); + + it('emits hints for namespace calls', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + math.add(1, 2) + end sub + namespace math + sub add(left as integer, right as integer) + end sub + end namespace + `); + expect(hints.map(h => h.label)).to.eql(['left:', 'right:']); + }); + + it('emits hints for class constructor calls', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + p = new Person("Alice", 30) + end sub + class Person + sub new(name as string, age as integer) + end sub + end class + `); + expect(hints.map(h => h.label)).to.eql(['name:', 'age:']); + }); + + it('emits hints for m.method() calls inside a class', () => { + const hints = getInlayHints('source/main.bs', trim` + class Greeter + sub greet() + m.sayHello("world", 42) + end sub + sub sayHello(name as string, age as integer) + end sub + end class + `); + expect(hints.map(h => h.label)).to.eql(['name:', 'age:']); + }); + + it('does not emit hints when the function is unknown', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + unknownFunction(1, 2) + end sub + `); + expect(hints).to.eql([]); + }); + + it('does not emit hints for excess arguments past the parameter list', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + takesOne(1, 2, 3) + end sub + sub takesOne(only as integer) + end sub + `); + expect(hints.map(h => h.label)).to.eql(['only:']); + }); + + it('does not crash on calls with no arguments', () => { + const hints = getInlayHints('source/main.bs', trim` + sub main() + noArgs() + end sub + sub noArgs() + end sub + `); + expect(hints).to.eql([]); + }); +}); diff --git a/src/bscPlugin/inlayHints/InlayHintProcessor.ts b/src/bscPlugin/inlayHints/InlayHintProcessor.ts new file mode 100644 index 000000000..b099f6895 --- /dev/null +++ b/src/bscPlugin/inlayHints/InlayHintProcessor.ts @@ -0,0 +1,210 @@ +import type { Range } from 'vscode-languageserver-protocol'; +import { InlayHintKind } from 'vscode-languageserver-protocol'; +import { + isBrsFile, + isClassStatement, + isDottedGetExpression, + isFunctionStatement, + isMethodStatement, + isNamespaceStatement, + isNewExpression, + isVariableExpression, + isXmlScope +} from '../../astUtils/reflection'; +import { WalkMode, createVisitor } from '../../astUtils/visitors'; +import type { BrsFile } from '../../files/BrsFile'; +import type { ProvideInlayHintsEvent } from '../../interfaces'; +import type { CallExpression, CallfuncExpression, FunctionParameterExpression } from '../../parser/Expression'; +import type { ClassStatement, FunctionStatement, MethodStatement, NamespaceStatement } from '../../parser/Statement'; +import { ParseMode } from '../../parser/Parser'; +import { util } from '../../util'; +import type { XmlScope } from '../../XmlScope'; + +export class InlayHintProcessor { + public constructor( + public event: ProvideInlayHintsEvent + ) { } + + public process() { + if (!isBrsFile(this.event.file)) { + return; + } + this.collectParameterNameHints(this.event.file); + } + + private collectParameterNameHints(file: BrsFile) { + const range = this.event.range; + + file.ast.walk(createVisitor({ + CallExpression: (call) => { + if (!call.range || !util.rangesIntersectOrTouch(call.range, range)) { + return; + } + this.emitParameterNameHints(file, call); + }, + CallfuncExpression: (call) => { + if (!call.range || !util.rangesIntersectOrTouch(call.range, range)) { + return; + } + this.emitParameterNameHintsForCallfunc(file, call); + } + }), { + walkMode: WalkMode.visitAllRecursive + }); + } + + private emitParameterNameHints(file: BrsFile, call: CallExpression) { + if (!call.args || call.args.length === 0) { + return; + } + + const params = this.resolveCallParameters(file, call); + if (!params) { + return; + } + + this.pushHintsForArgs(call.args, params); + } + + private emitParameterNameHintsForCallfunc(file: BrsFile, call: CallfuncExpression) { + if (!call.args || call.args.length === 0) { + return; + } + const name = call.methodName?.text; + if (!name) { + return; + } + const params = this.resolveCallfuncParameters(file, name); + if (!params) { + return; + } + this.pushHintsForArgs(call.args, params); + } + + /** + * For a CallExpression, find the function/method being called and return its parameter list, + * or undefined if the target cannot be uniquely resolved. + */ + private resolveCallParameters(file: BrsFile, call: CallExpression): FunctionParameterExpression[] | undefined { + //constructor: `new Foo(...)` + if (isNewExpression(call.parent)) { + const className = call.parent.className.getName(ParseMode.BrighterScript); + const classLink = file.getClassFileLink(className); + if (!classLink) { + return undefined; + } + const ctor = file.getClassMethod(classLink.item, 'new'); + return ctor?.func?.parameters; + } + + const callee = call.callee; + + //plain function call: `foo(...)` + if (isVariableExpression(callee)) { + const name = callee.name.text; + const containingNamespace = callee.findAncestor(isNamespaceStatement)?.getName(ParseMode.BrighterScript); + return this.lookupFunctionParameters(file, name, containingNamespace); + } + + //namespace call or method call: `ns.foo(...)` / `m.foo(...)` / `instance.foo(...)` + if (isDottedGetExpression(callee)) { + const name = callee.name.text; + const parts = util.getAllDottedGetParts(callee); + if (!parts || parts.length < 2) { + return undefined; + } + //drop the last part (the function name) to get the namespace/receiver path + const dotPart = parts.slice(0, parts.length - 1).map(x => x.text).join('.'); + + //prefer namespace lookup when the dotPart is a known namespace + const scope = file.program.getFirstScopeForFile(file); + const namespace = scope?.namespaceLookup?.get(dotPart.toLowerCase()); + if (namespace) { + return this.lookupFunctionParameters(file, name, dotPart); + } + + //otherwise, treat as method call on m or another receiver - look across class methods + return this.lookupClassMethodParameters(file, callee, name); + } + + return undefined; + } + + private lookupFunctionParameters(file: BrsFile, name: string, namespaceName?: string): FunctionParameterExpression[] | undefined { + const matches = file.program.getStatementsByName(name, file, namespaceName); + if (matches.length !== 1) { + return undefined; + } + const statement = matches[0].item; + if (isFunctionStatement(statement) || isMethodStatement(statement)) { + return (statement as FunctionStatement | MethodStatement).func?.parameters; + } + return undefined; + } + + /** + * For a method call like `m.foo(...)`, find a uniquely-resolvable method statement. + * If the receiver is `m`, prefer the enclosing class. Otherwise fall back to a name search + * across all classes (only used when there's exactly one match). + */ + private lookupClassMethodParameters(file: BrsFile, callee: { obj?: any }, name: string): FunctionParameterExpression[] | undefined { + if (isVariableExpression(callee.obj) && callee.obj.name.text === 'm') { + const enclosingClass = callee.obj.findAncestor(isClassStatement); + if (enclosingClass) { + const method = file.getClassMethod(enclosingClass, name, true); + if (method) { + return method.func?.parameters; + } + } + } + + //fallback: search all classes for a single matching method name + const matches = file.program.getStatementsByName(name, file).filter(link => isClassStatement(link.item.parent)); + if (matches.length !== 1) { + return undefined; + } + const statement = matches[0].item; + if (isMethodStatement(statement) || isFunctionStatement(statement)) { + return (statement as FunctionStatement | MethodStatement).func?.parameters; + } + return undefined; + } + + private resolveCallfuncParameters(file: BrsFile, name: string): FunctionParameterExpression[] | undefined { + //callfunc invocations are dispatched through XML components - look across xml scopes for a function with this name + const matches = file.program.getScopes() + .filter(scope => isXmlScope(scope)) + .flatMap(scope => file.program.getStatementsForXmlFile(scope as XmlScope, name)); + + if (matches.length !== 1) { + return undefined; + } + const statement = matches[0].item; + if (isFunctionStatement(statement) || isMethodStatement(statement)) { + return (statement as FunctionStatement | MethodStatement).func?.parameters; + } + return undefined; + } + + private pushHintsForArgs(args: Array<{ range?: Range }>, params: FunctionParameterExpression[]) { + for (let i = 0; i < args.length && i < params.length; i++) { + const arg = args[i]; + const param = params[i]; + const paramName = param?.name?.text; + if (!paramName || !arg?.range) { + continue; + } + //skip when the argument is just an identifier that already matches the parameter name + if (isVariableExpression(arg as any) && (arg as any).name?.text?.toLowerCase() === paramName.toLowerCase()) { + continue; + } + this.event.inlayHints.push({ + position: arg.range.start, + label: `${paramName}:`, + kind: InlayHintKind.Parameter, + paddingRight: true + }); + } + } +} + diff --git a/src/interfaces.ts b/src/interfaces.ts index c629aa700..691cad8c6 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType, SelectionRange } from 'vscode-languageserver-protocol'; +import type { Range, Diagnostic, CodeAction, Position, CompletionItem, Location, DocumentSymbol, WorkspaceSymbol, Disposable, FileChangeType, SelectionRange, InlayHint } from 'vscode-languageserver-protocol'; import type { Scope } from './Scope'; import type { BrsFile } from './files/BrsFile'; import type { XmlFile } from './files/XmlFile'; @@ -346,6 +346,20 @@ export interface Plugin { afterProvideSelectionRanges?(event: AfterProvideSelectionRangesEvent): any; + /** + * Called before the `provideInlayHints` hook + */ + beforeProvideInlayHints?(event: BeforeProvideInlayHintsEvent): any; + /** + * Provide inlay hints (e.g. parameter names at call sites, inferred type annotations) for the given range. + */ + provideInlayHints?(event: ProvideInlayHintsEvent): any; + /** + * Called after `provideInlayHints`. Use this if you want to intercept or sanitize the inlay hints provided by bsc or other plugins. + */ + afterProvideInlayHints?(event: AfterProvideInlayHintsEvent): any; + + onGetSemanticTokens?: PluginHandler; //scope events afterScopeCreate?: (scope: Scope) => void; @@ -516,6 +530,29 @@ export type BeforeProvideSelectionRangesEvent = ProvideSelectio export type AfterProvideSelectionRangesEvent = ProvideSelectionRangesEvent; +export interface ProvideInlayHintsEvent { + program: Program; + /** + * The file that the `inlayHint` request was invoked in + */ + file: TFile; + /** + * The range of the document for which inlay hints should be computed + */ + range: Range; + /** + * The list of scopes that this file is a member of + */ + scopes: Scope[]; + /** + * The result list of inlay hints. Plugins push hints into this array. + */ + inlayHints: InlayHint[]; +} +export type BeforeProvideInlayHintsEvent = ProvideInlayHintsEvent; +export type AfterProvideInlayHintsEvent = ProvideInlayHintsEvent; + + export interface OnGetSemanticTokensEvent { /** * The program this file is from diff --git a/src/lsp/LspProject.ts b/src/lsp/LspProject.ts index 0bd36d6b8..0faad9529 100644 --- a/src/lsp/LspProject.ts +++ b/src/lsp/LspProject.ts @@ -1,4 +1,4 @@ -import type { Diagnostic, Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, SelectionRange, TextEdit } from 'vscode-languageserver-protocol'; +import type { Diagnostic, Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, SelectionRange, TextEdit, InlayHint } from 'vscode-languageserver-protocol'; import type { Hover, MaybePromise, SemanticToken } from '../interfaces'; import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager'; import type { FileTranspileResult, SignatureInfoObj } from '../Program'; @@ -175,6 +175,11 @@ export interface LspProject { */ getSelectionRanges(options: { srcPath: string; positions: Position[] }): MaybePromise; + /** + * Get the inlay hints for the given range in the specified file + */ + getInlayHints(options: { srcPath: string; range: Range }): MaybePromise; + /** * Get the completions for the specified file and position */ diff --git a/src/lsp/Project.ts b/src/lsp/Project.ts index 7376d2eb1..faa31b195 100644 --- a/src/lsp/Project.ts +++ b/src/lsp/Project.ts @@ -10,7 +10,7 @@ import { URI } from 'vscode-uri'; import { Deferred } from '../deferred'; import type { StandardizedFileEntry } from 'roku-deploy'; import { rokuDeploy } from 'roku-deploy'; -import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol } from 'vscode-languageserver-protocol'; +import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol, InlayHint } from 'vscode-languageserver-protocol'; import { CompletionList } from 'vscode-languageserver-protocol'; import { CancellationTokenSource } from 'vscode-languageserver-protocol'; import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager'; @@ -519,6 +519,13 @@ export class Project implements LspProject { } } + public async getInlayHints(options: { srcPath: string; range: Range }): Promise { + await this.onIdle(); + if (this.builder.program.hasFile(options.srcPath)) { + return this.builder.program.getInlayHints(options.srcPath, options.range); + } + } + public async getCodeActions(options: { srcPath: string; range: Range }) { await this.onIdle(); diff --git a/src/lsp/ProjectManager.ts b/src/lsp/ProjectManager.ts index d1e90b362..2390fb078 100644 --- a/src/lsp/ProjectManager.ts +++ b/src/lsp/ProjectManager.ts @@ -6,7 +6,7 @@ import type { FileRenameTextEdit, LspDiagnostic, LspProject, ProjectConfig } fro import { Project } from './Project'; import { WorkerThreadProject } from './worker/WorkerThreadProject'; import { FileChangeType } from 'vscode-languageserver-protocol'; -import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken, SelectionRange } from 'vscode-languageserver-protocol'; +import type { Hover, Position, Range, Location, SignatureHelp, DocumentSymbol, SymbolInformation, WorkspaceSymbol, CompletionList, CancellationToken, SelectionRange, InlayHint } from 'vscode-languageserver-protocol'; import { Deferred } from '../deferred'; import type { DocumentActionWithStatus, FlushEvent } from './DocumentManager'; import { DocumentManager } from './DocumentManager'; @@ -803,6 +803,20 @@ export class ProjectManager { return result ?? []; } + @TrackBusyStatus + public async getInlayHints(options: { srcPath: string; range: Range }): Promise { + //wait for all pending syncs to finish + await this.onIdle(); + + //Ask every project for inlay hints, keep whichever one responds first with a non-empty result + let result = await util.promiseRaceMatch( + this.projects.map(x => x.getInlayHints(options)), + //keep the first non-empty result + (result) => result?.length > 0 + ); + return result ?? []; + } + /** * Scan a given workspace for all `bsconfig.json` files. If at least one is found, then only folders who have bsconfig.json are returned. * If none are found, then the workspaceFolder itself is treated as a project diff --git a/src/lsp/worker/WorkerThreadProject.ts b/src/lsp/worker/WorkerThreadProject.ts index 30e91ba1a..278a087c9 100644 --- a/src/lsp/worker/WorkerThreadProject.ts +++ b/src/lsp/worker/WorkerThreadProject.ts @@ -10,7 +10,7 @@ import type { Hover, MaybePromise, SemanticToken } from '../../interfaces'; import type { DocumentAction, DocumentActionWithStatus } from '../DocumentManager'; import { Deferred } from '../../deferred'; import type { FileTranspileResult, SignatureInfoObj } from '../../Program'; -import type { Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, SelectionRange } from 'vscode-languageserver-protocol'; +import type { Position, Range, Location, DocumentSymbol, WorkspaceSymbol, CodeAction, CompletionList, SelectionRange, InlayHint } from 'vscode-languageserver-protocol'; import type { Logger } from '../../logging'; import { createLogger } from '../../logging'; import * as fsExtra from 'fs-extra'; @@ -279,6 +279,10 @@ export class WorkerThreadProject implements LspProject { return this.sendStandardRequest('getSelectionRanges', options); } + public async getInlayHints(options: { srcPath: string; range: Range }): Promise { + return this.sendStandardRequest('getInlayHints', options); + } + public async getCompletions(options: { srcPath: string; position: Position }): Promise { return this.sendStandardRequest('getCompletions', options); }