Path: blob/main/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { Queue } from '../../../../base/common/async.js';5import { IStringDictionary } from '../../../../base/common/collections.js';6import { Iterable } from '../../../../base/common/iterator.js';7import { LRUCache } from '../../../../base/common/map.js';8import { Schemas } from '../../../../base/common/network.js';9import { IProcessEnvironment } from '../../../../base/common/platform.js';10import * as Types from '../../../../base/common/types.js';11import { URI as uri } from '../../../../base/common/uri.js';12import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';13import { localize } from '../../../../nls.js';14import { ICommandService } from '../../../../platform/commands/common/commands.js';15import { ConfigurationTarget, IConfigurationOverrides, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';16import { ILabelService } from '../../../../platform/label/common/label.js';17import { IInputOptions, IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';18import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';19import { IWorkspaceContextService, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js';20import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js';21import { IEditorService } from '../../editor/common/editorService.js';22import { IExtensionService } from '../../extensions/common/extensions.js';23import { IPathService } from '../../path/common/pathService.js';24import { ConfiguredInput, VariableError, VariableKind } from '../common/configurationResolver.js';25import { ConfigurationResolverExpression, IResolvedValue } from '../common/configurationResolverExpression.js';26import { AbstractVariableResolverService } from '../common/variableResolver.js';2728const LAST_INPUT_STORAGE_KEY = 'configResolveInputLru';29const LAST_INPUT_CACHE_SIZE = 5;3031export abstract class BaseConfigurationResolverService extends AbstractVariableResolverService {3233static readonly INPUT_OR_COMMAND_VARIABLES_PATTERN = /\${((input|command):(.*?))}/g;3435private userInputAccessQueue = new Queue<string | IQuickPickItem | undefined>();3637constructor(38context: {39getAppRoot: () => string | undefined;40getExecPath: () => string | undefined;41},42envVariablesPromise: Promise<IProcessEnvironment>,43editorService: IEditorService,44private readonly configurationService: IConfigurationService,45private readonly commandService: ICommandService,46workspaceContextService: IWorkspaceContextService,47private readonly quickInputService: IQuickInputService,48private readonly labelService: ILabelService,49private readonly pathService: IPathService,50extensionService: IExtensionService,51private readonly storageService: IStorageService,52) {53super({54getFolderUri: (folderName: string): uri | undefined => {55const folder = workspaceContextService.getWorkspace().folders.filter(f => f.name === folderName).pop();56return folder ? folder.uri : undefined;57},58getWorkspaceFolderCount: (): number => {59return workspaceContextService.getWorkspace().folders.length;60},61getConfigurationValue: (folderUri: uri | undefined, section: string): string | undefined => {62return configurationService.getValue<string>(section, folderUri ? { resource: folderUri } : {});63},64getAppRoot: (): string | undefined => {65return context.getAppRoot();66},67getExecPath: (): string | undefined => {68return context.getExecPath();69},70getFilePath: (): string | undefined => {71const fileResource = EditorResourceAccessor.getOriginalUri(editorService.activeEditor, {72supportSideBySide: SideBySideEditor.PRIMARY,73filterByScheme: [Schemas.file, Schemas.vscodeUserData, this.pathService.defaultUriScheme]74});75if (!fileResource) {76return undefined;77}78return this.labelService.getUriLabel(fileResource, { noPrefix: true });79},80getWorkspaceFolderPathForFile: (): string | undefined => {81const fileResource = EditorResourceAccessor.getOriginalUri(editorService.activeEditor, {82supportSideBySide: SideBySideEditor.PRIMARY,83filterByScheme: [Schemas.file, Schemas.vscodeUserData, this.pathService.defaultUriScheme]84});85if (!fileResource) {86return undefined;87}88const wsFolder = workspaceContextService.getWorkspaceFolder(fileResource);89if (!wsFolder) {90return undefined;91}92return this.labelService.getUriLabel(wsFolder.uri, { noPrefix: true });93},94getSelectedText: (): string | undefined => {95const activeTextEditorControl = editorService.activeTextEditorControl;9697let activeControl: ICodeEditor | null = null;9899if (isCodeEditor(activeTextEditorControl)) {100activeControl = activeTextEditorControl;101} else if (isDiffEditor(activeTextEditorControl)) {102const original = activeTextEditorControl.getOriginalEditor();103const modified = activeTextEditorControl.getModifiedEditor();104activeControl = original.hasWidgetFocus() ? original : modified;105}106107const activeModel = activeControl?.getModel();108const activeSelection = activeControl?.getSelection();109if (activeModel && activeSelection) {110return activeModel.getValueInRange(activeSelection);111}112return undefined;113},114getLineNumber: (): string | undefined => {115const activeTextEditorControl = editorService.activeTextEditorControl;116if (isCodeEditor(activeTextEditorControl)) {117const selection = activeTextEditorControl.getSelection();118if (selection) {119const lineNumber = selection.positionLineNumber;120return String(lineNumber);121}122}123return undefined;124},125getColumnNumber: (): string | undefined => {126const activeTextEditorControl = editorService.activeTextEditorControl;127if (isCodeEditor(activeTextEditorControl)) {128const selection = activeTextEditorControl.getSelection();129if (selection) {130const columnNumber = selection.positionColumn;131return String(columnNumber);132}133}134return undefined;135},136getExtension: id => {137return extensionService.getExtension(id);138},139}, labelService, pathService.userHome().then(home => home.path), envVariablesPromise);140141this.resolvableVariables.add('command');142this.resolvableVariables.add('input');143}144145override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: unknown, section?: string, variables?: IStringDictionary<string>, target?: ConfigurationTarget): Promise<any> {146const parsed = ConfigurationResolverExpression.parse(config);147await this.resolveWithInteraction(folder, parsed, section, variables, target);148149return parsed.toObject();150}151152override async resolveWithInteraction(folder: IWorkspaceFolderData | undefined, config: unknown, section?: string, variableToCommandMap?: IStringDictionary<string>, target?: ConfigurationTarget): Promise<Map<string, string> | undefined> {153const expr = ConfigurationResolverExpression.parse(config);154155// Get values for input variables from UI156for (const variable of expr.unresolved()) {157let result: IResolvedValue | undefined;158159// Command160if (variable.name === 'command') {161const commandId = (variableToCommandMap ? variableToCommandMap[variable.arg!] : undefined) || variable.arg!;162const value = await this.commandService.executeCommand(commandId, expr.toObject());163if (!Types.isUndefinedOrNull(value)) {164if (typeof value !== 'string') {165throw new VariableError(VariableKind.Command, localize('commandVariable.noStringType', "Cannot substitute command variable '{0}' because command did not return a result of type string.", commandId));166}167result = { value };168}169}170// Input171else if (variable.name === 'input') {172result = await this.showUserInput(section!, variable.arg!, await this.resolveInputs(folder, section!, target), variableToCommandMap);173}174// Contributed variable175else if (this._contributedVariables.has(variable.inner)) {176result = { value: await this._contributedVariables.get(variable.inner)!() };177}178else {179// Fallback to parent evaluation180const resolvedValue = await this.evaluateSingleVariable(variable, folder?.uri);181if (resolvedValue === undefined) {182// Not something we can handle183continue;184}185result = typeof resolvedValue === 'string' ? { value: resolvedValue } : resolvedValue;186}187188if (result === undefined) {189// Skip the entire flow if any input variable was canceled190return undefined;191}192193expr.resolve(variable, result);194}195196return new Map(Iterable.map(expr.resolved(), ([key, value]) => [key.inner, value.value!]));197}198199private async resolveInputs(folder: IWorkspaceFolderData | undefined, section: string, target?: ConfigurationTarget): Promise<ConfiguredInput[] | undefined> {200if (!section) {201return undefined;202}203204// Look at workspace configuration205let inputs: ConfiguredInput[] | undefined;206const overrides: IConfigurationOverrides = folder ? { resource: folder.uri } : {};207const result = this.configurationService.inspect<{ inputs?: ConfiguredInput[] }>(section, overrides);208209if (result) {210switch (target) {211case ConfigurationTarget.MEMORY: inputs = result.memoryValue?.inputs; break;212case ConfigurationTarget.DEFAULT: inputs = result.defaultValue?.inputs; break;213case ConfigurationTarget.USER: inputs = result.userValue?.inputs; break;214case ConfigurationTarget.USER_LOCAL: inputs = result.userLocalValue?.inputs; break;215case ConfigurationTarget.USER_REMOTE: inputs = result.userRemoteValue?.inputs; break;216case ConfigurationTarget.APPLICATION: inputs = result.applicationValue?.inputs; break;217case ConfigurationTarget.WORKSPACE: inputs = result.workspaceValue?.inputs; break;218219case ConfigurationTarget.WORKSPACE_FOLDER:220default:221inputs = result.workspaceFolderValue?.inputs;222break;223}224}225226227inputs ??= this.configurationService.getValue<any>(section, overrides)?.inputs;228229return inputs;230}231232private readInputLru(): LRUCache<string, string> {233const contents = this.storageService.get(LAST_INPUT_STORAGE_KEY, StorageScope.WORKSPACE);234const lru = new LRUCache<string, string>(LAST_INPUT_CACHE_SIZE);235try {236if (contents) {237lru.fromJSON(JSON.parse(contents));238}239} catch {240// ignored241}242243return lru;244}245246private storeInputLru(lru: LRUCache<string, string>): void {247this.storageService.store(LAST_INPUT_STORAGE_KEY, JSON.stringify(lru.toJSON()), StorageScope.WORKSPACE, StorageTarget.MACHINE);248}249250private async showUserInput(section: string, variable: string, inputInfos: ConfiguredInput[] | undefined, variableToCommandMap?: IStringDictionary<string>): Promise<IResolvedValue | undefined> {251if (!inputInfos) {252throw new VariableError(VariableKind.Input, localize('inputVariable.noInputSection', "Variable '{0}' must be defined in an '{1}' section of the debug or task configuration.", variable, 'inputs'));253}254255// Find info for the given input variable256const info = inputInfos.filter(item => item.id === variable).pop();257if (info) {258const missingAttribute = (attrName: string) => {259throw new VariableError(VariableKind.Input, localize('inputVariable.missingAttribute', "Input variable '{0}' is of type '{1}' and must include '{2}'.", variable, info.type, attrName));260};261262const defaultValueMap = this.readInputLru();263const defaultValueKey = `${section}.${variable}`;264const previousPickedValue = defaultValueMap.get(defaultValueKey);265266switch (info.type) {267case 'promptString': {268if (!Types.isString(info.description)) {269missingAttribute('description');270}271const inputOptions: IInputOptions = { prompt: info.description, ignoreFocusLost: true, value: variableToCommandMap?.[`input:${variable}`] ?? previousPickedValue ?? info.default };272if (info.password) {273inputOptions.password = info.password;274}275return this.userInputAccessQueue.queue(() => this.quickInputService.input(inputOptions)).then(resolvedInput => {276if (typeof resolvedInput === 'string' && !info.password) {277this.storeInputLru(defaultValueMap.set(defaultValueKey, resolvedInput));278}279return resolvedInput !== undefined ? { value: resolvedInput as string, input: info } : undefined;280});281}282283case 'pickString': {284if (!Types.isString(info.description)) {285missingAttribute('description');286}287if (Array.isArray(info.options)) {288for (const pickOption of info.options) {289if (!Types.isString(pickOption) && !Types.isString(pickOption.value)) {290missingAttribute('value');291}292}293} else {294missingAttribute('options');295}296297interface PickStringItem extends IQuickPickItem {298value: string;299}300const picks = new Array<PickStringItem>();301for (const pickOption of info.options) {302const value = Types.isString(pickOption) ? pickOption : pickOption.value;303const label = Types.isString(pickOption) ? undefined : pickOption.label;304305const item: PickStringItem = {306label: label ? `${label}: ${value}` : value,307value: value308};309310const topValue = variableToCommandMap?.[`input:${variable}`] ?? previousPickedValue ?? info.default;311if (value === info.default) {312item.description = localize('inputVariable.defaultInputValue', "(Default)");313picks.unshift(item);314} else if (value === topValue) {315picks.unshift(item);316} else {317picks.push(item);318}319}320321const pickOptions: IPickOptions<PickStringItem> = { placeHolder: info.description, matchOnDetail: true, ignoreFocusLost: true };322return this.userInputAccessQueue.queue(() => this.quickInputService.pick(picks, pickOptions, undefined)).then(resolvedInput => {323if (resolvedInput) {324const value = (resolvedInput as PickStringItem).value;325this.storeInputLru(defaultValueMap.set(defaultValueKey, value));326return { value, input: info };327}328return undefined;329});330}331332case 'command': {333if (!Types.isString(info.command)) {334missingAttribute('command');335}336return this.userInputAccessQueue.queue(() => this.commandService.executeCommand<string>(info.command, info.args)).then(result => {337if (typeof result === 'string' || Types.isUndefinedOrNull(result)) {338return { value: result, input: info };339}340throw new VariableError(VariableKind.Input, localize('inputVariable.command.noStringType', "Cannot substitute input variable '{0}' because command '{1}' did not return a result of type string.", variable, info.command));341});342}343344default:345throw new VariableError(VariableKind.Input, localize('inputVariable.unknownType', "Input variable '{0}' can only be of type 'promptString', 'pickString', or 'command'.", variable));346}347}348349throw new VariableError(VariableKind.Input, localize('inputVariable.undefinedVariable', "Undefined input variable '{0}' encountered. Remove or define '{0}' to continue.", variable));350}351}352353354