Skip to content

Commit

Permalink
Merge pull request #239398 from microsoft/tyriar/239396_speedup
Browse files Browse the repository at this point in the history
Optimize getCommandsInPath, restore cache, clean up cache lifecycle, add test
  • Loading branch information
Tyriar authored Feb 3, 2025
2 parents e923ba7 + af8504c commit 65bcaaf
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 84 deletions.
7 changes: 7 additions & 0 deletions extensions/terminal-suggest/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ export const upstreamSpecs = [
'rmdir',
'touch',
];


export const enum SettingsIds {
SuggestPrefix = 'terminal.integrated.suggest',
CachedWindowsExecutableExtensions = 'terminal.integrated.suggest.windowsExecutableExtensions',
CachedWindowsExecutableExtensionsSuffixOnly = 'windowsExecutableExtensions',
}
109 changes: 109 additions & 0 deletions extensions/terminal-suggest/src/env/pathExecutableCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as fs from 'fs/promises';
import * as vscode from 'vscode';
import { isExecutable } from '../helpers/executable';
import { osIsWindows } from '../helpers/os';
import type { ICompletionResource } from '../types';
import { getFriendlyResourcePath } from '../helpers/uri';
import { SettingsIds } from '../constants';

const isWindows = osIsWindows();

export class PathExecutableCache implements vscode.Disposable {
private _disposables: vscode.Disposable[] = [];

private _cachedAvailableCommandsPath: string | undefined;
private _cachedWindowsExecutableExtensions: { [key: string]: boolean | undefined } | undefined;
private _cachedCommandsInPath: { completionResources: Set<ICompletionResource> | undefined; labels: Set<string> | undefined } | undefined;

constructor() {
if (isWindows) {
this._cachedWindowsExecutableExtensions = vscode.workspace.getConfiguration(SettingsIds.SuggestPrefix).get(SettingsIds.CachedWindowsExecutableExtensionsSuffixOnly);
this._disposables.push(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(SettingsIds.CachedWindowsExecutableExtensions)) {
this._cachedWindowsExecutableExtensions = vscode.workspace.getConfiguration(SettingsIds.SuggestPrefix).get(SettingsIds.CachedWindowsExecutableExtensionsSuffixOnly);
this._cachedCommandsInPath = undefined;
}
}));
}
}

dispose() {
for (const d of this._disposables) {
d.dispose();
}
}

async getCommandsInPath(env: { [key: string]: string | undefined } = process.env): Promise<{ completionResources: Set<ICompletionResource> | undefined; labels: Set<string> | undefined } | undefined> {
// Create cache key
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 (this._cachedCommandsInPath && this._cachedAvailableCommandsPath === pathValue) {
return this._cachedCommandsInPath;
}

// Extract executables from PATH
const paths = pathValue.split(isWindows ? ';' : ':');
const pathSeparator = isWindows ? '\\' : '/';
const promises: Promise<Set<ICompletionResource> | undefined>[] = [];
const labels: Set<string> = new Set<string>();
for (const path of paths) {
promises.push(this._getFilesInPath(path, pathSeparator, labels));
}

// Merge all results
const executables = new Set<ICompletionResource>();
const resultSets = await Promise.all(promises);
for (const resultSet of resultSets) {
if (resultSet) {
for (const executable of resultSet) {
executables.add(executable);
}
}
}

// Return
this._cachedAvailableCommandsPath = pathValue;
this._cachedCommandsInPath = { completionResources: executables, labels };
return this._cachedCommandsInPath;
}

private async _getFilesInPath(path: string, pathSeparator: string, labels: Set<string>): Promise<Set<ICompletionResource> | undefined> {
try {
const dirExists = await fs.stat(path).then(stat => stat.isDirectory()).catch(() => false);
if (!dirExists) {
return undefined;
}
const result = new Set<ICompletionResource>();
const fileResource = vscode.Uri.file(path);
const files = await vscode.workspace.fs.readDirectory(fileResource);
for (const [file, fileType] of files) {
const formattedPath = getFriendlyResourcePath(vscode.Uri.joinPath(fileResource, file), pathSeparator);
if (!labels.has(file) && fileType !== vscode.FileType.Unknown && fileType !== vscode.FileType.Directory && await isExecutable(formattedPath, this._cachedWindowsExecutableExtensions)) {
result.add({ label: file, detail: formattedPath });
labels.add(file);
}
}
return result;
} catch (e) {
// Ignore errors for directories that can't be read
return undefined;
}
}
}
6 changes: 5 additions & 1 deletion extensions/terminal-suggest/src/helpers/executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
import { osIsWindows } from './os';
import * as fs from 'fs/promises';

