fish-lsp
Version:
LSP implementation for fish/fish-shell
543 lines (496 loc) • 16.7 kB
text/typescript
import { Hover, MarkupContent, MarkupKind, Position, SymbolKind, WorkspaceSymbol, URI, Location } from 'vscode-languageserver';
import Parser, { SyntaxNode, Tree } from 'web-tree-sitter';
import * as LSP from 'vscode-languageserver';
import { isPositionWithinRange, getChildNodes } from './utils/tree-sitter';
import { LspDocument } from './document';
import { isCommand, isCommandName } from './utils/node-types';
import { pathToUri } from './utils/translation';
import { existsSync } from 'fs';
import homedir from 'os';
import { Workspace } from './utils/workspace';
import { filterGlobalSymbols, FishDocumentSymbol, getFishDocumentSymbols } from './document-symbol';
import { GenericTree } from './utils/generic-tree';
import { findDefinitionSymbols } from './workspace-symbol';
import { config } from './config';
import { logger } from './logger';
export class Analyzer {
protected parser: Parser;
public workspaces: Workspace[];
public cache: AnalyzedDocumentCache = new AnalyzedDocumentCache();
public globalSymbols: GlobalDefinitionCache = new GlobalDefinitionCache();
public amountIndexed: number = 0;
constructor(parser: Parser, workspaces: Workspace[] = []) {
this.parser = parser;
this.workspaces = workspaces;
}
public analyze(document: LspDocument): FishDocumentSymbol[] {
this.parser.reset();
const analyzedDocument = this.getAnalyzedDocument(
this.parser,
document,
);
this.cache.setDocument(document.uri, analyzedDocument);
const symbols = this.cache.getDocumentSymbols(document.uri);
filterGlobalSymbols(symbols).forEach((symbol: FishDocumentSymbol) => {
this.globalSymbols.add(symbol);
});
return this.cache.getDocumentSymbols(document.uri);
}
private getAnalyzedDocument(
parser: Parser,
document: LspDocument,
): AnalyzedDocument {
const tree = parser.parse(document.getText());
const documentSymbols = getFishDocumentSymbols(
document,
tree.rootNode,
);
const commands = this.getCommandNames(document);
return AnalyzedDocument.create(
document,
documentSymbols,
commands,
tree,
);
}
public async initiateBackgroundAnalysis(
callbackfn: (text: string) => void,
): Promise<{ filesParsed: number; }> {
const startTime = performance.now();
const max_files = config.fish_lsp_max_background_files;
let amount = 0;
const analysisPromises: Promise<void>[] = [];
for (const workspace of this.workspaces) {
const docs = workspace
.urisToLspDocuments()
.filter((doc: LspDocument) => doc.shouldAnalyzeInBackground())
.slice(0, max_files - amount); // Only take what we need up to max_files
// Create promises for each document analysis
const workspacePromises = docs.map(async (doc) => {
try {
this.analyze(doc);
amount++;
} catch (err) {
logger.log(err);
}
});
analysisPromises.push(...workspacePromises);
if (amount >= max_files) {
break;
}
}
// Wait for all analysis tasks to complete
await Promise.all(analysisPromises);
this.amountIndexed = amount;
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2); // Convert to seconds with 2 decimal places
callbackfn(`[fish-lsp] analyzed ${amount} files in ${duration}s`);
logger.log(`[fish-lsp] analyzed ${amount} files in ${duration}s`);
return { filesParsed: amount };
}
public findDocumentSymbol(
document: LspDocument,
position: Position,
): FishDocumentSymbol | undefined {
const symbols = FishDocumentSymbol.flattenArray(
this.cache.getDocumentSymbols(document.uri),
);
const wordAtPoint = this.wordAtPoint(document.uri, position.line, position.character);
return symbols.find((symbol) => {
if (symbol.kind === SymbolKind.Function && wordAtPoint === symbol.name) {
return symbol.scope.containsPosition(position);
}
return isPositionWithinRange(position, symbol.selectionRange);
});
}
/**
* method that returns all the workspaceSymbols that are in the same scope as the given
* shell
* @returns {WorkspaceSymbol[]} array of all symbols
*/
public getWorkspaceSymbols(query: string = ''): WorkspaceSymbol[] {
return this.globalSymbols.allSymbols
.map((s) => FishDocumentSymbol.toWorkspaceSymbol(s))
.filter((symbol: WorkspaceSymbol) => {
return symbol.name.startsWith(query);
});
}
public getDefinition(
document: LspDocument,
position: Position,
): FishDocumentSymbol {
const symbols: FishDocumentSymbol[] = findDefinitionSymbols(this, document, position);
return symbols[0]!;
}
public getDefinitionLocation(
document: LspDocument,
position: Position,
): LSP.Location[] {
const symbol = this.getDefinition(document, position) as FishDocumentSymbol;
if (symbol) {
return [
Location.create(symbol.uri, symbol.selectionRange),
];
}
return [];
}
public getHover(document: LspDocument, position: Position): Hover | null {
const tree = this.getTree(document);
const node = this.nodeAtPoint(
document.uri,
position.line,
position.character,
);
if (!tree || !node) {
return null;
}
const symbol =
this.getDefinition(document, position) as FishDocumentSymbol ||
this.globalSymbols.findFirst(node.text);
if (symbol) {
return {
contents: {
kind: MarkupKind.Markdown,
value: symbol.detail,
} as MarkupContent,
};
}
return null;
}
//public findCompletions(
// document: LspDocument,
// position: Position,
// data: FishCompletionData
//): FishCompletionItem[] {
// const symbols = this.cache.getDocumentSymbols(document.uri);
// const localSymbols = findSymbolsForCompletion(symbols, position);
//
// const globalSymbols = this.globalSymbols
// .uniqueSymbols()
// .filter((s) => !localSymbols.some((l) => s.name === l.name))
// .map((s) => FishDocumentSymbol.toGlobalCompletion(s, data));
//
// return [
// ...localSymbols.map((s) =>
// FishDocumentSymbol.toLocalCompletion(s, data)
// ),
// ...globalSymbols,
// ];
//}
getTree(document: LspDocument): Tree | undefined {
return this.cache.getDocument(document.uri)?.tree;
}
/**
* Finds the rootnode given a LspDocument. If useCache is set to false, it will
* use the parser to parse the document passed in, and then return the rootNode.
*/
getRootNode(document: LspDocument): SyntaxNode | undefined {
return this.cache.getParsedTree(document.uri)?.rootNode;
}
getDocument(documentUri: string): LspDocument | undefined {
return this.cache.getDocument(documentUri)?.document;
}
getDocumentSymbols(documentUri: string): FishDocumentSymbol[] {
return this.cache.getDocumentSymbols(documentUri);
}
getFlatDocumentSymbols(documentUri: string): FishDocumentSymbol[] {
return this.cache.getFlatDocumentSymbols(documentUri);
}
public parsePosition(
document: LspDocument,
position: Position,
): { root: SyntaxNode | null; currentNode: SyntaxNode | null; } {
const root = this.getRootNode(document) || null;
return {
root: root,
currentNode:
root?.descendantForPosition({
row: position.line,
column: Math.max(0, position.character - 1),
}) || null,
};
}
/**
* Returns an object to be deconstructed, for the onComplete function in the server.
* This function is necessary because the normal onComplete parse of the LspDocument
* will commonly throw errors (user is incomplete typing a command, etc.). To avoid
* inaccurate parses for the entire document, we instead parse just the current line
* that the user is on, and send it to the shell script to complete.
*
* @Note: the position should not edited (pass in the direct position from the CompletionParams)
*
* @returns
* line - the string output of the line the cursor is on
* lineRootNode - the rootNode for the line that the cursor is on
* lineCurrentNode - the last node in the line
*/
public parseCurrentLine(
document: LspDocument,
position: Position,
): {
line: string;
word: string;
lineRootNode: SyntaxNode;
lineLastNode: SyntaxNode;
} {
//const linePreTrim: string = document.getLineBeforeCursor(position);
//const line = linePreTrim.slice(0,linePreTrim.lastIndexOf('\n'));
const line = document
.getLineBeforeCursor(position)
.replace(/^(.*)\n$/, '$1') || '';
const word =
this.wordAtPoint(
document.uri,
position.line,
Math.max(position.character - 1, 0),
) || '';
const lineRootNode = this.parser.parse(line).rootNode;
const lineLastNode = lineRootNode.descendantForPosition({
row: 0,
column: line.length - 1,
});
return { line, word, lineRootNode, lineLastNode };
}
public wordAtPoint(
uri: string,
line: number,
column: number,
): string | null {
const node = this.nodeAtPoint(uri, line, column);
if (!node || node.childCount > 0 || node.text.trim() === '') {
return null;
}
return node.text.trim();
}
/**
* Find the node at the given point.
*/
public nodeAtPoint(
uri: string,
line: number,
column: number,
): Parser.SyntaxNode | null {
const tree = this.cache.getParsedTree(uri);
if (!tree?.rootNode) {
// Check for lacking rootNode (due to failed parse?)
return null;
}
return tree.rootNode.descendantForPosition({ row: line, column });
}
/**
* Find the name of the command at the given point.
*/
public commandNameAtPoint(
uri: string,
line: number,
column: number,
): string | null {
let node = this.nodeAtPoint(uri, line, column);
while (node && !isCommand(node)) {
node = node.parent;
}
if (!node) {
return null;
}
const firstChild = node.firstNamedChild;
if (!firstChild || !isCommandName(firstChild)) {
return null;
}
return firstChild.text.trim();
}
public getNodes(document: LspDocument): SyntaxNode[] {
return getChildNodes(this.parser.parse(document.getText()).rootNode);
}
private getCommandNames(document: LspDocument): string[] {
const allCommands = this.getNodes(document)
.filter((node) => isCommandName(node))
.map((node) => node.text);
const result = new Set(allCommands);
return Array.from(result);
}
public getExistingAutoloadedFiles(name: string): string[] {
const searchNames = [
`${homedir}/.config/functions/${name}.fish`,
`${homedir}/.config/completions/${name}.fish`,
];
return searchNames
.filter((path) => existsSync(path))
.map((path) => pathToUri(path));
}
}
export class GlobalDefinitionCache {
constructor(private _definitions: Map<string, FishDocumentSymbol[]> = new Map()) { }
add(symbol: FishDocumentSymbol): void {
const current = this._definitions.get(symbol.name) || [];
if (!current.some(s => FishDocumentSymbol.equal(s, symbol))) {
current.push(symbol);
}
this._definitions.set(symbol.name, current);
}
find(name: string): FishDocumentSymbol[] {
return this._definitions.get(name) || [];
}
findFirst(name: string): FishDocumentSymbol | undefined {
const symbols = this.find(name);
if (symbols.length === 0) {
return undefined;
}
return symbols[0];
}
has(name: string): boolean {
return this._definitions.has(name);
}
uniqueSymbols(): FishDocumentSymbol[] {
const unique: FishDocumentSymbol[] = [];
this.allNames.forEach(name => {
const u = this.findFirst(name);
if (u) {
unique.push(u);
}
});
return unique;
}
get allSymbols(): FishDocumentSymbol[] {
const all: FishDocumentSymbol[] = [];
for (const [_, symbols] of this._definitions.entries()) {
all.push(...symbols);
}
return all;
}
get allNames(): string[] {
return [...this._definitions.keys()];
}
get map(): Map<string, FishDocumentSymbol[]> {
return this._definitions;
}
}
type AnalyzedDocument = {
document: LspDocument;
documentSymbols: FishDocumentSymbol[];
commands: string[];
tree: Parser.Tree;
};
export namespace AnalyzedDocument {
export function create(document: LspDocument, documentSymbols: FishDocumentSymbol[], commands: string[], tree: Parser.Tree): AnalyzedDocument {
return {
document,
documentSymbols,
commands,
tree,
};
}
}
export class AnalyzedDocumentCache {
constructor(private _documents: Map<URI, AnalyzedDocument> = new Map()) { }
uris(): string[] {
return [...this._documents.keys()];
}
setDocument(uri: URI, analyzedDocument: AnalyzedDocument): void {
this._documents.set(uri, analyzedDocument);
}
getDocument(uri: URI): AnalyzedDocument | undefined {
if (!this._documents.has(uri)) {
return undefined;
}
return this._documents.get(uri);
}
updateUri(oldUri: URI, newUri: URI): void {
const oldValue = this.getDocument(oldUri);
if (oldValue) {
this._documents.delete(oldUri);
this._documents.set(newUri, oldValue);
}
}
getDocumentSymbols(uri: URI): FishDocumentSymbol[] {
return this._documents.get(uri)?.documentSymbols || [];
}
getFlatDocumentSymbols(uri: URI): FishDocumentSymbol[] {
return FishDocumentSymbol.flattenArray(this.getDocumentSymbols(uri));
}
getCommands(uri: URI): string[] {
return this._documents.get(uri)?.commands || [];
}
getRootNode(uri: URI): Parser.SyntaxNode | undefined {
return this.getParsedTree(uri)?.rootNode;
}
getParsedTree(uri: URI): Parser.Tree | undefined {
return this._documents.get(uri)?.tree;
}
getSymbolTree(uri: URI): GenericTree<FishDocumentSymbol> {
const document = this.getDocument(uri);
if (!document) {
return new GenericTree<FishDocumentSymbol>([]);
}
return new GenericTree<FishDocumentSymbol>(document.documentSymbols);
}
/**
* Name is a string that will be searched across all symbols in cache. tree-sitter-fish
* type of symbols that will be searched is 'word' (i.e. variables, functions, commands)
* @param {string} name - string SyntaxNode.name to search in cache
* @returns {map<URI, SyntaxNode[]>} - map of URIs to SyntaxNodes that match the name
*/
findMatchingNames(name: string): Map<URI, SyntaxNode[]> {
const matches = new Map<URI, SyntaxNode[]>();
this.forEach((uri, doc) => {
const root = doc.tree.rootNode;
const nodes = root.descendantsOfType('word').filter(node => node.text === name);
if (nodes.length > 0) {
matches.set(uri, nodes);
}
});
return matches;
}
forEach(callbackfn: (uri: URI, document: AnalyzedDocument) => void): void {
for (const [uri, document] of this._documents) {
callbackfn(uri, document);
}
}
filter(callbackfn: (uri: URI, document?: AnalyzedDocument) => boolean): AnalyzedDocument[] {
const result: AnalyzedDocument[] = [];
this.forEach((currentUri, currentDocument) => {
if (callbackfn(currentUri, currentDocument)) {
result.push(currentDocument);
}
});
return result;
}
mapUris<U>(callbackfn: (doc: AnalyzedDocument) => U, uris: URI[] = this.uris()): U[] {
const result: U[] = [];
for (const uri of uris) {
const doc = this.getDocument(uri);
if (!doc) {
continue;
}
result.push(callbackfn(doc));
}
return result;
}
}
export class SymbolCache {
constructor(
private _names: Set<string> = new Set(),
private _variables: Map<string, FishDocumentSymbol[]> = new Map(),
private _functions: Map<string, FishDocumentSymbol[]> = new Map(),
) { }
add(symbol: FishDocumentSymbol): void {
const oldVars = this._variables.get(symbol.name) || [];
switch (symbol.kind) {
case SymbolKind.Variable:
this._variables.set(symbol.name, [...oldVars, symbol]);
break;
case SymbolKind.Function:
this._functions.set(symbol.name, [...oldVars, symbol]);
break;
}
this._names.add(symbol.name);
}
isVariable(name: string): boolean {
return this._variables.has(name);
}
isFunction(name: string): boolean {
return this._functions.has(name);
}
has(name: string): boolean {
return this._names.has(name);
}
}