UNPKG

fish-lsp

Version:

LSP implementation for fish/fish-shell

528 lines (527 loc) 21.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createFixAllAction = createFixAllAction; exports.handleMissingEndFix = handleMissingEndFix; exports.handleExtraEndFix = handleExtraEndFix; exports.handleSingleQuoteVarFix = handleSingleQuoteVarFix; exports.handleTestCommandVariableExpansionWithoutString = handleTestCommandVariableExpansionWithoutString; exports.getQuickFixes = getQuickFixes; const vscode_languageserver_1 = require("vscode-languageserver"); const error_codes_1 = require("../diagnostics/error-codes"); const tree_sitter_1 = require("../utils/tree-sitter"); const node_types_1 = require("../diagnostics/node-types"); const action_kinds_1 = require("./action-kinds"); const logger_1 = require("../logger"); const analyze_1 = require("../analyze"); const tree_sitter_2 = require("../utils/tree-sitter"); const translation_1 = require("../utils/translation"); const node_types_2 = require("../utils/node-types"); function createQuickFix(title, diagnostic, edits) { return { title, kind: action_kinds_1.SupportedCodeActionKinds.QuickFix.toString(), isPreferred: true, diagnostics: [diagnostic], edit: { changes: edits }, }; } function createFixAllAction(document, actions) { if (actions.length === 0) return undefined; const fixableActions = actions.filter(action => { return action.isPreferred && action.kind === action_kinds_1.SupportedCodeActionKinds.QuickFix; }); for (const fixable of fixableActions) { logger_1.logger.info('createFixAllAction', { fixable: fixable.title }); } if (fixableActions.length === 0) return undefined; const resultEdits = {}; const diagnostics = []; for (const action of fixableActions) { if (!action.edit || !action.edit.changes) continue; const changes = action.edit.changes; for (const uri of Object.keys(changes)) { const edits = changes[uri]; if (!edits || edits.length === 0) continue; if (!resultEdits[uri]) { resultEdits[uri] = []; } const oldEdits = resultEdits[uri]; if (edits && edits?.length > 0) { if (!oldEdits.some(e => edits.find(newEdit => (0, tree_sitter_1.equalRanges)(e.range, newEdit.range)))) { oldEdits.push(...edits); resultEdits[uri] = oldEdits; diagnostics.push(...action.diagnostics || []); } } } } const allEdits = []; for (const uri in resultEdits) { const edits = resultEdits[uri]; if (!edits || edits.length === 0) continue; allEdits.push(...edits); } return { title: `Fix all auto-fixable quickfixes (total fixes: ${allEdits.length}) (codes: ${diagnostics.map(d => d.code).join(', ')})`, kind: action_kinds_1.SupportedCodeActionKinds.QuickFixAll, diagnostics, edit: { changes: resultEdits, }, data: { isQuickFix: true, documentUri: document.uri, totalEdits: allEdits.length, uris: Array.from(new Set(Object.keys(resultEdits))), }, }; } function getErrorNodeToken(node) { const { text } = node; const startTokens = Object.keys(node_types_1.ErrorNodeTypes); for (const token of startTokens) { if (text.startsWith(token)) { return node_types_1.ErrorNodeTypes[token]; } } return undefined; } function handleMissingEndFix(document, diagnostic, analyzer) { const root = analyzer.getTree(document.uri).rootNode; const errNode = root.descendantForPosition({ row: diagnostic.range.start.line, column: diagnostic.range.start.character }); const rawErrorNodeToken = getErrorNodeToken(errNode); if (!rawErrorNodeToken) return undefined; const endTokenWithNewline = rawErrorNodeToken === 'end' ? '\nend' : rawErrorNodeToken; return { title: `Add missing "${rawErrorNodeToken}"`, diagnostics: [diagnostic], kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, edit: { changes: { [document.uri]: [ vscode_languageserver_1.TextEdit.insert({ line: errNode.endPosition.row, character: errNode.endPosition.column, }, endTokenWithNewline), ], }, }, }; } function handleExtraEndFix(document, diagnostic) { const edit = vscode_languageserver_1.TextEdit.del(diagnostic.range); return createQuickFix('Remove extra "end"', diagnostic, { [document.uri]: [edit], }); } function handleMissingQuietError(document, diagnostic) { const edit = vscode_languageserver_1.TextEdit.insert(diagnostic.range.end, ' -q '); return { title: 'Add silence (-q) flag', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, command: { command: 'editor.action.formatDocument', title: 'Format Document', }, isPreferred: true, }; } function handleZeroIndexedArray(document, diagnostic) { return { title: 'Convert zero-indexed array to one-indexed array', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [ vscode_languageserver_1.TextEdit.del(diagnostic.range), vscode_languageserver_1.TextEdit.insert(diagnostic.range.start, '1'), ], }, }, isPreferred: true, }; } function handleDotSourceCommand(document, diagnostic) { const edit = vscode_languageserver_1.TextEdit.replace(diagnostic.range, 'source'); return { title: 'Convert dot source command to source', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, isPreferred: true, }; } function handleUniversalVariable(document, diagnostic) { const text = document.getText(diagnostic.range); let newText = text.replace(/U/g, 'g'); newText = newText.replace(/--universal/g, '--global'); const edit = vscode_languageserver_1.TextEdit.replace({ start: diagnostic.range.start, end: diagnostic.range.end, }, newText); return { title: 'Convert universal scope to global scope', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, isPreferred: true, }; } function handleSingleQuoteVarFix(document, diagnostic) { const text = document.getText(diagnostic.range); const newText = text.replace(/'/g, '"').replace(/\$/g, '\\$'); const edit = vscode_languageserver_1.TextEdit.replace(diagnostic.range, newText); return { title: 'Convert to double quotes', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, isPreferred: true, }; } function handleTestCommandVariableExpansionWithoutString(document, diagnostic) { return createQuickFix('Surround test string comparison with double quotes', diagnostic, { [document.uri]: [ vscode_languageserver_1.TextEdit.insert(diagnostic.range.start, '"'), vscode_languageserver_1.TextEdit.insert(diagnostic.range.end, '"'), ], }); } function handleMissingDefinition(diagnostic, node, document) { const functionName = (0, translation_1.pathToRelativeFunctionName)(document.uri); const edit = { 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: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, isPreferred: true, }; } function handleFilenameMismatch(diagnostic, node, document) { 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 = (0, translation_1.uriToPath)(newUri); const annotation = vscode_languageserver_1.ChangeAnnotation.create(`rename ${oldFilename} to ${newUri.split('/').pop()}`, true, `Rename '${oldFilePath}' to '${newFilePath}'`); const workspaceEdit = { documentChanges: [ vscode_languageserver_1.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: action_kinds_1.SupportedCodeActionKinds.RefactorRewrite, diagnostics: [diagnostic], edit: workspaceEdit, }; } function handleCompletionFilenameMismatch(diagnostic, node, document) { const functionName = node.text; const newUri = document.uri.replace(/[^/]+\.fish$/, `${functionName}.fish`); if (document.getAutoloadType() !== 'completions') { return; } const oldName = document.getAutoLoadName(); const oldFilePath = document.getFilePath(); const oldFilename = document.getFilename(); const newFilePath = (0, translation_1.uriToPath)(newUri); const annotation = vscode_languageserver_1.ChangeAnnotation.create(`rename ${oldFilename} to ${newUri.split('/').pop()}`, true, `Rename '${oldFilePath}' to '${newFilePath}'`); const workspaceEdit = { documentChanges: [ vscode_languageserver_1.RenameFile.create(document.uri, newUri, { ignoreIfExists: false, overwrite: true }), ], changeAnnotations: { [annotation.label]: annotation, }, }; return { title: `RENAME: '${oldFilename}' to '${functionName}.fish' (File missing completion '${oldName}')`, kind: action_kinds_1.SupportedCodeActionKinds.RefactorRewrite, diagnostics: [diagnostic], edit: workspaceEdit, }; } function handleReservedKeyword(diagnostic, node, document) { const replaceText = `__${node.text}`; const changeAnnotation = vscode_languageserver_1.ChangeAnnotation.create(`rename ${node.text} to ${replaceText}`, true, `Rename reserved keyword function definition '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`); const workspaceEdit = { changes: { [document.uri]: [ vscode_languageserver_1.TextEdit.replace((0, tree_sitter_2.getRange)(node), replaceText), ], }, changeAnnotations: { [changeAnnotation.label]: changeAnnotation, }, }; return { title: `Rename reserved keyword '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`, kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], isPreferred: true, edit: workspaceEdit, }; } const getNodeType = (node) => { if ((0, node_types_2.isFunctionDefinitionName)(node)) { return 'function'; } if ((0, node_types_2.isArgparseVariableDefinitionName)(node)) { return 'argparse'; } if ((0, node_types_2.isAliasDefinitionName)(node)) { return 'alias'; } if ((0, node_types_2.isVariableDefinitionName)(node)) { return 'variable'; } return 'unknown'; }; function handleUnusedSymbol(diagnostic, node, document) { const nodeType = getNodeType(node); if (nodeType === 'unknown') return undefined; let scopeNode = node; while (scopeNode && !(0, node_types_2.isFunctionDefinition)(scopeNode)) { scopeNode = scopeNode.parent; } if (nodeType === 'function') { const changeAnnotation = vscode_languageserver_1.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 = { changes: { [document.uri]: [ vscode_languageserver_1.TextEdit.del((0, tree_sitter_2.getRange)(scopeNode)), ], }, changeAnnotations: { [changeAnnotation.label]: changeAnnotation, }, }; return { title: `Remove unused function ${node.text} (line: ${node.startPosition.row + 1})`, kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: workspaceEdit, }; } if (nodeType === 'argparse') { const parentCommand = (0, node_types_2.findParentCommand)(node); if (!parentCommand) return undefined; const changeAnnotation = vscode_languageserver_1.ChangeAnnotation.create(`Check if argparse variable ${node.text} is set`, true, `Check if argparse variable '${node.text}' is set, in file '${document.getFilePath()}' (line: ${node.startPosition.row + 1})`); const symbol = analyze_1.analyzer.getDefinition(document, diagnostic.range.end); if (!symbol) return undefined; const indent = document.getIndentAtLine(parentCommand.endPosition.row); const name = symbol.aliasedNames.length > 0 ? symbol.aliasedNames.reduce((longest, current) => current.length > longest.length ? current : longest, '') : symbol.name; const insertText = [ '\n', `if set -ql ${name}`, ' ', 'end', ].map(line => `${indent}${line}`).join('\n'); let parentNode = symbol.node; if (parentNode && parentNode.nextNamedSibling && (0, node_types_2.isConditionalCommand)(parentNode.nextNamedSibling)) { while (parentNode && parentNode.nextNamedSibling && (0, node_types_2.isConditionalCommand)(parentNode.nextNamedSibling)) { parentNode = parentNode.nextNamedSibling; } } const workspaceEdit = { changes: { [document.uri]: [ vscode_languageserver_1.TextEdit.insert((0, tree_sitter_2.getRange)(parentNode).end, insertText), ], }, changeAnnotations: { [changeAnnotation.label]: changeAnnotation, }, }; return { title: `Use \`argparse ${node.text}\` variable '${name}' if it's set in '${symbol.parent?.name || (0, translation_1.uriToReadablePath)(document.uri)}'`, kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: workspaceEdit, isPreferred: true, }; } return undefined; } function handleAddEndStdinToArgparse(diagnostic, document) { const edit = vscode_languageserver_1.TextEdit.insert(diagnostic.range.end, ' -- $argv'); return { title: 'Add end stdin ` -- $argv` to argparse', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: { changes: { [document.uri]: [edit], }, }, isPreferred: true, }; } function handleConvertDeprecatedFishLsp(diagnostic, node, document) { logger_1.logger.log({ name: 'handleConvertDeprecatedFishLsp', diagnostic: diagnostic.range, node: node.text }); const replaceText = node.text === 'fish_lsp_logfile' ? 'fish_lsp_log_file' : node.text; const edit = vscode_languageserver_1.TextEdit.replace(diagnostic.range, replaceText); const workspaceEdit = { changes: { [document.uri]: [edit], }, }; return { title: 'Convert deprecated environment variable name', kind: action_kinds_1.SupportedCodeActionKinds.QuickFix, diagnostics: [diagnostic], edit: workspaceEdit, isPreferred: true, }; } async function getQuickFixes(document, diagnostic, analyzer) { if (!diagnostic.code) return []; logger_1.logger.log({ code: diagnostic.code, message: diagnostic.message, severity: diagnostic.severity, node: diagnostic.data.node.text, range: diagnostic.range, }); let action; const actions = []; const root = analyzer.getRootNode(document.uri); let node = root; if (root) { node = (0, tree_sitter_1.getChildNodes)(root).find(n => n.startPosition.row === diagnostic.range.start.line && n.startPosition.column === diagnostic.range.start.character); } logger_1.logger.info('getQuickFixes', { code: diagnostic.code, message: diagnostic.message, node: node?.text }); switch (diagnostic.code) { case error_codes_1.ErrorCodes.missingEnd: action = handleMissingEndFix(document, diagnostic, analyzer); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.extraEnd: action = handleExtraEndFix(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.missingQuietOption: action = handleMissingQuietError(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.usedUnviersalDefinition: action = handleUniversalVariable(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.dotSourceCommand: action = handleDotSourceCommand(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.zeroIndexedArray: action = handleZeroIndexedArray(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.singleQuoteVariableExpansion: action = handleSingleQuoteVarFix(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.testCommandMissingStringCharacters: action = handleTestCommandVariableExpansionWithoutString(document, diagnostic); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.autoloadedFunctionMissingDefinition: if (!node) return []; return [handleMissingDefinition(diagnostic, node, document)]; case error_codes_1.ErrorCodes.autoloadedFunctionFilenameMismatch: if (!node) return []; action = handleFilenameMismatch(diagnostic, node, document); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.functionNameUsingReservedKeyword: if (!node) return []; return [handleReservedKeyword(diagnostic, node, document)]; case error_codes_1.ErrorCodes.unusedLocalDefinition: if (!node) return []; action = handleUnusedSymbol(diagnostic, node, document); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.autoloadedCompletionMissingCommandName: if (!node) return []; action = handleCompletionFilenameMismatch(diagnostic, node, document); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.argparseMissingEndStdin: action = handleAddEndStdinToArgparse(diagnostic, document); if (action) actions.push(action); return actions; case error_codes_1.ErrorCodes.fishLspDeprecatedEnvName: if (!node) return []; return [handleConvertDeprecatedFishLsp(diagnostic, node, document)]; default: return actions; } }