UNPKG

fish-lsp

Version:

LSP implementation for fish/fish-shell

288 lines (245 loc) 10.5 kB
import os from 'os'; import { ChangeAnnotation, CodeAction, CodeActionKind, CreateFile, Range, TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, WorkspaceEdit } from 'vscode-languageserver'; import { LspDocument } from '../document'; import { SyntaxNode } from 'web-tree-sitter'; import { findEnclosingScope, getChildNodes, getRange } from '../utils/tree-sitter'; import { findParentCommand, isCommand, isCommandWithName, isFunctionDefinitionName, isIfStatement } from '../utils/node-types'; import { SupportedCodeActionKinds } from './action-kinds'; import { convertIfToCombinersString } from './combiner'; import path from 'path'; import { formatTextWithIndents, pathToUri } from '../utils/translation'; import { logger } from '../logger'; import { buildCompleteString, findFlagsToComplete } from './argparse-completions'; /** * Notice how this file compared to the other code-actions, uses a node as it's parameter * This is because the reafactors are not based on diagnostics. However, if we need to use * a diagnostic for some reason, we can always pass its `Document.data.node` property. * * This section is very much still a WIP, so there are definitely some improvements * to be made. */ export function createRefactorAction( title: string, kind: CodeActionKind, edits: { [uri: string]: TextEdit[]; }, preferredAction = false, ): CodeAction { return { title, kind, edit: { changes: edits }, isPreferred: preferredAction, }; } export function extractFunctionWithArgparseToCompletionsFile( document: LspDocument, range: Range, node: SyntaxNode, ) { logger.log('extractFunctionWithArgparseToCompletionsFile', document, range, { node: { text: node.text, type: node.type } }); let selectedNode = node; if (isFunctionDefinitionName(node)) { selectedNode = node.parent!; } if (isCommandWithName(selectedNode, 'argparse') || selectedNode.text.startsWith('argparse')) { selectedNode = findEnclosingScope(selectedNode); } if (selectedNode.type !== 'function_definition') return; const argparseNode = getChildNodes(selectedNode).find(n => isCommandWithName(n, 'argparse')); const hasArgparse = !!argparseNode; if (!hasArgparse) return; const functionName = getChildNodes(selectedNode).find(n => isFunctionDefinitionName(n))!.text; const autoloadType = document.getAutoloadType(); /** cancel if we're not in an autoloaded file */ if (functionName !== document.getAutoLoadName() || !['functions', 'config.fish'].includes(autoloadType)) return; const completionPath = path.join(os.homedir(), '.config', 'fish', 'completions', `${functionName}.fish`); const completionUri = pathToUri(completionPath); const completionFlags = findFlagsToComplete(argparseNode); const completionText = buildCompleteString(functionName, completionFlags); const changeAnnotation: ChangeAnnotation = { label: `Create completions for '${functionName}' in file: ${completionPath}`, description: `Create completions for '${functionName}' to file: ${completionPath}`, }; const createFileAction = CreateFile.create(completionUri, { ignoreIfExists: true, overwrite: false }); // Get the selected text const selectedText = `\n# auto generated by fish-lsp\n${completionText}\n`; const createFileEdit = TextDocumentEdit.create( VersionedTextDocumentIdentifier.create(completionUri, 0), [TextEdit.insert({ line: 0, character: 0 }, selectedText)]); const workspaceEdit: WorkspaceEdit = { documentChanges: [ createFileAction, createFileEdit, ], changeAnnotations: { [changeAnnotation.label]: changeAnnotation }, }; return { title: `Create completions for '${functionName}' in file: ${completionPath}`, kind: SupportedCodeActionKinds.RefactorExtract, edit: workspaceEdit, } as CodeAction; } export function extractFunctionToFile( document: LspDocument, range: Range, node: SyntaxNode, ) { logger.log('extractFunctionToFile', document, range, { node: { text: node.text, type: node.type } }); let selectedNode = node; if (isFunctionDefinitionName(node)) { selectedNode = node.parent!; } if (selectedNode.type !== 'function_definition') return; const functionName = getChildNodes(selectedNode).find(n => isFunctionDefinitionName(n))!.text; // cancel if we're already in the file if (functionName === document.getAutoLoadName()) return; const functionPath = path.join(os.homedir(), '.config', 'fish', 'functions', `${functionName}.fish`); const functionUri = pathToUri(functionPath); const changeAnnotation: ChangeAnnotation = { label: `Extract function '${functionName}' to file: ${functionPath}`, description: `Extract function '${functionName}' to file: ${functionPath}`, }; const createFileAction = CreateFile.create(functionUri, { ignoreIfExists: false, overwrite: true }); // Get the selected text const selectedText = document.getText(getRange(selectedNode)); const createFileEdit = TextDocumentEdit.create( VersionedTextDocumentIdentifier.create(functionUri, 0), [TextEdit.insert({ line: 0, character: 0 }, selectedText)]); const removeOldFunction = TextDocumentEdit.create( VersionedTextDocumentIdentifier.create(document.uri, document.version), [TextEdit.del(getRange(selectedNode))]); const workspaceEdit: WorkspaceEdit = { documentChanges: [ createFileAction, createFileEdit, removeOldFunction, ], changeAnnotations: { [changeAnnotation.label]: changeAnnotation }, }; return { title: `Extract function '${functionName}' to file: ${functionPath}`, kind: SupportedCodeActionKinds.RefactorExtract, edit: workspaceEdit, } as CodeAction; } export function extractToFunction( document: LspDocument, range: Range, ): CodeAction | undefined { logger.log('extractToFunction', document, range); // Generate a unique function name const functionName = `extracted_function_${Math.floor(Math.random() * 1000)}`; // Get the selected text const selectedText = document.getText(range); // make sure we're not extracting nothing if (selectedText.trim() === '' && document.getLine(range.start.line).trim() !== '') return; const indent = document.getIndentAtLine(range.start.line); // Create the new function const functionText = [ `\n${indent}function ${functionName}`, ...selectedText.split('\n').map(line => `${indent} ${line}`), // Indent the function body `${indent}end\n`, ].join('\n'); // Insert the new function before the current scope const insertEdit = TextEdit.insert( { line: range.start.line, character: 0 }, `\n${functionText}\n`, ); // Replace the selected text with a call to the new function const replaceEdit = TextEdit.replace(range, `${functionName}`); return createRefactorAction( `Extract to local function '${functionName}'`, SupportedCodeActionKinds.RefactorExtract, { [document.uri]: [replaceEdit, insertEdit], }, ); } export function extractCommandToFunction( document: LspDocument, selectedNode: SyntaxNode, ) { logger.log('extractCommandToFunction', document, { selectedNode: { text: selectedNode.text, type: selectedNode.type } }); // Generate a unique function name const functionName = `extracted_function_${Math.floor(Math.random() * 1000)}`; let cmd = selectedNode; if (selectedNode.type !== 'command') { cmd = findParentCommand(selectedNode) || selectedNode; } if (!cmd || !isCommand(cmd)) return; // Get the selected text const selectedText = document.getText(getRange(cmd)); // Create the new function const functionText = [ `\nfunction ${functionName}`, ...selectedText.split('\n').map(line => ` ${line}`), // Indent the function body 'end\n', ].join('\n'); // Replace the selected text with a call to the new function const replaceEdit = TextEdit.replace(getRange(cmd), `${functionName}`); // Insert the new function before the current scope // const insertPosition = getRange(selectedNode).start; const insertEdit = TextEdit.insert( { line: document.getLines(), character: 0 }, `\n${functionText}\n`, ); return createRefactorAction( `Extract command to local function '${functionName}'`, SupportedCodeActionKinds.RefactorExtract, { [document.uri]: [replaceEdit, insertEdit], }, ); } export function extractToVariable( document: LspDocument, range: Range, selectedNode: SyntaxNode, ): CodeAction | undefined { logger.log('extractToVariable', document, { selectedNode: { text: selectedNode.text, type: selectedNode.type } }); // Only allow extracting commands or expressions if (!isCommand(selectedNode)) return undefined; const selectedText = document.getText(range); const varName = `extracted_var_${Math.floor(Math.random() * 1000)}`; // Create variable declaration const declaration = `set -l ${varName} (${selectedText})\n`; // Replace original text with variable const replaceEdit = TextEdit.replace(range, declaration); return createRefactorAction( `Extract selected '${selectedNode.firstNamedChild!.text}' command to local variable '${varName}'`, SupportedCodeActionKinds.RefactorExtract, { [document.uri]: [replaceEdit], }, ); } export function convertIfToCombiners( document: LspDocument, selectedNode: SyntaxNode, isSelected: boolean = true, ): CodeAction | undefined { logger.log('convertIfToCombiners', document, { selectedNode: { text: selectedNode.text, type: selectedNode.type } }); let node = selectedNode; if (node.type === 'if' && !isIfStatement(node)) { node = node.parent!; } if (!isIfStatement(node)) return undefined; const combinerString = convertIfToCombinersString(node); // format the input with proper indentation, trimStart() because the range will include the leading whitespace const formattedString = formatTextWithIndents( document, selectedNode.startPosition.row, combinerString, ).trimStart(); const message = isSelected ? `Convert selected if statement to conditionally executed statement (line: ${node.startPosition.row + 1})` : `Convert if statement to conditionally executed statement (line: ${node.startPosition.row + 1})`; return createRefactorAction( message, SupportedCodeActionKinds.RefactorRewrite, { [document.uri]: [TextEdit.replace(getRange(node), formattedString)], }, true, // Mark as preferred action ); }