fish-lsp
Version:
LSP implementation for fish/fish-shell
669 lines (578 loc) • 25.9 kB
text/typescript
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);
}
}