mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-02 14:31:31 +01:00
484 lines
17 KiB
TypeScript
484 lines
17 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 } 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 type { ICompletionResource } from './types';
|
|
import { getBashGlobals } from './shell/bash';
|
|
import { getZshGlobals } from './shell/zsh';
|
|
import { getFishGlobals } from './shell/fish';
|
|
import { getPwshGlobals } from './shell/pwsh';
|
|
import { getTokenType, TokenType } from './tokens';
|
|
import { PathExecutableCache } from './env/pathExecutableCache';
|
|
import { getFriendlyResourcePath } from './helpers/uri';
|
|
|
|
// TODO: remove once API is finalized
|
|
export const enum TerminalShellType {
|
|
Sh = 1,
|
|
Bash = 2,
|
|
Fish = 3,
|
|
Csh = 4,
|
|
Ksh = 5,
|
|
Zsh = 6,
|
|
CommandPrompt = 7,
|
|
GitBash = 8,
|
|
PowerShell = 9,
|
|
Python = 10,
|
|
Julia = 11,
|
|
NuShell = 12,
|
|
Node = 13
|
|
}
|
|
|
|
const isWindows = osIsWindows();
|
|
const cachedGlobals: Map<TerminalShellType, ICompletionResource[] | undefined> = new Map();
|
|
let pathExecutableCache: PathExecutableCache;
|
|
|
|
export const availableSpecs: Fig.Spec[] = [
|
|
cdSpec,
|
|
codeInsidersCompletionSpec,
|
|
codeCompletionSpec,
|
|
];
|
|
for (const spec of upstreamSpecs) {
|
|
availableSpecs.push(require(`./completions/upstream/${spec}`).default);
|
|
}
|
|
|
|
const getShellSpecificGlobals: Map<TerminalShellType, (options: ExecOptionsWithStringEncoding, existingCommands?: Set<string>) => Promise<(string | ICompletionResource)[]>> = new Map([
|
|
[TerminalShellType.Bash, getBashGlobals],
|
|
[TerminalShellType.Zsh, getZshGlobals],
|
|
// TODO: Ghost text in the command line prevents completions from working ATM for fish
|
|
[TerminalShellType.Fish, getFishGlobals],
|
|
[TerminalShellType.PowerShell, getPwshGlobals],
|
|
]);
|
|
|
|
async function getShellGlobals(shellType: TerminalShellType, existingCommands?: Set<string>): Promise<ICompletionResource[] | undefined> {
|
|
try {
|
|
const cachedCommands = cachedGlobals.get(shellType);
|
|
if (cachedCommands) {
|
|
return cachedCommands;
|
|
}
|
|
const shell = getShell(shellType);
|
|
if (!shell) {
|
|
return;
|
|
}
|
|
const options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell };
|
|
const mixedCommands: (string | ICompletionResource)[] | undefined = await getShellSpecificGlobals.get(shellType)?.(options, existingCommands);
|
|
const normalizedCommands = mixedCommands?.map(command => typeof command === 'string' ? ({ label: command }) : command);
|
|
cachedGlobals.set(shellType, normalizedCommands);
|
|
return normalizedCommands;
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching builtin commands:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
export async function activate(context: vscode.ExtensionContext) {
|
|
pathExecutableCache = new PathExecutableCache();
|
|
context.subscriptions.push(pathExecutableCache);
|
|
|
|
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;
|
|
}
|
|
|
|
const shellType: TerminalShellType | undefined = 'shellType' in terminal.state ? terminal.state.shellType as TerminalShellType : undefined;
|
|
if (!shellType) {
|
|
return;
|
|
}
|
|
|
|
const commandsInPath = await pathExecutableCache.getExecutablesInPath(terminal.shellIntegration?.env);
|
|
const shellGlobals = await getShellGlobals(shellType, commandsInPath?.labels) ?? [];
|
|
if (!commandsInPath?.completionResources) {
|
|
return;
|
|
}
|
|
const commands = [...commandsInPath.completionResources, ...shellGlobals];
|
|
|
|
const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition);
|
|
const pathSeparator = isWindows ? '\\' : '/';
|
|
const tokenType = getTokenType(terminalContext, shellType);
|
|
const result = await getCompletionItemsFromSpecs(availableSpecs, terminalContext, commands, prefix, tokenType, terminal.shellIntegration?.cwd, token);
|
|
if (terminal.shellIntegration?.env) {
|
|
const homeDirCompletion = result.items.find(i => i.label === '~');
|
|
if (homeDirCompletion && terminal.shellIntegration.env.HOME) {
|
|
homeDirCompletion.documentation = getFriendlyResourcePath(vscode.Uri.file(terminal.shellIntegration.env.HOME), pathSeparator, vscode.TerminalCompletionItemKind.Folder);
|
|
homeDirCompletion.kind = vscode.TerminalCompletionItemKind.Folder;
|
|
}
|
|
}
|
|
|
|
if (result.cwd && (result.filesRequested || result.foldersRequested)) {
|
|
return new vscode.TerminalCompletionList(result.items, { filesRequested: result.filesRequested, foldersRequested: result.foldersRequested, cwd: result.cwd, env: terminal.shellIntegration?.env });
|
|
}
|
|
return result.items;
|
|
}
|
|
}, '/', '\\'));
|
|
}
|
|
|
|
/**
|
|
* 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 getDescription(spec: Fig.Spec): string {
|
|
if ('description' in spec) {
|
|
return spec.description ?? '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
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, detail?: string, documentation?: string | vscode.MarkdownString, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem {
|
|
const endsWithSpace = prefix.endsWith(' ');
|
|
const lastWord = endsWithSpace ? '' : prefix.split(' ').at(-1) ?? '';
|
|
return {
|
|
label: commandResource.label,
|
|
detail: detail ?? commandResource.detail ?? '',
|
|
documentation,
|
|
replacementIndex: cursorPosition - lastWord.length,
|
|
replacementLength: lastWord.length,
|
|
kind: kind ?? commandResource.kind ?? vscode.TerminalCompletionItemKind.Method
|
|
};
|
|
}
|
|
|
|
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,
|
|
tokenType: TokenType,
|
|
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;
|
|
|
|
let precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1);
|
|
if (isWindows) {
|
|
const spaceIndex = precedingText.indexOf(' ');
|
|
const commandEndIndex = spaceIndex === -1 ? precedingText.length : spaceIndex;
|
|
const lastDotIndex = precedingText.lastIndexOf('.', commandEndIndex);
|
|
if (lastDotIndex > 0) { // Don't treat dotfiles as extensions
|
|
precedingText = precedingText.substring(0, lastDotIndex) + precedingText.substring(spaceIndex);
|
|
}
|
|
}
|
|
|
|
let specificItemsProvided = false;
|
|
for (const spec of specs) {
|
|
const specLabels = getLabel(spec);
|
|
|
|
if (!specLabels) {
|
|
continue;
|
|
}
|
|
|
|
for (const specLabel of specLabels) {
|
|
const availableCommand = (isWindows
|
|
? availableCommands.find(command => command.label.match(new RegExp(`${specLabel}(\\.[^ ]+)?$`)))
|
|
: availableCommands.find(command => command.label.startsWith(specLabel)));
|
|
if (!availableCommand || (token && token.isCancellationRequested)) {
|
|
continue;
|
|
}
|
|
|
|
// push it to the completion items
|
|
if (tokenType === TokenType.Command) {
|
|
if (availableCommand.kind !== vscode.TerminalCompletionItemKind.Alias) {
|
|
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, { label: specLabel }, getDescription(spec), availableCommand.detail));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const commandAndAliases = (isWindows
|
|
? availableCommands.filter(command => specLabel === removeAnyFileExtension(command.definitionCommand ?? command.label))
|
|
: availableCommands.filter(command => specLabel === (command.definitionCommand ?? command.label)));
|
|
if (
|
|
!(isWindows
|
|
? commandAndAliases.some(e => precedingText.startsWith(`${removeAnyFileExtension(e.label)} `))
|
|
: commandAndAliases.some(e => precedingText.startsWith(`${e.label} `)))
|
|
) {
|
|
// the spec label is not the first word in the command line, so do not provide options or args
|
|
continue;
|
|
}
|
|
|
|
const optionsCompletionResult = handleOptions(specLabel, spec, terminalContext, precedingText, prefix);
|
|
if (optionsCompletionResult) {
|
|
items.push(...optionsCompletionResult.items);
|
|
filesRequested ||= optionsCompletionResult.filesRequested;
|
|
foldersRequested ||= optionsCompletionResult.foldersRequested;
|
|
specificItemsProvided ||= optionsCompletionResult.items.length > 0;
|
|
}
|
|
if (!optionsCompletionResult?.isOptionArg) {
|
|
const argsCompletionResult = handleArguments(specLabel, spec, terminalContext, precedingText);
|
|
if (argsCompletionResult) {
|
|
items.push(...argsCompletionResult.items);
|
|
filesRequested ||= argsCompletionResult.filesRequested;
|
|
foldersRequested ||= argsCompletionResult.foldersRequested;
|
|
specificItemsProvided ||= argsCompletionResult.items.length > 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (tokenType === TokenType.Command) {
|
|
// 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, command.detail));
|
|
}
|
|
}
|
|
filesRequested = true;
|
|
foldersRequested = true;
|
|
} else {
|
|
const shouldShowResourceCompletions =
|
|
!specificItemsProvided &&
|
|
!filesRequested &&
|
|
!foldersRequested;
|
|
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; isOptionArg: 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,
|
|
undefined,
|
|
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, isOptionArg: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { items: optionItems, filesRequested: false, foldersRequested: false, isOptionArg: 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 (Array.isArray(arg.template) ? arg.template.includes('filepaths') : arg.template === 'filepaths') {
|
|
filesRequested = true;
|
|
}
|
|
if (Array.isArray(arg.template) ? arg.template.includes('folders') : 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, undefined, vscode.TerminalCompletionItemKind.Argument));
|
|
}
|
|
}
|
|
}
|
|
if (items.length) {
|
|
return { items, filesRequested, foldersRequested };
|
|
}
|
|
}
|
|
}
|
|
return { items, filesRequested, foldersRequested };
|
|
}
|
|
|
|
function getShell(shellType: TerminalShellType): string | undefined {
|
|
switch (shellType) {
|
|
case TerminalShellType.Bash:
|
|
return 'bash';
|
|
case TerminalShellType.Fish:
|
|
return 'fish';
|
|
case TerminalShellType.Zsh:
|
|
return 'zsh';
|
|
case TerminalShellType.PowerShell:
|
|
return 'pwsh';
|
|
default: {
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeAnyFileExtension(label: string): string {
|
|
return label.replace(/\.[a-zA-Z0-9!#\$%&'\(\)\-@\^_`{}~\+,;=\[\]]+$/, '');
|
|
}
|