fish-lsp
Version:
LSP implementation for fish/fish-shell
280 lines (261 loc) • 12.2 kB
text/typescript
import Parser, { SyntaxNode } from 'web-tree-sitter';
import { initializeParser } from '../../parser';
import { getChildNodes, getLeafs, getLastLeaf, firstAncestorMatch } from '../tree-sitter';
import { isUnmatchedStringCharacter, isPartialForLoop } from '../node-types';
import { FishCompletionItem } from './types';
//import { CompletionItemsArrayTypes, WordsToNotCompleteAfter } from './utils/completion-types';
//import { isBuiltin, BuiltInList, isFunction } from "./utils/builtins";
export class InlineParser {
private readonly COMMAND_TYPES = ['command', 'for_statement', 'case', 'function'];
static async create() {
const parser = await initializeParser();
return new InlineParser(parser);
}
constructor(private parser: Parser) {
this.parser = parser;
}
/**
* returns a context aware node, which represents the current word
* where the completion list is being is requested.
* ________________________________________
* | line | word |
* |----------------|----------------------|
* | `ls -` | `-` |
* |----------------|----------------------|
* | `ls ` | `null` |
* -----------------------------------------
*/
parseWord(line: string): {
wordNode: SyntaxNode | null;
word: string | null;
} {
if (line.endsWith(' ') || line.endsWith('(')) {
return { word: null, wordNode: null };
}
const { rootNode } = this.parser.parse(line);
//let node = rootNode.descendantForPosition({row: 0, column: line.length-1});
//const node = getLastLeaf(rootNode);
const node = getLastLeaf(rootNode);
if (!node || node.text.trim() === '') {
return { word: null, wordNode: null };
}
return {
word: node.text.trim() + line.slice(node.endIndex),
wordNode: node,
};
}
/**
* Returns a command SyntaxNode if one is seen on the current line.
* Will return null if a command is needed at the current cursor.
* Later will be useful to narrow down, which possible types of FishCompletionItems
* should be sent to the client, based on the command.
* ───────────────────────────────────────────────────────────────────────────────
* • Some examples of the expected behavior can be seen below:
* ───────────────────────────────────────────────────────────────────────────────
* '', 'switch', 'if', 'while', ';', 'and', 'or', ⟶ returns 'null'
* ───────────────────────────────────────────────────────────────────────────────
* 'for ...', 'case ...', 'function ...', 'end ', ⟶ returns 'command' node shown
* ───────────────────────────────────────────────────────────────────────────────
*/
parseCommand(line: string) : {
command: string | null;
commandNode: SyntaxNode | null;
} {
const { word, wordNode } = this.parseWord(line.trimEnd());
if (wordPrecedesCommand(word)) {
return { command: null, commandNode: null };
}
const { virtualLine, maxLength } = Line.appendEndSequence(line, wordNode);
const { rootNode } = this.parser.parse(virtualLine);
const node = getLastLeaf(rootNode, maxLength);
if (!node) {
return { command: null, commandNode: null };
}
let commandNode = firstAncestorMatch(node, (n) => this.COMMAND_TYPES.includes(n.type));
commandNode = commandNode?.firstChild || commandNode;
return {
command: commandNode?.text || null,
commandNode: commandNode || null,
};
}
parse(line: string): SyntaxNode {
this.parser.reset();
return this.parser.parse(line).rootNode;
}
getNodeContext(line: string) {
const { word, wordNode } = this.parseWord(line);
const { command, commandNode } = this.parseCommand(line);
const index = this.getIndex(line);
if (word === command) {
return { word, wordNode, command: null, commandNode: null, index: 0 };
}
return {
word,
wordNode,
command,
commandNode,
//last,
//lastNode,
index: index,
};
}
lastItemIsOption(line: string): boolean {
const { command } = this.parseCommand(line);
if (!command) {
return false;
}
const afterCommand = line.lastIndexOf(command) + 1;
const lastItem = line.slice(afterCommand).trim().split(' ').at(-1);
if (lastItem) {
return lastItem.startsWith('-');
}
return false;
}
getLastNode(line: string): SyntaxNode | null {
const { wordNode } = this.parseWord(line.trimEnd());
//if (wordPrecedesCommand(word)) return {command: null, commandNode: null};
const { virtualLine, maxLength: _maxLength } = Line.appendEndSequence(line, wordNode);
const rootNode = this.parse(virtualLine);
const node = getLastLeaf(rootNode);
return node;
}
hasOption(command: SyntaxNode, options: string[]) {
return getChildNodes(command).some(n => options.includes(n.text));
}
getIndex(line: string): number {
const { commandNode } = this.parseCommand(line);
if (!commandNode) {
return 0;
}
if (commandNode) {
const node = firstAncestorMatch(commandNode, (n) => this.COMMAND_TYPES.includes(n.type))!;
const allLeafs = getLeafs(node).filter(leaf => leaf.startPosition.column < line.length);
return Math.max(allLeafs.length - 1, 1);
}
return 0;
}
/**
* here we will specifically populate the completion list with items specific to their
* command & word context.
* For example:
* ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••
* LINE • CONTEXTUAL INFO FROM LINE • ITEMS ADDED
* ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••
* `end ` • {word: null, command: 'end'} • pipes
* ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••
* `printf "` • {word: '"', command: 'printf'} • format specifiers,
* • • strings, variables
* ••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••
*/
//getCompletionArrayTypes(line: string) {
// const {word, command, wordNode, commandNode} = this.getNodeContext(line)
// const result: CompletionItemsArrayTypes[] = []
// switch (command) {
// case 'functions': result.push(CompletionItemsArrayTypes.FUNCTIONS); break
// case 'end': result.push(CompletionItemsArrayTypes.PIPES); break
// case 'printf': result.push(CompletionItemsArrayTypes.FORMAT_SPECIFIERS); break
// case 'set': result.push(CompletionItemsArrayTypes.VARIABLES); break
// case 'function':
// //if (isOption(lastNode) && ['-e', '--on-event'].includes(lastNode.text)) result.push(CompletionItemsArrayTypes.FUNCTIONS);
// //if (isOption(lastNode) && ['-v', '--on-variable'].includes(lastNode.text)) result.push(CompletionItemsArrayTypes.VARIABLES);
// //if (isOption(lastNode) && ['-V', '--inherit-variable'].includes(lastNode.text)) result.push(CompletionItemsArrayTypes.VARIABLES);
// result.push(CompletionItemsArrayTypes.AUTOLOAD_FILENAME);
// break
// case 'return':
// result.push(CompletionItemsArrayTypes.STATUS_NUMBERS, CompletionItemsArrayTypes.VARIABLES);
// break
// default:
// result.push(CompletionItemsArrayTypes.VARIABLES, CompletionItemsArrayTypes.FUNCTIONS, CompletionItemsArrayTypes.PIPES, CompletionItemsArrayTypes.WILDCARDS, CompletionItemsArrayTypes.ESCAPE_CHARS)
// break
// }
// //if (isStringCharacter(lastNode)) result.push(CompletionItemsArrayTypes.VARIABLES, CompletionItemsArrayTypes.ESCAPE_CHARS)
// return result
//}
async createCompletionList(line: string): Promise<FishCompletionItem[]> {
const result: FishCompletionItem[] = [];
const { word: _word, command, wordNode: _wordNode, commandNode: _commandNode } = this.getNodeContext(line);
if (!command) {
//result.push(items.allCo)
}
//const completionArrayTypes = this.getCompletionArrayTypes(line)
//const completionData: FishCompletionData = {
// word, command, wordNode, commandNode, line
//}
//for (const arrayType of completionArrayTypes) {
// //result.push(...await createCompletionItem(arrayType, completionData))
//}
return result;
}
}
/**
* Checks input 'word' against lists of strings that represent fish shell tokens that
* denote the next item could be a command. The tokens seen below, are mostly commands
* that should be treated specially (to help determine the current completion context)
*
* @param {string | null} word - the current word which might not exists
* @returns {boolean} - True if the word is a token that precedes a command.
* False if the word is not something that precedes a command, (i.e. a flag)
*/
export function wordPrecedesCommand(word: string | null) {
if (!word) {
return false;
}
const chars = ['(', ';'];
const combiners = ['and', 'or', 'not', '!', '&&', '||'];
const conditional = ['if', 'while', 'else if', 'switch'];
const pipes = ['|', '&', '1>|', '2>|', '&|'];
return (
chars.includes(word) ||
combiners.includes(word) ||
conditional.includes(word) ||
pipes.includes(word)
);
}
/**
* Helper functions to edit lines in the CompletionList methods.
*/
export namespace Line {
export function isEmpty(line: string): boolean {
return line.trim().length === 0;
}
export function isComment(line: string): boolean {
return line.trim().startsWith('#');
}
export function hasMultipleLastSpaces(line: string): boolean {
return line.trim().endsWith(' ');
}
export function removeAllButLastSpace(line: string): string {
if (line.endsWith(' ')) {
return line;
}
return line.split(' ')[-1] || line;
}
export function appendEndSequence(
oldLine: string,
wordNode: SyntaxNode | null,
endSequence: string = ';end;',
) {
let virtualEOLChars = endSequence;
let maxLength = oldLine.length;
if (wordNode && isUnmatchedStringCharacter(wordNode)) {
virtualEOLChars = wordNode.text + endSequence;
maxLength -= 1;
}
if (wordNode && isPartialForLoop(wordNode)) {
const completeForLoop = ['for', 'i', 'in', '_'];
const errorNode = firstAncestorMatch(wordNode, (n) =>
n.hasError,
)!;
const leafs = getLeafs(errorNode);
virtualEOLChars =
' ' +
completeForLoop.slice(leafs.length).join(' ') +
endSequence;
}
return {
virtualLine: [oldLine, virtualEOLChars].join(''),
virtualEOLChars: virtualEOLChars,
maxLength: maxLength,
};
}
}