fish-lsp
Version:
LSP implementation for fish/fish-shell
383 lines (338 loc) • 14.7 kB
text/typescript
import os from 'os';
import { z } from 'zod';
import { logToStdout } from './logger';
import fishLspEnvVariables from './snippets/fishlspEnvVariables.json';
import { InitializeResult, TextDocumentSyncKind } from 'vscode-languageserver';
import { AllSupportedActions } from './code-actions/action-kinds';
import { LspCommands } from './command';
/********************************************
********** Handlers/Providers ***********
*******************************************/
export const ConfigHandlerSchema = z.object({
complete: z.boolean().default(true),
hover: z.boolean().default(true),
rename: z.boolean().default(true),
reference: z.boolean().default(true),
logger: z.boolean().default(true),
formatting: z.boolean().default(true),
codeAction: z.boolean().default(true),
codeLens: z.boolean().default(true),
folding: z.boolean().default(true),
signature: z.boolean().default(true),
executeCommand: z.boolean().default(true),
inlayHint: z.boolean().default(true),
highlight: z.boolean().default(true),
diagnostic: z.boolean().default(true),
popups: z.boolean().default(true),
});
/**
* The configHandlers object stores the enabled/disabled state of the cli flags
* for the language server handlers.
*
* USAGE:
* 1.) This object first uses the parsed shell env values found in the variables:
* - `fish_lsp_enabled_handlers`
* - `fish_lsp_disabled_handlers`
*
* 2.) Next, it uses the cli flags parsed from the `--enable` and `--disable` flags:
* - keys are from the validHandlers array.
*
* 3.) Finally, its values can be used to determine if a handler is enabled or disabled.
*/
export const configHandlers = ConfigHandlerSchema.parse({});
export const validHandlers: Array<keyof typeof ConfigHandlerSchema.shape> = [
'complete', 'hover', 'rename', 'reference', 'formatting',
'codeAction', 'codeLens', 'folding', 'signature', 'executeCommand',
'inlayHint', 'highlight', 'diagnostic', 'popups',
];
export function updateHandlers(keys: string[], value: boolean): void {
keys.forEach(key => {
if (validHandlers.includes(key as keyof typeof ConfigHandlerSchema.shape)) {
configHandlers[key as keyof typeof ConfigHandlerSchema.shape] = value;
}
});
}
/********************************************
********** User Env ***********
*******************************************/
export const ConfigSchema = z.object({
/** Handlers that are enabled in the language server */
fish_lsp_enabled_handlers: z.array(z.string()).default([]),
/** Handlers that are disabled in the language server */
fish_lsp_disabled_handlers: z.array(z.string()).default([]),
/** Characters that completion items will be accepted on */
fish_lsp_commit_characters: z.array(z.string()).default(['\t', ';', ' ']),
/** Path to the log files */
fish_lsp_logfile: z.string().default(''),
/** All workspaces/paths for the language-server to index */
fish_lsp_all_indexed_paths: z.array(z.string()).default(['/usr/share/fish', `${os.homedir()}/.config/fish`]),
/** All workspace/paths that the language-server should be able to rename inside*/
fish_lsp_modifiable_paths: z.array(z.string()).default([`${os.homedir()}/.config/fish`]),
/** error code numbers to disable */
fish_lsp_diagnostic_disable_error_codes: z.array(z.number()).default([]),
/** max background files */
fish_lsp_max_background_files: z.number().default(1000),
/** show startup analysis notification */
fish_lsp_show_client_popups: z.boolean().default(true),
});
export type Config = z.infer<typeof ConfigSchema>;
export function getConfigFromEnvironmentVariables(): {
config: Config;
environmentVariablesUsed: string[];
} {
const rawConfig = {
fish_lsp_enabled_handlers: process.env.fish_lsp_enabled_handlers?.split(' '),
fish_lsp_disabled_handlers: process.env.fish_lsp_disabled_handlers?.split(' '),
fish_lsp_commit_characters: process.env.fish_lsp_commit_characters?.split(' '),
fish_lsp_logfile: process.env.fish_lsp_logfile,
fish_lsp_all_indexed_paths: process.env.fish_lsp_all_indexed_paths?.split(' '),
fish_lsp_modifiable_paths: process.env.fish_lsp_modifiable_paths?.split(' '),
fish_lsp_diagnostic_disable_error_codes: process.env.fish_lsp_diagnostic_disable_error_codes?.split(' ').map(toNumber),
fish_lsp_max_background_files: toNumber(process.env.fish_lsp_max_background_files),
fish_lsp_show_client_popups: toBoolean(process.env.fish_lsp_show_client_popups),
};
const environmentVariablesUsed = Object.entries(rawConfig)
.map(([key, value]) => typeof value !== 'undefined' ? key : null)
.filter((key): key is string => key !== null);
const config = ConfigSchema.parse(rawConfig);
return { config, environmentVariablesUsed };
}
export function getDefaultConfiguration(): Config {
return ConfigSchema.parse({});
}
/**
* convert boolean & number shell strings to their correct type
*/
const toBoolean = (s?: string): boolean | undefined =>
typeof s !== 'undefined' ? s === 'true' || s === '1' : undefined;
const toNumber = (s?: string): number | undefined =>
typeof s !== 'undefined' ? parseInt(s, 10) : undefined;
/**
* generateJsonSchemaShellScript - just prints the starter template for the schema
* in fish-shell
*/
export function generateJsonSchemaShellScript(showComments: boolean, useGlobal: boolean, useLocal: boolean, useExport: boolean) {
const result: string[] = [];
const command = getEnvVariableCommand(useGlobal, useLocal, useExport);
Object.values(fishLspEnvVariables).forEach(entry => {
const { name, description, valueType } = entry;
const line = !showComments
? `${command} ${name}\n`
: [
`# ${name} <${valueType.toUpperCase()}>`,
formatDescription(description, 80),
`${command} ${name}`,
'',
].join('\n');
result.push(line);
});
const output = result.join('\n').trimEnd();
logToStdout(output);
}
/**
* showJsonSchemaShellScript - prints the current environment schema
* in fish
*/
export function showJsonSchemaShellScript(showComments: boolean, useGlobal: boolean, useLocal: boolean, useExport: boolean) {
const { config } = getConfigFromEnvironmentVariables();
const command = getEnvVariableCommand(useGlobal, useLocal, useExport);
const findValue = (keyName: string) => {
return Object.values(fishLspEnvVariables).find(entry => {
const { name } = entry;
return name === keyName;
})!;
};
const result: string[] = [];
for (const item of Object.entries(config)) {
const [key, value] = item;
const entry = findValue(key);
let line = !showComments
? `${command} ${key} `
: [
`# ${entry.name} <${entry.valueType.toUpperCase()}>`,
formatDescription(entry.description, 80),
`${command} ${key} `,
].join('\n');
if (Array.isArray(value)) {
if (value.length === 0) {
line += "''\n"; // Print two single quotes for empty arrays
} else {
// Map each value to ensure any special characters are escaped
const escapedValues = value.map(v => escapeValue(v));
line += escapedValues.join(' ') + '\n'; // Join array values with a space
}
} else {
// Use a helper function to handle string escaping
line += escapeValue(value) + '\n';
}
result.push(line);
}
const output = result.join('\n').trimEnd();
logToStdout(output);
}
/*************************************
******* formatting helpers ********
************************************/
// Function to format descriptions into multi-line comments
function formatDescription(description: string, maxLineLength: number = 80): string {
const words = description.split(' ');
let currentLine = '#';
let formattedDescription = '';
for (const word of words) {
// Check if adding the next word would exceed the line length
if (currentLine.length + word.length + 1 > maxLineLength) {
formattedDescription += currentLine + '\n';
currentLine = '# ' + word; // Start a new line with the word
} else {
// Append word to the current line
currentLine += (currentLine.length > 1 ? ' ' : ' ') + word;
}
}
// Append any remaining text in the current line
if (currentLine.length > 1) {
formattedDescription += currentLine;
}
return formattedDescription;
}
function escapeValue(value: string | number | boolean): string {
if (typeof value === 'string') {
// Replace special characters with their escaped equivalents
return `'${value.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/'/g, "\\'")}'`;
} else {
// Return non-string types as they are
return value.toString();
}
}
/**
* getEnvVariableCommand - returns the correct command for setting environment variables
* in fish-shell. Used for generating `fish-lsp env` output. Result string will be
* either `set -g`, `set -l`, `set -gx`, or `set -lx`, depending on the flags passed.
* ___
* ```fish
* >_ fish-lsp env --no-global --no-export --no-comments | head -n 1
* set -l fish_lsp_enabled_handlers
* ```
* ___
* @param {boolean} useGlobal - whether to use the global flag
* @param {boolean} useLocal - allows for skipping the local flag
* @param {boolean} useExport - whether to use the export flag
* @returns {string} - the correct command for setting environment variables
*/
function getEnvVariableCommand(useGlobal: boolean, useLocal: boolean, useExport: boolean): 'set -g' | 'set -l' | 'set -gx' | 'set -lx' | 'set' | 'set -x' {
let command = 'set';
command = useGlobal ? `${command} -g` : useLocal ? `${command} -l` : command;
command = useExport ? command.endsWith('-g') || command.endsWith('-l') ? `${command}x` : `${command} -x` : command;
return command as 'set -g' | 'set -l' | 'set -gx' | 'set -lx' | 'set' | 'set -x';
}
/********************************************
*** initializeResult ***
*******************************************/
/* in server onInitialize() */
export function adjustInitializeResultCapabilitiesFromConfig(configHandlers: z.infer<typeof ConfigHandlerSchema>, userConfig: z.infer<typeof ConfigSchema>): InitializeResult {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: configHandlers.complete ? {
resolveProvider: true,
allCommitCharacters: userConfig.fish_lsp_commit_characters,
workDoneProgress: true,
triggerCharacters: ['$'],
} : undefined,
hoverProvider: configHandlers.hover,
definitionProvider: configHandlers.reference,
referencesProvider: configHandlers.reference,
renameProvider: configHandlers.rename,
documentFormattingProvider: configHandlers.formatting,
documentRangeFormattingProvider: configHandlers.formatting,
foldingRangeProvider: configHandlers.folding,
codeActionProvider: configHandlers.codeAction ? {
codeActionKinds: [...AllSupportedActions],
workDoneProgress: true,
resolveProvider: true,
} : undefined,
executeCommandProvider: configHandlers.executeCommand ? {
commands: [...AllSupportedActions, ...LspCommands],
workDoneProgress: true,
} : undefined,
documentSymbolProvider: {
label: 'Fish-LSP',
},
workspaceSymbolProvider: {
resolveProvider: true,
},
documentHighlightProvider: configHandlers.highlight,
inlayHintProvider: configHandlers.inlayHint,
signatureHelpProvider: configHandlers.signature ? { workDoneProgress: false, triggerCharacters: ['.'] } : undefined,
},
};
}
/********************************************
*** Config ***
*******************************************/
export namespace Config {
/**
* fixPopups - updates the `config.fish_lsp_show_client_popups` value based on the 3 cases:
* - cli flags include 'popups' -> directly sets `fish_lsp_show_client_popups`
* - `config.fish_lsp_enabled_handlers`/`config.fish_lsp_disabled_handlers` includes 'popups'
* - if both set && env doesn't set popups -> disable popups
* - if enabled && env doesn't set popups-> enable popups
* - if disabled && env doesn't set popups -> disable popups
* - if env sets popups -> use env for popups && don't override with handler
* - `config.fish_lsp_show_client_popups` is set in the environment variables
* @param {string[]} enabled - the cli flags that are enabled
* @param {string[]} disabled - the cli flags that are disabled
* @returns {void}
*/
export function fixPopups(enabled: string[], disabled: string[]): void {
/*
* `enabled/disabled` cli flag arrays are used instead of `configHandlers`
* because `configHandlers` always sets `popups` to true
*/
if (enabled.includes('popups') || disabled.includes('popups')) {
if (enabled.includes('popups')) config.fish_lsp_show_client_popups = true;
if (disabled.includes('popups')) config.fish_lsp_show_client_popups = false;
return;
}
/**
* `configHandlers.popups` is set to false, so popups are disabled
*/
if (configHandlers.popups === false) {
config.fish_lsp_show_client_popups = false;
return;
}
// envValue is the value of `process.env.fish_lsp_show_client_popups`
const envValue = toBoolean(process.env.fish_lsp_show_client_popups);
// check error case where both are set
if (
config.fish_lsp_enabled_handlers.includes('popups')
&& config.fish_lsp_disabled_handlers.includes('popups')
) {
if (envValue) {
config.fish_lsp_show_client_popups = envValue;
return;
} else {
config.fish_lsp_show_client_popups = false;
return;
}
}
/**
* `process.env.fish_lsp_show_client_popups` is not set, and
* `fish_lsp_enabled_handlers/fish_lsp_disabled_handlers` includes 'popups'
*/
if (typeof envValue === 'undefined') {
if (config.fish_lsp_enabled_handlers.includes('popups')) {
config.fish_lsp_show_client_popups = true;
return;
}
/** config.fish_lsp_disabled_handlers is from the fish env */
if (config.fish_lsp_disabled_handlers.includes('popups')) {
config.fish_lsp_show_client_popups = false;
return;
}
}
// `process.env.fish_lsp_show_client_popups` is set and 'popups' is enabled/disabled in the handlers
return;
}
}
// create config to be used globally
export const { config, environmentVariablesUsed } = getConfigFromEnvironmentVariables();