UNPKG

fish-lsp

Version:

LSP implementation for fish/fish-shell

452 lines (399 loc) 13 kB
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; } }