mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-02 22:41:31 +01:00
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
import * as vscode from 'vscode';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { ExecOptionsWithStringEncoding, execSync } from 'child_process';
|
|
import { upstreamSpecs } from './constants';
|
|
import codeCompletionSpec from './completions/code';
|
|
import cdSpec from './completions/cd';
|
|
import codeInsidersCompletionSpec from './completions/code-insiders';
|
|
import { osIsWindows } from './helpers/os';
|
|
import { isExecutable } from './helpers/executable';
|
|
|
|
const isWindows = osIsWindows();
|
|
let cachedAvailableCommandsPath: string | undefined;
|
|
let cachedWindowsExecutableExtensions: { [key: string]: boolean | undefined } | undefined;
|
|
const cachedWindowsExecutableExtensionsSettingId = 'terminal.integrated.suggest.windowsExecutableExtensions';
|
|
let cachedAvailableCommands: Set<ICompletionResource> | undefined;
|
|
const cachedBuiltinCommands: Map<string, ICompletionResource[] | undefined> = new Map();
|
|
|
|
export const availableSpecs: Fig.Spec[] = [
|
|
cdSpec,
|
|
codeInsidersCompletionSpec,
|
|
codeCompletionSpec,
|
|
];
|
|
for (const spec of upstreamSpecs) {
|
|
availableSpecs.push(require(`./completions/upstream/${spec}`).default);
|
|
}
|
|
|
|
function getBuiltinCommands(shell: string, existingCommands?: Set<string>): ICompletionResource[] | undefined {
|
|
try {
|
|
const shellType = path.basename(shell, path.extname(shell));
|
|
const cachedCommands = cachedBuiltinCommands.get(shellType);
|
|
if (cachedCommands) {
|
|
return cachedCommands;
|
|
}
|
|
const filter = (cmd: string) => cmd && !existingCommands?.has(cmd);
|
|
const options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell };
|
|
let commands: string[] | undefined;
|
|
switch (shellType) {
|
|
case 'bash': {
|
|
const bashOutput = execSync('compgen -b', options);
|
|
commands = bashOutput.split('\n').filter(filter);
|
|
break;
|
|
}
|
|
case 'zsh': {
|
|
const zshOutput = execSync('printf "%s\\n" ${(k)builtins}', options);
|
|
commands = zshOutput.split('\n').filter(filter);
|
|
break;
|
|
}
|
|
case 'fish': {
|
|
// TODO: Ghost text in the command line prevents completions from working ATM for fish
|
|
const fishOutput = execSync('functions -n', options);
|
|
commands = fishOutput.split(', ').filter(filter);
|
|
break;
|
|
}
|
|
case 'pwsh': {
|
|
// TODO: Select `CommandType, DisplayName` and map to a rich type with kind and detail
|
|
const output = execSync('Get-Command -All | Select-Object Name | ConvertTo-Json', options);
|
|
let json: any;
|
|
try {
|
|
json = JSON.parse(output);
|
|
} catch (e) {
|
|
console.error('Error parsing pwsh output:', e);
|
|
return [];
|
|
}
|
|
commands = (json as any[]).map(e => e.Name);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const commandResources = commands?.map(command => ({ label: command }));
|
|
cachedBuiltinCommands.set(shellType, commandResources);
|
|
return commandResources;
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching builtin commands:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
export async function activate(context: vscode.ExtensionContext) {
|
|
context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({
|
|
id: 'terminal-suggest',
|
|
async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: { commandLine: string; cursorPosition: number }, token: vscode.CancellationToken): Promise<vscode.TerminalCompletionItem[] | vscode.TerminalCompletionList | undefined> {
|
|
if (token.isCancellationRequested) {
|
|
return;
|
|
}
|
|
|
|
// TODO: Leverage shellType when available https://github.com/microsoft/vscode/issues/230165
|
|
const shellPath = ('shellPath' in terminal.creationOptions ? terminal.creationOptions.shellPath : undefined) ?? vscode.env.shell;
|
|
if (!shellPath) {
|
|
return;
|
|
}
|
|
|
|
const commandsInPath = await getCommandsInPath(terminal.shellIntegration?.env);
|
|
const builtinCommands = getBuiltinCommands(shellPath, commandsInPath?.labels) ?? [];
|
|
if (!commandsInPath?.completionResources) {
|
|
return;
|
|
}
|
|
const commands = [...commandsInPath.completionResources, ...builtinCommands];
|
|
|
|
const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition);
|
|
|
|
const result = await getCompletionItemsFromSpecs(availableSpecs, terminalContext, commands, prefix, terminal.shellIntegration?.cwd, token);
|
|
if (result.cwd && (result.filesRequested || result.foldersRequested)) {
|
|
// const cwd = resolveCwdFromPrefix(prefix, terminal.shellIntegration?.cwd) ?? terminal.shellIntegration?.cwd;
|
|
return new vscode.TerminalCompletionList(result.items, { filesRequested: result.filesRequested, foldersRequested: result.foldersRequested, cwd: result.cwd, pathSeparator: isWindows ? '\\' : '/' });
|
|
}
|
|
return result.items;
|
|
}
|
|
}, '/', '\\'));
|
|
|
|
if (isWindows) {
|
|
cachedWindowsExecutableExtensions = vscode.workspace.getConfiguration('terminal.integrated.suggest').get('windowsExecutableExtensions');
|
|
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => {
|
|
if (e.affectsConfiguration(cachedWindowsExecutableExtensionsSettingId)) {
|
|
cachedWindowsExecutableExtensions = vscode.workspace.getConfiguration('terminal.integrated.suggest').get('windowsExecutableExtensions');
|
|
cachedAvailableCommands = undefined;
|
|
cachedAvailableCommandsPath = undefined;
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adjusts the current working directory based on a given prefix if it is a folder.
|
|
* @param prefix - The folder path prefix.
|
|
* @param currentCwd - The current working directory.
|
|
* @returns The new working directory.
|
|
*/
|
|
export async function resolveCwdFromPrefix(prefix: string, currentCwd?: vscode.Uri): Promise<vscode.Uri | undefined> {
|
|
if (!currentCwd) {
|
|
return;
|
|
}
|
|
try {
|
|
// Get the nearest folder path from the prefix. This ignores everything after the `/` as
|
|
// they are what triggers changes in the directory.
|
|
let lastSlashIndex: number;
|
|
if (isWindows) {
|
|
// TODO: This support is very basic, ideally the slashes supported would depend upon the
|
|
// shell type. For example git bash under Windows does not allow using \ as a path
|
|
// separator.
|
|
lastSlashIndex = prefix.lastIndexOf('\\');
|
|
if (lastSlashIndex === -1) {
|
|
lastSlashIndex = prefix.lastIndexOf('/');
|
|
}
|
|
} else {
|
|
lastSlashIndex = prefix.lastIndexOf('/');
|
|
}
|
|
const relativeFolder = lastSlashIndex === -1 ? '' : prefix.slice(0, lastSlashIndex);
|
|
|
|
// Resolve the absolute path of the prefix
|
|
const resolvedPath = path.resolve(currentCwd?.fsPath, relativeFolder);
|
|
|
|
const stat = await fs.stat(resolvedPath);
|
|
|
|
// Check if the resolved path exists and is a directory
|
|
if (stat.isDirectory()) {
|
|
return currentCwd.with({ path: resolvedPath });
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
|
|
// If the prefix is not a folder, return the current cwd
|
|
return currentCwd;
|
|
}
|
|
|
|
|
|
function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[] | undefined {
|
|
if (typeof spec === 'string') {
|
|
return [spec];
|
|
}
|
|
if (typeof spec.name === 'string') {
|
|
return [spec.name];
|
|
}
|
|
if (!Array.isArray(spec.name) || spec.name.length === 0) {
|
|
return;
|
|
}
|
|
return spec.name;
|
|
}
|
|
|
|
function createCompletionItem(cursorPosition: number, prefix: string, commandResource: ICompletionResource, description?: string, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem {
|
|
const endsWithSpace = prefix.endsWith(' ');
|
|
const lastWord = endsWithSpace ? '' : prefix.split(' ').at(-1) ?? '';
|
|
return {
|
|
label: commandResource.label,
|
|
detail: description ?? commandResource.path ?? '',
|
|
replacementIndex: cursorPosition - lastWord.length,
|
|
replacementLength: lastWord.length,
|
|
kind: kind ?? vscode.TerminalCompletionItemKind.Method
|
|
};
|
|
}
|
|
|
|
interface ICompletionResource {
|
|
label: string;
|
|
path?: string;
|
|
}
|
|
async function getCommandsInPath(env: { [key: string]: string | undefined } = process.env): Promise<{ completionResources: Set<ICompletionResource> | undefined; labels: Set<string> | undefined } | undefined> {
|
|
const labels: Set<string> = new Set<string>();
|
|
let pathValue: string | undefined;
|
|
if (isWindows) {
|
|
const caseSensitivePathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
|
|
if (caseSensitivePathKey) {
|
|
pathValue = env[caseSensitivePathKey];
|
|
}
|
|
} else {
|
|
pathValue = env.PATH;
|
|
}
|
|
if (pathValue === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Check cache
|
|
if (cachedAvailableCommands && cachedAvailableCommandsPath === pathValue) {
|
|
return { completionResources: cachedAvailableCommands, labels };
|
|
}
|
|
|
|
// Extract executables from PATH
|
|
const paths = pathValue.split(isWindows ? ';' : ':');
|
|
const pathSeparator = isWindows ? '\\' : '/';
|
|
const executables = new Set<ICompletionResource>();
|
|
for (const path of paths) {
|
|
try {
|
|
const dirExists = await fs.stat(path).then(stat => stat.isDirectory()).catch(() => false);
|
|
if (!dirExists) {
|
|
continue;
|
|
}
|
|
const fileResource = vscode.Uri.file(path);
|
|
const files = await vscode.workspace.fs.readDirectory(fileResource);
|
|
for (const [file, fileType] of files) {
|
|
const formattedPath = getFriendlyFilePath(vscode.Uri.joinPath(fileResource, file), pathSeparator);
|
|
if (!labels.has(file) && fileType !== vscode.FileType.Unknown && fileType !== vscode.FileType.Directory && await isExecutable(formattedPath, cachedWindowsExecutableExtensions)) {
|
|
executables.add({ label: file, path: formattedPath });
|
|
labels.add(file);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore errors for directories that can't be read
|
|
continue;
|
|
}
|
|
}
|
|
cachedAvailableCommands = executables;
|
|
return { completionResources: executables, labels };
|
|
}
|
|
|
|
function getPrefix(commandLine: string, cursorPosition: number): string {
|
|
// Return an empty string if the command line is empty after trimming
|
|
if (commandLine.trim() === '') {
|
|
return '';
|
|
}
|
|
|
|
// Check if cursor is not at the end and there's non-whitespace after the cursor
|
|
if (cursorPosition < commandLine.length && /\S/.test(commandLine[cursorPosition])) {
|
|
return '';
|
|
}
|
|
|
|
// Extract the part of the line up to the cursor position
|
|
const beforeCursor = commandLine.slice(0, cursorPosition);
|
|
|
|
// Find the last sequence of non-whitespace characters before the cursor
|
|
const match = beforeCursor.match(/(\S+)\s*$/);
|
|
|
|
// Return the match if found, otherwise undefined
|
|
return match ? match[0] : '';
|
|
}
|
|
|
|
export function asArray<T>(x: T | T[]): T[];
|
|
export function asArray<T>(x: T | readonly T[]): readonly T[];
|
|
export function asArray<T>(x: T | T[]): T[] {
|
|
return Array.isArray(x) ? x : [x];
|
|
}
|
|
|
|
export async function getCompletionItemsFromSpecs(
|
|
specs: Fig.Spec[],
|
|
terminalContext: { commandLine: string; cursorPosition: number },
|
|
availableCommands: ICompletionResource[],
|
|
prefix: string,
|
|
shellIntegrationCwd?: vscode.Uri,
|
|
token?: vscode.CancellationToken
|
|
): Promise<{ items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; cwd?: vscode.Uri }> {
|
|
const items: vscode.TerminalCompletionItem[] = [];
|
|
let filesRequested = false;
|
|
let foldersRequested = false;
|
|
|
|
const firstCommand = getFirstCommand(terminalContext.commandLine);
|
|
const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1);
|
|
|
|
for (const spec of specs) {
|
|
const specLabels = getLabel(spec);
|
|
|
|
if (!specLabels) {
|
|
continue;
|
|
}
|
|
|
|
for (const specLabel of specLabels) {
|
|
if (!availableCommands.find(command => command.label === specLabel) || (token && token.isCancellationRequested)) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
// If the prompt is empty
|
|
!terminalContext.commandLine
|
|
// or the first command matches the command
|
|
|| !!firstCommand && specLabel.startsWith(firstCommand)
|
|
) {
|
|
// push it to the completion items
|
|
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, { label: specLabel }));
|
|
}
|
|
|
|
if (!terminalContext.commandLine.startsWith(specLabel)) {
|
|
// the spec label is not the first word in the command line, so do not provide options or args
|
|
continue;
|
|
}
|
|
|
|
const argsCompletionResult = handleArguments(specLabel, spec, terminalContext, precedingText);
|
|
if (argsCompletionResult) {
|
|
items.push(...argsCompletionResult.items);
|
|
filesRequested ||= argsCompletionResult.filesRequested;
|
|
foldersRequested ||= argsCompletionResult.foldersRequested;
|
|
}
|
|
|
|
const optionsCompletionResult = handleOptions(specLabel, spec, terminalContext, precedingText, prefix);
|
|
if (optionsCompletionResult) {
|
|
items.push(...optionsCompletionResult.items);
|
|
filesRequested ||= optionsCompletionResult.filesRequested;
|
|
foldersRequested ||= optionsCompletionResult.foldersRequested;
|
|
}
|
|
}
|
|
}
|
|
|
|
const shouldShowResourceCompletions =
|
|
(!terminalContext.commandLine.trim() || !items.length) &&
|
|
!filesRequested &&
|
|
!foldersRequested;
|
|
|
|
const shouldShowCommands = !terminalContext.commandLine.substring(0, terminalContext.cursorPosition).trimStart().includes(' ');
|
|
|
|
if (shouldShowCommands && !filesRequested && !foldersRequested) {
|
|
// Include builitin/available commands in the results
|
|
const labels = new Set(items.map((i) => i.label));
|
|
for (const command of availableCommands) {
|
|
if (!labels.has(command.label)) {
|
|
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldShowResourceCompletions) {
|
|
filesRequested = true;
|
|
foldersRequested = true;
|
|
}
|
|
|
|
let cwd: vscode.Uri | undefined;
|
|
if (shellIntegrationCwd && (filesRequested || foldersRequested)) {
|
|
cwd = await resolveCwdFromPrefix(prefix, shellIntegrationCwd) ?? shellIntegrationCwd;
|
|
}
|
|
|
|
return { items, filesRequested, foldersRequested, cwd };
|
|
}
|
|
|
|
function handleArguments(specLabel: string, spec: Fig.Spec, terminalContext: { commandLine: string; cursorPosition: number }, precedingText: string): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined {
|
|
let args;
|
|
if ('args' in spec && spec.args && asArray(spec.args)) {
|
|
args = asArray(spec.args);
|
|
}
|
|
const expectedText = `${specLabel} `;
|
|
|
|
if (!precedingText.includes(expectedText)) {
|
|
return;
|
|
}
|
|
|
|
const currentPrefix = precedingText.slice(precedingText.lastIndexOf(expectedText) + expectedText.length);
|
|
const argsCompletions = getCompletionItemsFromArgs(args, currentPrefix, terminalContext);
|
|
|
|
if (!argsCompletions) {
|
|
return;
|
|
}
|
|
|
|
return argsCompletions;
|
|
}
|
|
|
|
function handleOptions(specLabel: string, spec: Fig.Spec, terminalContext: { commandLine: string; cursorPosition: number }, precedingText: string, prefix: string): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined {
|
|
let options;
|
|
if ('options' in spec && spec.options) {
|
|
options = spec.options;
|
|
}
|
|
if (!options) {
|
|
return;
|
|
}
|
|
|
|
const optionItems: vscode.TerminalCompletionItem[] = [];
|
|
|
|
for (const option of options) {
|
|
const optionLabels = getLabel(option);
|
|
|
|
if (!optionLabels) {
|
|
continue;
|
|
}
|
|
|
|
for (const optionLabel of optionLabels) {
|
|
if (
|
|
// Already includes this option
|
|
optionItems.find((i) => i.label === optionLabel)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
optionItems.push(
|
|
createCompletionItem(
|
|
terminalContext.cursorPosition,
|
|
prefix,
|
|
{ label: optionLabel },
|
|
option.description,
|
|
vscode.TerminalCompletionItemKind.Flag
|
|
)
|
|
);
|
|
|
|
const expectedText = `${specLabel} ${optionLabel} `;
|
|
if (!precedingText.includes(expectedText)) {
|
|
continue;
|
|
}
|
|
|
|
const currentPrefix = precedingText.slice(precedingText.lastIndexOf(expectedText) + expectedText.length);
|
|
const argsCompletions = getCompletionItemsFromArgs(option.args, currentPrefix, terminalContext);
|
|
|
|
if (argsCompletions) {
|
|
return { items: argsCompletions.items, filesRequested: argsCompletions.filesRequested, foldersRequested: argsCompletions.foldersRequested };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { items: optionItems, filesRequested: false, foldersRequested: false };
|
|
}
|
|
|
|
|
|
function getCompletionItemsFromArgs(args: Fig.SingleOrArray<Fig.Arg> | undefined, currentPrefix: string, terminalContext: { commandLine: string; cursorPosition: number }): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } | undefined {
|
|
if (!args) {
|
|
return;
|
|
}
|
|
|
|
let items: vscode.TerminalCompletionItem[] = [];
|
|
let filesRequested = false;
|
|
let foldersRequested = false;
|
|
for (const arg of asArray(args)) {
|
|
if (!arg) {
|
|
continue;
|
|
}
|
|
if (arg.template) {
|
|
if (arg.template === 'filepaths') {
|
|
filesRequested = true;
|
|
} else if (arg.template === 'folders') {
|
|
foldersRequested = true;
|
|
}
|
|
}
|
|
if (arg.suggestions?.length) {
|
|
// there are specific suggestions to show
|
|
items = [];
|
|
for (const suggestion of arg.suggestions) {
|
|
const suggestionLabels = getLabel(suggestion);
|
|
if (!suggestionLabels) {
|
|
continue;
|
|
}
|
|
const twoWordsBefore = terminalContext.commandLine.slice(0, terminalContext.cursorPosition).split(' ').at(-2);
|
|
const wordBefore = terminalContext.commandLine.slice(0, terminalContext.cursorPosition).split(' ').at(-1);
|
|
for (const suggestionLabel of suggestionLabels) {
|
|
if (items.find(i => i.label === suggestionLabel)) {
|
|
continue;
|
|
}
|
|
if (!arg.isVariadic && twoWordsBefore === suggestionLabel && wordBefore?.trim() === '') {
|
|
return { items: [], filesRequested, foldersRequested };
|
|
}
|
|
if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) {
|
|
const description = typeof suggestion !== 'string' ? suggestion.description : '';
|
|
items.push(createCompletionItem(terminalContext.cursorPosition, wordBefore ?? '', { label: suggestionLabel }, description, vscode.TerminalCompletionItemKind.Argument));
|
|
}
|
|
}
|
|
}
|
|
if (items.length) {
|
|
return { items, filesRequested, foldersRequested };
|
|
}
|
|
}
|
|
}
|
|
return { items, filesRequested, foldersRequested };
|
|
}
|
|
|
|
|
|
|
|
function getFirstCommand(commandLine: string): string | undefined {
|
|
const wordsOnLine = commandLine.split(' ');
|
|
let firstCommand: string | undefined = wordsOnLine[0];
|
|
if (wordsOnLine.length > 1) {
|
|
firstCommand = undefined;
|
|
} else if (wordsOnLine.length === 0) {
|
|
firstCommand = commandLine;
|
|
}
|
|
return firstCommand;
|
|
}
|
|
|
|
function getFriendlyFilePath(uri: vscode.Uri, pathSeparator: string): string {
|
|
let path = uri.fsPath;
|
|
// Ensure drive is capitalized on Windows
|
|
if (pathSeparator === '\\' && path.match(/^[a-zA-Z]:\\/)) {
|
|
path = `${path[0].toUpperCase()}:${path.slice(2)}`;
|
|
}
|
|
return path;
|
|
}
|