fish-lsp
Version:
LSP implementation for fish/fish-shell
288 lines (245 loc) • 10.5 kB
text/typescript
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
);
}