export async function isExecutable(filePath: string, configuredWindowsExecutableExtensions?: { [key: string]: boolean | undefined } | undefined): Promise<boolean> {
export function isExecutable(filePath: string, configuredWindowsExecutableExtensions?: { [key: string]: boolean | undefined } | undefined): Promise<boolean> | boolean {
if (osIsWindows()) {
const resolvedWindowsExecutableExtensions = resolveWindowsExecutableExtensions(configuredWindowsExecutableExtensions);
return resolvedWindowsExecutableExtensions.find(ext => filePath.endsWith(ext)) !== undefined;
}
return isExecutableUnix(filePath);
}

export async function isExecutableUnix(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath);
// On macOS/Linux, check if the executable bit is set
Expand Down
20 changes: 20 additions & 0 deletions extensions/terminal-suggest/src/helpers/uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* 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';

export function getFriendlyResourcePath(uri: vscode.Uri, pathSeparator: string, kind?: vscode.TerminalCompletionItemKind): 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)}`;
}
if (kind === vscode.TerminalCompletionItemKind.Folder) {
if (!path.endsWith(pathSeparator)) {
path += pathSeparator;
}
}
return path;
}
93 changes: 10 additions & 83 deletions extensions/terminal-suggest/src/terminalSuggestMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ 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';
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 {
Expand All @@ -37,11 +38,8 @@ export const enum TerminalShellType {
}

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<TerminalShellType, ICompletionResource[] | undefined> = new Map();
const cachedGlobals: Map<TerminalShellType, ICompletionResource[] | undefined> = new Map();
let pathExecutableCache: PathExecutableCache;

export const availableSpecs: Fig.Spec[] = [
cdSpec,
Expand All @@ -62,7 +60,7 @@ const getShellSpecificGlobals: Map<TerminalShellType, (options: ExecOptionsWithS

async function getShellGlobals(shellType: TerminalShellType, existingCommands?: Set<string>): Promise<ICompletionResource[] | undefined> {
try {
const cachedCommands = cachedBuiltinCommands.get(shellType);
const cachedCommands = cachedGlobals.get(shellType);
if (cachedCommands) {
return cachedCommands;
}
Expand All @@ -73,7 +71,7 @@ async function getShellGlobals(shellType: TerminalShellType, existingCommands?:
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);
cachedBuiltinCommands.set(shellType, normalizedCommands);
cachedGlobals.set(shellType, normalizedCommands);
return normalizedCommands;

} catch (error) {
Expand All @@ -83,6 +81,9 @@ async function getShellGlobals(shellType: TerminalShellType, existingCommands?:
}

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> {
Expand All @@ -95,7 +96,7 @@ export async function activate(context: vscode.ExtensionContext) {
return;
}

const commandsInPath = await getCommandsInPath(terminal.shellIntegration?.env);
const commandsInPath = await pathExecutableCache.getCommandsInPath(terminal.shellIntegration?.env);
const shellGlobals = await getShellGlobals(shellType, commandsInPath?.labels) ?? [];
if (!commandsInPath?.completionResources) {
return;
Expand All @@ -120,17 +121,6 @@ export async function activate(context: vscode.ExtensionContext) {
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;
}
}));
}
}

/**
Expand Down Expand Up @@ -209,55 +199,6 @@ function createCompletionItem(cursorPosition: number, prefix: string, commandRes
};
}


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 = getFriendlyResourcePath(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, detail: 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() === '') {
Expand Down Expand Up @@ -496,20 +437,6 @@ function getCompletionItemsFromArgs(args: Fig.SingleOrArray<Fig.Arg> | undefined
return { items, filesRequested, foldersRequested };
}

function getFriendlyResourcePath(uri: vscode.Uri, pathSeparator: string, kind?: vscode.TerminalCompletionItemKind): 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)}`;
}
if (kind === vscode.TerminalCompletionItemKind.Folder) {
if (!path.endsWith(pathSeparator)) {
path += pathSeparator;
}
}
return path;
}

function getShell(shellType: TerminalShellType): string | undefined {
switch (shellType) {
case TerminalShellType.Bash:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import 'mocha';
import { strictEqual } from 'node:assert';
import { PathExecutableCache } from '../../env/pathExecutableCache';

suite('PathExecutableCache', () => {
test('cache should return empty for empty PATH', async () => {
const cache = new PathExecutableCache();
const result = await cache.getCommandsInPath({ PATH: '' });
strictEqual(Array.from(result!.completionResources!).length, 0);
strictEqual(Array.from(result!.labels!).length, 0);
});

test('caching is working on successive calls', async () => {
const cache = new PathExecutableCache();
const env = { PATH: process.env.PATH };
const result = await cache.getCommandsInPath(env);
const result2 = await cache.getCommandsInPath(env);
strictEqual(result, result2);
});
});

0 comments on commit 65bcaaf

Please sign in to comment.