fish-lsp
Version:
LSP implementation for fish/fish-shell
402 lines (376 loc) • 13 kB
text/typescript
import { SymbolKind, MarkupContent } from 'vscode-languageserver';
import { execCmd, execCommandDocs, execEscapedCommand } from './exec';
import { FishCompletionItem, CompletionExample } from './completion/types';
import { isBuiltin } from './builtins';
/****************************************************************************************
* *
* @TODO: DO NOT convert this to a FishDocumentSymbol! Instead, use this to cache to *
* FishDocumentSymbol documentation strings cached. FishDocumentSymbol will lookup *
* base documentation from this cache. Converting this to a FishDocumentSymbol will *
* cause issues with the lsp api because, documentSymbols require a range/location *
* (Maybe check BaseSymbol, I vaguely remember that one of the Symbol's *
* mentions not requiring a Range, having multiple symbols is still *
* not a capability the protocol supports, as per the v.0.7.0) *
* With that in mind, build out a structure inside analyzer, that will be able to use *
* everything that is necessary for a well-informed detail to the client. *
* Current goal likely needs: *
* • parser *
* • FishDocumentSymbol *
* • This DocumentationCache *
* • some kind of flag resolver (the function flags '--description', *
* '--argument-names', '--inherit-variables', come to mind) *
* *
* *
* @TODO: support docs & formatted docs. (non-markdown version will be docs) *
* *
* @TODO: Refactor building documentation string! Potentially remove documentation.ts *
* *
****************************************************************************************/
export interface CachedGlobalItem {
docs?: string;
formattedDocs?: MarkupContent;
uri?: string;
referenceUris: Set<string>;
type: SymbolKind;
resolved: boolean;
}
export function createCachedItem(type: SymbolKind, uri?: string): CachedGlobalItem {
return {
type: type,
resolved: false,
uri: uri,
referenceUris: uri ? new Set([...uri]) : new Set<string>(),
} as CachedGlobalItem;
}
/**
* Currently spoofs docs as FormattedDocs, likely to change in future versions.
*/
async function getNewDocString(name: string, item: CachedGlobalItem) : Promise<string | undefined> {
switch (item.type) {
case SymbolKind.Variable:
return await getVariableDocString(name);
case SymbolKind.Function:
return await getFunctionDocString(name);
case SymbolKind.Class:
return await getBuiltinDocString(name);
default:
return undefined;
}
}
export async function resolveItem(name: string, item: CachedGlobalItem, uri?: string) {
if (uri !== undefined) {
item.referenceUris.add(uri);
}
if (item.resolved) {
return item;
}
if (item.type === SymbolKind.Function) {
item.uri = await getFunctionUri(name);
}
const newDocStr: string | undefined = await getNewDocString(name, item);
item.resolved = true;
if (!newDocStr) {
return item;
}
item.docs = newDocStr;
return item;
}
/**
* just a getter for the absolute path to a function defined
*/
async function getFunctionUri(name: string): Promise<string | undefined> {
const uriString = await execEscapedCommand(`type -ap ${name}`);
const uri = uriString.join('\n').trim();
if (!uri) {
return undefined;
}
return uri;
}
/**
* builds MarkupString for function names, since fish shell standard for private functions
* is naming convention with leading '__', this function ensures that our MarkupStrings
* will be able to display the FunctionName (instead of interpreting it as '__' bold text)
*/
function _escapePathStr(functionTitleLine: string) : string {
const afterComment = functionTitleLine.split(' ').slice(1);
const pathIndex = afterComment.findIndex((str: string) => str.includes('/'));
const path: string = afterComment[pathIndex]?.toString() || '';
return [
'**' + afterComment.slice(0, pathIndex).join(' ').trim() + '**',
`*\`${path}\`*`,
'**' + afterComment.slice(pathIndex + 1).join(' ').trim() + '**',
].join(' ');
}
function _ensureMinLength<T>(arr: T[], minLength: number, fillValue?: T): T[] {
while (arr.length < minLength) {
arr.push(fillValue as T);
}
return arr;
}
/**
* builds FunctionDocumentation string
*/
export async function getFunctionDocString(name: string): Promise<string | undefined> {
const functionDoc = await execCmd(`functions ${name}`);
const title = `___(function)___ - _${name}_`;
if (!functionDoc) return;
return [
title,
'___',
'```fish',
functionDoc.join('\n'),
'```',
].join('\n');
}
export async function getStaticDocString(item: FishCompletionItem): Promise<string> {
let result = [
'```text',
`${item.label} - ${item.documentation}`,
'```',
].join('\n');
item.examples?.forEach((example: CompletionExample) => {
result += [
'___',
'```fish',
`# ${example.title}`,
example.shellText,
'```',
].join('\n');
});
return result;
}
export async function getAbbrDocString(name: string): Promise<string | undefined> {
const items: string[] = await execCmd('abbr --show | string split \' -- \' -m1 -f2');
function getAbbr(items: string[]) : [string, string] {
const start : string = `${name} `;
for (const item of items) {
if (item.startsWith(start)) {
return [start.trimEnd(), item.slice(start.length)];
}
}
return ['', ''];
}
const [title, body] = getAbbr(items);
return [
`Abbreviation: \`${title}\``,
'___',
'```fish',
body.trimEnd(),
'```',
].join('\n') || '';
}
/**
* builds MarkupString for builtin documentation
*/
export async function getBuiltinDocString(name: string): Promise<string | undefined> {
if (!isBuiltin(name)) return undefined;
const cmdDocs: string = await execCommandDocs(name);
if (!cmdDocs) {
return undefined;
}
const splitDocs = cmdDocs.split('\n');
const startIndex = splitDocs.findIndex((line: string) => line.trim() === 'NAME');
return [
`__${name.toUpperCase()}__ - _https://fishshell.com/docs/current/cmds/${name.trim()}.html_`,
'___',
'```man',
splitDocs.slice(startIndex).join('\n'),
'```',
].join('\n');
}
export async function getAliasDocString(label: string, line: string): Promise<string | undefined> {
return [
`Alias: _${label}_`,
'___',
'```fish',
line.split('\t')[1],
'```',
].join('\n');
}
/**
* builds MarkupString for event handler documentation
*/
export async function getEventHandlerDocString(documentation: string): Promise<string> {
const [label, ...commandArr] = documentation.split(/\s/, 2);
const command = commandArr.join(' ');
const doc = await getFunctionDocString(command);
if (!doc) {
return [
`Event: \`${label}\``,
'___',
`Event handler for \`${command}\``,
].join('\n');
}
return [
`Event: \`${label}\``,
'___',
doc,
].join('\n');
}
/**
* builds MarkupString for global variable documentation
*/
export async function getVariableDocString(name: string): Promise<string | undefined> {
const vName = name.startsWith('$') ? name.slice(name.lastIndexOf('$')) : name;
const out = await execCmd(`set --show --long ${vName}`);
const { first, middle, last } = out.reduce((acc, curr, idx, arr) => {
if (idx === 0) {
acc.first = curr;
} else if (idx === arr.length - 1) {
acc.last = curr;
} else {
acc.middle.push(curr);
}
return acc;
}, { first: '', middle: [] as string[], last: '' });
return [
first,
'___',
middle.join('\n'),
'___',
last,
].join('\n');
}
export async function getCommandDocString(name: string): Promise<string | undefined> {
const cmdDocs: string = await execCommandDocs(name);
if (!cmdDocs) {
return undefined;
}
const splitDocs = cmdDocs.split('\n');
const startIndex = splitDocs.findIndex((line: string) => line.trim() === 'NAME');
return [
'```man',
splitDocs.slice(startIndex).join('\n'),
'```',
].join('\n');
}
export function initializeMap(collection: string[], type: SymbolKind, _uri?: string): Map<string, CachedGlobalItem> {
const items: Map<string, CachedGlobalItem> = new Map<string, CachedGlobalItem>();
collection.forEach((item) => {
items.set(item, createCachedItem(type));
});
return items;
}
/**
* Uses internal fish shell commands to store brief output for global variables, functions,
* builtins, and unknown identifiers. This class is meant to be initialized once, on server
* startup. It is then used as fallback documentation provider, if our analysis can't
* resolve any documentation for a given identifier.
*/
export class DocumentationCache {
private _variables: Map<string, CachedGlobalItem> = new Map();
private _functions: Map<string, CachedGlobalItem> = new Map();
private _builtins: Map<string, CachedGlobalItem> = new Map();
private _unknowns: Map<string, CachedGlobalItem> = new Map();
get items(): string[] {
return [
...this._variables.keys(),
...this._functions.keys(),
...this._builtins.keys(),
...this._unknowns.keys(),
];
}
async parse(uri?: string) {
this._unknowns = initializeMap([], SymbolKind.Null, uri);
await Promise.all([
execEscapedCommand('set -n'),
execEscapedCommand('functions -an | string collect'),
execEscapedCommand('builtin -n'),
]).then(([vars, funcs, builtins]) => {
this._variables = initializeMap(vars, SymbolKind.Variable, uri);
this._functions = initializeMap(funcs, SymbolKind.Function, uri);
this._builtins = initializeMap(builtins, SymbolKind.Class, uri);
});
return this;
}
find(name: string, type?: SymbolKind): CachedGlobalItem | undefined {
if (type === SymbolKind.Variable) {
return this._variables.get(name);
}
if (type === SymbolKind.Function) {
return this._functions.get(name);
}
if (type === SymbolKind.Class) {
return this._builtins.get(name);
}
return this._unknowns.get(name);
}
findType(name: string): SymbolKind {
if (this._variables.has(name)) {
return SymbolKind.Variable;
}
if (this._functions.has(name)) {
return SymbolKind.Function;
}
if (this._builtins.has(name)) {
return SymbolKind.Class;
}
return SymbolKind.Null;
}
/**
* @async
* Resolves a symbol's documentation. Store's resolved items in the Cache, otherwise
* returns the already cached item.
*/
async resolve(name: string, uri?:string, type?: SymbolKind) {
const itemType = type || this.findType(name);
let item : CachedGlobalItem | undefined = this.find(name, itemType);
if (!item) {
item = createCachedItem(itemType, uri);
this._unknowns.set(name, item);
}
if (item.resolved && item.docs) {
return item;
}
if (!item.resolved) {
item = await resolveItem(name, item);
}
if (!item.docs) {
this._unknowns.set(name, item);
}
this.setItem(name, item);
return item;
}
/**
* sets an item, mostly called within this class, because CachedGlobalItem will typically
* already be resolved.
*
* @param {string} name - string for the symbol
* @param {CachedGlobalItem} item - the item to set
*/
setItem(name: string, item: CachedGlobalItem) {
switch (item.type) {
case SymbolKind.Variable:
this._variables.set(name, item);
break;
case SymbolKind.Function:
this._functions.set(name, item);
break;
case SymbolKind.Class:
this._builtins.set(name, item);
break;
default:
this._unknowns.set(name, item);
break;
}
}
/**
* getter for a cached item, guarding SymbolKind.Null from retrieved.
*/
getItem(name: string) {
const item = this.find(name);
if (!item || item.type === SymbolKind.Null) {
return undefined;
}
return item;
}
}
/**
* Function to be called when the server is initialized, so that the DocumentationCache
* can be populated.
*/
export async function initializeDocumentationCache() {
const cache = new DocumentationCache();
await cache.parse();
return cache;
}