fish-lsp
Version:
LSP implementation for fish/fish-shell
452 lines (399 loc) • 13 kB
text/typescript
import { ChangeAnnotation, CodeAction, Diagnostic, RenameFile, TextEdit, WorkspaceEdit } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { ErrorCodes } from '../diagnostics/errorCodes';
import { getChildNodes } from '../utils/tree-sitter';
import { SyntaxNode } from 'web-tree-sitter';
import { ErrorNodeTypes } from '../diagnostics/node-types';
import { SupportedCodeActionKinds } from './action-kinds';
import { logger } from '../logger';
import { Analyzer } from '../analyze';
// import { createAliasInlineAction, createAliasSaveActionNewFile } from './alias-wrapper';
import { getRange } from '../utils/tree-sitter';
import { pathToRelativeFunctionName, uriToPath } from '../utils/translation';
import { isFunctionDefinition } from '../utils/node-types';
/**
* These quick-fixes are separated from the other diagnostic quick-fixes because
* future work will involve adding significantly more complex
* solutions here (atleast I hope. I definitely think fish uniquely has a lot
* of potential for how advancded quickfixes could become eventually).
*
* The quick-fixes located at disable-actions.ts are mainly for simple disabling
* of diagnostic messages.
*/
// Helper to create a QuickFix code action
function createQuickFix(
title: string,
diagnostic: Diagnostic,
edits: { [uri: string]: TextEdit[]; },
): CodeAction {
return {
title,
kind: SupportedCodeActionKinds.QuickFix.toString(),
isPreferred: true,
diagnostics: [diagnostic],
edit: { changes: edits },
};
}
/**
* utility function to get the error node token
*/
function getErrorNodeToken(node: SyntaxNode): string | undefined {
const { text } = node;
const startTokens = Object.keys(ErrorNodeTypes);
for (const token of startTokens) {
if (text.startsWith(token)) {
return ErrorNodeTypes[token as keyof typeof ErrorNodeTypes];
}
}
return undefined;
}
export function handleMissingEndFix(
document: LspDocument,
diagnostic: Diagnostic,
analyzer: Analyzer,
): CodeAction | undefined {
const root = analyzer.getTree(document)!.rootNode;
const errNode = root.descendantForPosition({ row: diagnostic.range.start.line, column: diagnostic.range.start.character })!;
// const err = root!.childForFieldName('ERROR')!;
// const toSearch = getChildNodes(err).find(node => node.isError)!;
const rawErrorNodeToken = getErrorNodeToken(errNode);
if (!rawErrorNodeToken) return undefined;
const endTokenWithNewline = rawErrorNodeToken === 'end' ? '\nend' : rawErrorNodeToken;
return {
title: `Add missing "${rawErrorNodeToken}"`,
diagnostics: [diagnostic],
kind: SupportedCodeActionKinds.QuickFix,
edit: {
changes: {
[document.uri]: [
TextEdit.insert({
line: errNode!.endPosition.row,
character: errNode!.endPosition.column,
}, endTokenWithNewline),
],
},
},
};
}
export function handleExtraEndFix(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
// Simply delete the extra end
const edit = TextEdit.del(diagnostic.range);
return createQuickFix(
'Remove extra "end"',
diagnostic,
{
[document.uri]: [edit],
},
);
}
// Handle missing quiet option error
function handleMissingQuietError(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
// Add -q flag
const edit = TextEdit.insert(diagnostic.range.end, ' -q ');
return {
title: 'Add quiet (-q) flag',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
command: {
command: 'editor.action.formatDocument',
title: 'Format Document',
},
isPreferred: true,
};
}
function handleZeroIndexedArray(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
return {
title: 'Convert zero-indexed array to one-indexed array',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [
TextEdit.del(diagnostic.range),
TextEdit.insert(diagnostic.range.start, '1'),
],
},
},
isPreferred: true,
};
}
// fix cases like: -xU
function handleUniversalVariable(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
const text = document.getText(diagnostic.range);
let newText = text.replace(/U/g, 'g');
newText = newText.replace(/--universal/g, '--global');
const edit = TextEdit.replace(
{
start: diagnostic.range.start,
end: diagnostic.range.end,
},
newText,
);
return {
title: 'Convert universal scope to global scope',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
export function handleSingleQuoteVarFix(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
// Replace single quotes with double quotes
const text = document.getText(diagnostic.range);
const newText = text.replace(/'/g, '"').replace(/\$/g, '\\$');
const edit = TextEdit.replace(
diagnostic.range,
newText,
);
return {
title: 'Convert to double quotes',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
export function handleTestCommandVariableExpansionWithoutString(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
return createQuickFix(
'Surround test string comparison with double quotes',
diagnostic,
{
[document.uri]: [
TextEdit.insert(diagnostic.range.start, '"'),
TextEdit.insert(diagnostic.range.end, '"'),
],
},
);
}
function handleMissingDefinition(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
// Create function definition with filename
const functionName = pathToRelativeFunctionName(document.uri);
const edit: TextEdit = {
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: `function ${functionName}\n # TODO: Implement function\nend\n`,
};
return {
title: `Create function '${functionName}'`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
function handleFilenameMismatch(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction | undefined {
const functionName = node.text;
const newUri = document.uri.replace(/[^/]+\.fish$/, `${functionName}.fish`);
if (document.getAutoloadType() !== 'functions') {
return;
}
const oldName = document.getAutoLoadName();
const oldFilePath = document.getFilePath();
const oldFilename = document.getFilename();
const newFilePath = uriToPath(newUri);
const annotation = ChangeAnnotation.create(
`rename ${oldFilename} to ${newUri.split('/').pop()}`,
true,
`Rename '${oldFilePath}' to '${newFilePath}'`,
);
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
RenameFile.create(document.uri, newUri, { ignoreIfExists: false, overwrite: true }),
],
changeAnnotations: {
[annotation.label]: annotation,
},
};
return {
title: `RENAME: '${oldFilename}' to '${functionName}.fish' (File missing function '${oldName}')`,
kind: SupportedCodeActionKinds.RefactorRewrite,
diagnostics: [diagnostic],
edit: workspaceEdit,
};
}
function handleReservedKeyword(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
const replaceText = `__${node.text}`;
const changeAnnotation = ChangeAnnotation.create(
`rename ${node.text} to ${replaceText}`,
true,
`Rename reserved keyword function definition '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`,
);
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.replace(getRange(node), replaceText),
],
},
changeAnnotations: {
[changeAnnotation.label]: changeAnnotation,
},
};
return {
title: `Rename reserved keyword '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
isPreferred: true,
edit: workspaceEdit,
};
}
function handleUnusedFunction(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
// Find the entire function definition to remove
let scopeNode = node;
while (scopeNode && !isFunctionDefinition(scopeNode)) {
scopeNode = scopeNode.parent!;
}
const changeAnnotation = ChangeAnnotation.create(
`Removed unused function ${node.text}`,
true,
`Removed unused function '${node.text}', in file '${document.getFilePath()}' (line: ${node.startPosition.row + 1} - ${node.endPosition.row + 1})`,
);
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.del(getRange(scopeNode)),
],
},
changeAnnotations: {
[changeAnnotation.label]: changeAnnotation,
},
};
return {
title: `Remove unused function ${node.text} (line: ${node.startPosition.row + 1})`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: workspaceEdit,
};
}
function handleAddEndStdinToArgparse(diagnostic: Diagnostic, document: LspDocument): CodeAction {
const edit = TextEdit.insert(diagnostic.range.end, ' -- $argv');
return {
title: 'Add end stdin ` -- $argv` to argparse',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
export async function getQuickFixes(
document: LspDocument,
diagnostic: Diagnostic,
analyzer: Analyzer,
): Promise<CodeAction[]> {
if (!diagnostic.code) return [];
logger.log({
code: diagnostic.code,
message: diagnostic.message,
severity: diagnostic.severity,
node: diagnostic.data.node.text,
range: diagnostic.range,
});
let action: CodeAction | undefined;
const actions: CodeAction[] = [];
const root = analyzer.getRootNode(document);
let node = root;
if (root) {
node = getChildNodes(root).find(n =>
n.startPosition.row === diagnostic.range.start.line &&
n.startPosition.column === diagnostic.range.start.character);
}
switch (diagnostic.code) {
case ErrorCodes.missingEnd:
action = handleMissingEndFix(document, diagnostic, analyzer);
if (action) actions.push(action);
return actions;
case ErrorCodes.extraEnd:
action = handleExtraEndFix(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.missingQuietOption:
action = handleMissingQuietError(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.usedUnviersalDefinition:
action = handleUniversalVariable(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.zeroIndexedArray:
action = handleZeroIndexedArray(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.singleQuoteVariableExpansion:
action = handleSingleQuoteVarFix(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.testCommandMissingStringCharacters:
action = handleTestCommandVariableExpansionWithoutString(document, diagnostic);
if (action) actions.push(action);
return actions;
// case ErrorCodes.usedAlias:
// if (!node) return [];
// actions.push(
// ...await Promise.all([
// createAliasInlineAction(node, document),
// createAliasSaveActionNewFile(node, document),
// ]),
// );
// return actions;
case ErrorCodes.autoloadedFunctionMissingDefinition:
if (!node) return [];
return [handleMissingDefinition(diagnostic, node, document)];
case ErrorCodes.autoloadedFunctionFilenameMismatch:
if (!node) return [];
action = handleFilenameMismatch(diagnostic, node, document);
if (action) actions.push(action);
return actions;
case ErrorCodes.functionNameUsingReservedKeyword:
if (!node) return [];
return [handleReservedKeyword(diagnostic, node, document)];
case ErrorCodes.unusedLocalFunction:
if (!node) return [];
return [handleUnusedFunction(diagnostic, node, document)];
case ErrorCodes.argparseMissingEndStdin:
action = handleAddEndStdinToArgparse(diagnostic, document);
if (action) actions.push(action);
return actions;
default:
return actions;
}
}