UNPKG

fish-lsp

Version:

LSP implementation for fish/fish-shell

669 lines (578 loc) 25.9 kB
import Parser, { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from './parser'; import { Analyzer } from './analyze'; import { InitializeParams, CompletionParams, Connection, CompletionList, CompletionItem, MarkupContent, DocumentSymbolParams, DefinitionParams, Location, ReferenceParams, DocumentSymbol, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidSaveTextDocumentParams, InitializeResult, HoverParams, Hover, RenameParams, TextDocumentPositionParams, TextDocumentIdentifier, WorkspaceEdit, TextEdit, DocumentFormattingParams, CodeActionParams, CodeAction, DocumentRangeFormattingParams, FoldingRangeParams, FoldingRange, InlayHintParams, MarkupKind, WorkspaceSymbolParams, WorkspaceSymbol, SymbolKind, CompletionTriggerKind, SignatureHelpParams, SignatureHelp, DocumentHighlight, DocumentHighlightParams, PublishDiagnosticsParams } from 'vscode-languageserver'; import * as LSP from 'vscode-languageserver'; import { LspDocument, LspDocuments } from './document'; import { formatDocumentContent } from './formatting'; import { Logger, logger } from './logger'; import { symbolKindsFromNode, uriToPath } from './utils/translation'; import { getChildNodes, getNodeAtPosition } from './utils/tree-sitter'; import { handleHover } from './hover'; import { getDiagnostics } from './diagnostics/validate'; import { DocumentationCache, initializeDocumentationCache } from './utils/documentation-cache'; import { initializeDefaultFishWorkspaces } from './utils/workspace'; import { filterLastPerScopeSymbol, FishDocumentSymbol } from './document-symbol'; import { getRenameWorkspaceEdit, getReferenceLocations } from './workspace-symbol'; import { CompletionPager, initializeCompletionPager, SetupData } from './utils/completion/pager'; import { FishCompletionItem } from './utils/completion/types'; import { getDocumentationResolver } from './utils/completion/documentation'; import { FishCompletionList } from './utils/completion/list'; import { PrebuiltDocumentationMap, getPrebuiltDocUrl } from './utils/snippets'; import { findParentCommand, isCommand, isVariableDefinition } from './utils/node-types'; import { adjustInitializeResultCapabilitiesFromConfig, configHandlers, config } from './config'; import { enrichToMarkdown } from './documentation'; import { getAliasedCompletionItemSignature } from './signature'; import { CompletionItemMap } from './utils/completion/startup-cache'; import { getDocumentHighlights } from './document-highlight'; import { buildCommentCompletions } from './utils/completion/comment-completions'; import { createCodeActionHandler } from './code-actions/code-action-handler'; import { createExecuteCommandHandler } from './command'; import { getStatusInlayHints } from './code-lens'; // @TODO export type SupportedFeatures = { codeActionDisabledSupport: boolean; }; export default class FishServer { public static async create( connection: Connection, _params: InitializeParams, ): Promise<FishServer> { const documents = new LspDocuments(); // Run these operations in parallel rather than sequentially const [ parser, cache, workspaces, completionsMap, ] = await Promise.all([ initializeParser(), initializeDocumentationCache(), initializeDefaultFishWorkspaces(), CompletionItemMap.initialize(), ]); const analyzer = new Analyzer(parser, workspaces); const completions = await initializeCompletionPager(logger, completionsMap); return new FishServer( connection, parser, analyzer, documents, completions, completionsMap, cache, logger, ); } private initializeParams: InitializeParams | undefined; protected features: SupportedFeatures; constructor( // the connection of the FishServer private connection: Connection, private parser: Parser, public analyzer: Analyzer, private docs: LspDocuments, private completion: CompletionPager, private completionMap: CompletionItemMap, private documentationCache: DocumentationCache, protected logger: Logger, ) { this.features = { codeActionDisabledSupport: false }; } async initialize(params: InitializeParams): Promise<InitializeResult> { logger.logAsJson('async server.initialize(params)'); if (params) { logger.log(); logger.log({ 'server.initialize.params': params }); logger.log(); } const result = adjustInitializeResultCapabilitiesFromConfig(configHandlers, config); logger.log({ onInitializedResult: result }); return result; } register(connection: Connection): void { const codeActionHandler = createCodeActionHandler(this.docs, this.analyzer); const executeHandler = createExecuteCommandHandler(this.connection, this.docs, this.logger); //this.connection.window.createWorkDoneProgress(); connection.onInitialized(this.onInitialized.bind(this)); connection.onDidOpenTextDocument(this.didOpenTextDocument.bind(this)); connection.onDidChangeTextDocument(this.didChangeTextDocument.bind(this)); connection.onDidCloseTextDocument(this.didCloseTextDocument.bind(this)); connection.onDidSaveTextDocument(this.didSaveTextDocument.bind(this)); // • for multiple completionProviders -> https://github.com/microsoft/vscode-extension-samples/blob/main/completions-sample/src/extension.ts#L15 // • https://github.com/Dart-Code/Dart-Code/blob/7df6509870d51cc99a90cf220715f4f97c681bbf/src/providers/dart_completion_item_provider.ts#L197-202 connection.onCompletion(this.onCompletion.bind(this)); connection.onCompletionResolve(this.onCompletionResolve.bind(this)), connection.onDocumentSymbol(this.onDocumentSymbols.bind(this)); connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this)); // this.connection.onWorkspaceSymbolResolve(this.onWorkspaceSymbolResolve.bind(this)) connection.onDefinition(this.onDefinition.bind(this)); connection.onReferences(this.onReferences.bind(this)); connection.onHover(this.onHover.bind(this)); connection.onRenameRequest(this.onRename.bind(this)); connection.onDocumentFormatting(this.onDocumentFormatting.bind(this)); connection.onDocumentRangeFormatting(this.onDocumentRangeFormatting.bind(this)); connection.onCodeAction(codeActionHandler); connection.onFoldingRanges(this.onFoldingRanges.bind(this)); //this.connection.workspace.applyEdit() connection.onDocumentHighlight(this.onDocumentHighlight.bind(this)); connection.languages.inlayHint.on(this.onInlayHints.bind(this)); connection.onSignatureHelp(this.onShowSignatureHelp.bind(this)); connection.onExecuteCommand(executeHandler); logger.log({ 'server.register': 'registered' }); } didOpenTextDocument(params: DidOpenTextDocumentParams): void { const textDoc = params.textDocument; const textDocText = textDoc.text.length > 300 ? textDoc.text.slice(0, 300) + `\n...[${textDoc.text.length - 300} chars]` : textDoc.text; this.logParams('didOpenTextDocument', { textDocument: { version: textDoc.version, uri: textDoc.uri, text: textDocText, languageID: textDoc.languageId, }, }); const uri = uriToPath(params.textDocument.uri); if (!uri) { logger.logAsJson(`DID NOT OPEN ${uri} \n URI is null or undefined`); return; } if (this.docs.open(uri, params.textDocument)) { const doc = this.docs.get(uri); if (doc) { this.logParams('opened document: ', params.textDocument.uri); this.analyzer.analyze(doc); this.logParams('analyzed document: ', params.textDocument.uri); this.connection.sendDiagnostics(this.sendDiagnostics({ uri: doc.uri, diagnostics: [] })); } } else { logger.logAsJson(`Cannot open already opened doc '${params.textDocument.uri}'.`); this.didChangeTextDocument({ textDocument: params.textDocument, contentChanges: [ { text: params.textDocument.text, }, ], }); } } didChangeTextDocument(params: DidChangeTextDocumentParams): void { this.logParams('didChangeTextDocument', params); const uri = uriToPath(params.textDocument.uri); const doc = this.docs.get(uri); if (!uri || !doc) return; doc.applyEdits(doc.version + 1, ...params.contentChanges); this.analyzer.analyze(doc); logger.logAsJson(`CHANGED -> ${doc.version}:::${doc.uri}`); const root = this.analyzer.getRootNode(doc); if (!root) return; this.connection.sendDiagnostics(this.sendDiagnostics({ uri: doc.uri, diagnostics: [] })); // else ? } didCloseTextDocument(params: DidCloseTextDocumentParams): void { this.logParams('didCloseTextDocument', params); const uri = uriToPath(params.textDocument.uri); if (!uri) return; logger.logAsJson(`[${this.didCloseTextDocument.name}]: ${params.textDocument.uri}`); this.docs.close(uri); logger.logAsJson(`closed uri: ${uri}`); } didSaveTextDocument(params: DidSaveTextDocumentParams): void { this.logParams('didSaveTextDocument', params); return; } // @see: // • @link [bash-lsp](https://github.com/bash-lsp/bash-language-server/blob/3a319865af9bd525d8e08cd0dd94504d5b5b7d66/server/src/server.ts#L236) async onInitialized() { return { backgroundAnalysisCompleted: this.startBackgroundAnalysis(), }; } // @TODO: REFACTOR THIS OUT OF SERVER // https://github.com/Dart-Code/Dart-Code/blob/7df6509870d51cc99a90cf220715f4f97c681bbf/src/providers/dart_completion_item_provider.ts#L197-202 // https://github.com/microsoft/vscode-languageserver-node/pull/322 // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#insertTextModehttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#insertTextMode // • clean up into completion.ts file & Decompose to state machine, with a function that gets the state machine in this class. // DART is best example i've seen for this. // ~ https://github.com/Dart-Code/Dart-Code/blob/7df6509870d51cc99a90cf220715f4f97c681bbf/src/providers/dart_completion_item_provider.ts#L197-202 ~ // • Implement both escapedCompletion script and dump syntax tree script // • Add default CompletionLists to complete.ts // • Add local file items. // • Lastly add parameterInformation items. [ 1477 : ParameterInformation ] // convert to CompletionItem[] async onCompletion(params: CompletionParams): Promise<CompletionList> { this.logParams('onCompletion', params); const { doc, uri, current } = this.getDefaults(params); let list: FishCompletionList = FishCompletionList.empty(); if (!uri || !doc) { logger.logAsJson('onComplete got [NOT FOUND]: ' + uri); return this.completion.empty(); } const symbols = this.analyzer.cache.getFlatDocumentSymbols(doc.uri); const { line, word } = this.analyzer.parseCurrentLine(doc, params.position); if (!line) return await this.completion.completeEmpty(symbols); const fishCompletionData = { uri: doc.uri, position: params.position, context: { triggerKind: params.context?.triggerKind || CompletionTriggerKind.Invoked, triggerCharacter: params.context?.triggerCharacter, }, } as SetupData; if (line.trim().startsWith('#') && current) { logger.log('completeComment'); return buildCommentCompletions(line, params.position, current, fishCompletionData, word); } if (word.trim().endsWith('$') || line.trim().endsWith('$') || word.trim() === '$') { logger.log('completeVariables'); return this.completion.completeVariables(line, word, fishCompletionData, symbols); } try { logger.log('complete'); // logger.log({ uri: uri, symbols: symbols.map(s => s.name) }); list = await this.completion.complete(line, fishCompletionData, symbols); } catch (error) { this.logger.logAsJson('ERROR: onComplete ' + error?.toString() || 'error'); } return list; } /** * until further reworking, onCompletionResolve requires that when a completionBuilderItem() is .build() * it it also given the method .kind(FishCompletionItemKind) to set the kind of the item. * Not seeing a completion result, with typed correctly is likely caused from this. */ async onCompletionResolve(item: CompletionItem): Promise<CompletionItem> { const fishItem = item as FishCompletionItem; if (fishItem.useDocAsDetail) { item.documentation = { kind: MarkupKind.Markdown, value: fishItem.documentation.toString(), }; return item; } const doc = await getDocumentationResolver(fishItem); if (doc) { item.documentation = doc as MarkupContent; } return item; } // • lsp-spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol // • hierarchy of symbols support on line 554: https://github.com/typescript-language-server/typescript-language-server/blob/114d4309cb1450585f991604118d3eff3690237c/src/lsp-server.ts#L554 // // ResolveWorkspaceResult // https://github.com/Dart-Code/Dart-Code/blob/master/src/extension/providers/dart_workspace_symbol_provider.ts#L7 // async onDocumentSymbols( params: DocumentSymbolParams, ): Promise<DocumentSymbol[]> { this.logParams('onDocumentSymbols', params); const { doc } = this.getDefaultsForPartialParams(params); if (!doc) return []; const symbols = this.analyzer.cache.getDocumentSymbols(doc.uri); return filterLastPerScopeSymbol(symbols); } protected get supportHierarchicalDocumentSymbol(): boolean { const textDocument = this.initializeParams?.capabilities.textDocument; const documentSymbol = textDocument && textDocument.documentSymbol; return ( !!documentSymbol && !!documentSymbol.hierarchicalDocumentSymbolSupport ); } /** * highlight provider */ onDocumentHighlight(params: DocumentHighlightParams): DocumentHighlight[] { this.logParams('onDocumentHighlight', params); const { doc } = this.getDefaults(params); if (!doc) return []; const text = doc.getText(); const tree = this.parser.parse(text); const node = getNodeAtPosition(tree, params.position); if (!node) return []; const highlights = getDocumentHighlights(tree, node); return highlights; } async onWorkspaceSymbol(params: WorkspaceSymbolParams): Promise<WorkspaceSymbol[]> { this.logParams('onWorkspaceSymbol', params.query); return this.analyzer.getWorkspaceSymbols(params.query) || []; } // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#showDocumentParams async onDefinition(params: DefinitionParams): Promise<Location[]> { this.logParams('onDefinition', params); const { doc } = this.getDefaults(params); if (!doc) return []; return this.analyzer.getDefinitionLocation(doc, params.position); } async onReferences(params: ReferenceParams): Promise<Location[]> { this.logParams('onReference', params); const { doc, uri, root, current } = this.getDefaults(params); if (!doc || !uri || !root || !current) return []; return getReferenceLocations(this.analyzer, doc, params.position); } // Probably should move away from `documentationCache`. It works but is too expensive memory wise. // REFACTOR into a procedure that conditionally determines output type needed. // Also plan to get rid of any other cache's, so that the garbage collector can do its job. async onHover(params: HoverParams): Promise<Hover | null> { this.logParams('onHover', params); const { doc, uri, root, current } = this.getDefaults(params); if (!doc || !uri || !root || !current) { return null; } const { kindType, kindString } = symbolKindsFromNode(current); logger.log({ currentText: current.text, currentType: current.type, symbolKind: kindString }); const prebuiltSkipType = [ ...PrebuiltDocumentationMap.getByType('pipe'), ...PrebuiltDocumentationMap.getByType('status'), ].find(obj => obj.name === current.text); // const prebuiltDoc = PrebuiltDocumentationMap.getByName(current.text); const symbolItem = this.analyzer.getHover(doc, params.position); if (symbolItem) return symbolItem; if (prebuiltSkipType) { return { contents: enrichToMarkdown([ `___${current.text}___ - _${getPrebuiltDocUrl(prebuiltSkipType)}_`, '___', `type - __(${prebuiltSkipType.type})__`, '___', `${prebuiltSkipType.description}`, ].join('\n')), }; } const symbolType = [ 'function', 'class', 'variable', ].includes(kindString) ? kindType : undefined; const globalItem = await this.documentationCache.resolve( current.text.trim(), uri, symbolType, ); logger.log({ './src/server.ts:395': `this.documentationCache.resolve() found ${!!globalItem}`, docs: globalItem.docs }); if (globalItem && globalItem.docs) { logger.log(globalItem.docs); return { contents: { kind: MarkupKind.Markdown, value: globalItem.docs, }, }; } const fallbackHover = await handleHover( this.analyzer, doc, params.position, current, this.documentationCache, ); logger.log(fallbackHover?.contents); return fallbackHover; } // workspace.fileOperations.didRename // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#fileEvent //applyEdits(params: WorkspaceEdit): void { // this.logParams("applyRenameFile", params); // const changes : ResoucreOperation = params. // for (const change of changes) { // switch (change.kind) { // case 'rename': // this.docs.rename(change.oldUri, change.newUri); // this.analyzer.cache.updateUri(change.oldUri, change.newUri); // // // } // const newUri = change. // } // // return; //} async onRename(params: RenameParams): Promise<WorkspaceEdit | null> { this.logParams('onRename', params); const { doc } = this.getDefaults(params); if (!doc) return null; return getRenameWorkspaceEdit( this.analyzer, doc, params.position, params.newName, ); } async onDocumentFormatting(params: DocumentFormattingParams): Promise<TextEdit[]> { this.logParams('onDocumentFormatting', params); const { doc } = this.getDefaultsForPartialParams(params); if (!doc) return []; const formattedText = await formatDocumentContent(doc.getText()).catch(error => { this.connection.console.error(`Formatting error: ${error}`); if (config.fish_lsp_show_client_popups) { this.connection.window.showErrorMessage(`Failed to format range: ${error}`); } return doc.getText(); // fallback to original text on error }); const fullRange: LSP.Range = { start: doc.positionAt(0), end: doc.positionAt(doc.getText().length), }; return [TextEdit.replace(fullRange, formattedText)]; } async onDocumentRangeFormatting(params: DocumentRangeFormattingParams): Promise<TextEdit[]> { this.logParams('onDocumentRangeFormatting', params); const { doc } = this.getDefaultsForPartialParams(params); if (!doc) return []; const range = params.range; const startOffset = doc.offsetAt(range.start); const endOffset = doc.offsetAt(range.end); const originalText = doc.getText().slice(startOffset, endOffset); const formattedText = await formatDocumentContent(originalText).catch(error => { this.connection.console.error(`Formatting error: ${error}`); if (config.fish_lsp_show_client_popups) { this.connection.window.showErrorMessage(`Failed to format range: ${error}`); } return originalText; // fallback to original text on error }); return [TextEdit.replace(range, formattedText)]; } async onFoldingRanges(params: FoldingRangeParams): Promise<FoldingRange[] | undefined> { this.logParams('onFoldingRanges', params); const file = uriToPath(params.textDocument.uri); const document = this.docs.get(file); if (!document) { throw new Error(`The document should not be opened in the folding range, file: ${file}`); } //this.analyzer.analyze(document) const symbols = this.analyzer.getDocumentSymbols(document.uri); const flatSymbols = FishDocumentSymbol.toTree(symbols).toFlatArray(); logger.logPropertiesForEachObject( flatSymbols.filter((s) => s.kind === SymbolKind.Function), 'name', 'range', ); const folds = flatSymbols .filter((symbol) => symbol.kind === SymbolKind.Function) .map((symbol) => FishDocumentSymbol.toFoldingRange(symbol)); folds.forEach((fold) => logger.log({ fold })); return folds; } async onCodeAction(params: CodeActionParams): Promise<CodeAction[]> { this.logParams('onCodeAction', params); const uri = uriToPath(params.textDocument.uri); const document = this.docs.get(uri); if (!document || !uri) return []; const results: CodeAction[] = []; // for (const diagnostic of params.context.diagnostics) { // const res = handleConversionToCodeAction( // diagnostic, // root, // document, // ); // if (res) results.push(res); // } return results; } // works but is super slow and resource intensive, plus it doesn't really display much async onInlayHints(params: InlayHintParams) { logger.log({ params }); const uri = uriToPath(params.textDocument.uri); const document = this.docs.get(uri); if (!document) return []; const root = this.analyzer.getRootNode(document); if (!root) return []; return getStatusInlayHints(root); } public onShowSignatureHelp(params: SignatureHelpParams): SignatureHelp | null { this.logParams('onShowSignatureHelp', params); const { doc, uri } = this.getDefaults(params); if (!doc || !uri) return null; const { line, lineRootNode, lineLastNode } = this.analyzer.parseCurrentLine(doc, params.position); if (line.trim() === '') return null; const currentCmd = findParentCommand(lineLastNode)!; // const commands = getChildNodes(lineRootNode).filter(isCommand) const aliasSignature = this.completionMap.allOfKinds('alias').find(a => a.label === currentCmd.text); if (aliasSignature) return getAliasedCompletionItemSignature(aliasSignature); const varNode = getChildNodes(lineRootNode).find(c => isVariableDefinition(c)); const lastCmd = getChildNodes(lineRootNode).filter(c => isCommand(c)).pop(); logger.log({ line, lastCmds: lastCmd?.text }); if (varNode && (line.startsWith('set') || line.startsWith('read')) && lastCmd?.text === lineRootNode.text.trim()) { const varName = varNode.text; const varDocs = PrebuiltDocumentationMap.getByName(varNode.text); if (!varDocs.length) return null; return { signatures: [ { label: varName, documentation: { kind: 'markdown', value: varDocs.map(d => d.description).join('\n'), }, }, ], activeSignature: 0, activeParameter: 0, }; } return null; } public sendDiagnostics(params: PublishDiagnosticsParams) { this.logParams('sendDiagnostics', params); const { diagnostics } = params; const uri = uriToPath(params.uri); const doc = this.docs.get(uri); if (!doc) return { uri: params.uri, diagnostics }; const { rootNode } = this.parser.parse(doc.getText()); return { uri: params.uri, diagnostics: getDiagnostics(rootNode, doc) }; } ///////////////////////////////////////////////////////////////////////////////////// // HELPERS ///////////////////////////////////////////////////////////////////////////////////// /** * Logs the params passed into a handler * * @param {string} methodName - the FishLsp method name that was called * @param {any[]} params - the params passed into the method */ private logParams(methodName: string, ...params: any[]) { logger.log({ handler: methodName, params }); } // helper to get all the default objects needed when a TextDocumentPositionParam is passed // into a handler private getDefaults(params: TextDocumentPositionParams): { doc?: LspDocument; uri?: string; root?: SyntaxNode | null; current?: SyntaxNode | null; } { const uri = uriToPath(params.textDocument.uri); const doc = this.docs.get(uri); if (!doc || !uri) return {}; const root = this.analyzer.getRootNode(doc); const current = this.analyzer.nodeAtPoint( doc.uri, params.position.line, params.position.character, ); return { doc, uri, root, current }; } private getDefaultsForPartialParams(params: { textDocument: TextDocumentIdentifier; }): { doc?: LspDocument; uri?: string; root?: SyntaxNode | null; } { const uri = uriToPath(params.textDocument.uri); const doc = this.docs.get(uri); const root = doc ? this.analyzer.getRootNode(doc) : undefined; return { doc, uri, root }; } public async startBackgroundAnalysis(): Promise<{ filesParsed: number; }> { // ../node_modules/vscode-languageserver/lib/common/progress.d.ts const notifyCallback = (text: string) => { if (!config.fish_lsp_show_client_popups) return; this.connection.window.showInformationMessage(text); }; return this.analyzer.initiateBackgroundAnalysis(notifyCallback); } }