Path: blob/main/src/vs/workbench/services/configurationResolver/common/configurationResolverExpression.ts
5243 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*--------------------------------------------------------------------------------------------*/45import { Iterable } from '../../../../base/common/iterator.js';6import { isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js';7import { ConfiguredInput } from './configurationResolver.js';89/** A replacement found in the object, as ${name} or ${name:arg} */10export type Replacement = {11/** ${name:arg} */12id: string;13/** The `name:arg` in ${name:arg} */14inner: string;15/** The `name` in ${name:arg} */16name: string;17/** The `arg` in ${name:arg} */18arg?: string;19};2021interface IConfigurationResolverExpression<T> {22/**23* Gets the replacements which have not yet been24* resolved.25*/26unresolved(): Iterable<Replacement>;2728/**29* Gets the replacements which have been resolved.30*/31resolved(): Iterable<[Replacement, IResolvedValue]>;3233/**34* Resolves a replacement into the string value.35* If the value is undefined, the original variable text will be preserved.36*/37resolve(replacement: Replacement, data: string | IResolvedValue): void;3839/**40* Returns the complete object. Any unresolved replacements are left intact.41*/42toObject(): T;43}4445type PropertyLocation = {46object: any;47propertyName: string | number;48replaceKeyName?: boolean;49};5051export interface IResolvedValue {52value: string | undefined;5354/** Present when the variable is resolved from an input field. */55input?: ConfiguredInput;56}5758interface IReplacementLocation {59replacement: Replacement;60locations: PropertyLocation[];61resolved?: IResolvedValue;62}6364export class ConfigurationResolverExpression<T> implements IConfigurationResolverExpression<T> {65public static readonly VARIABLE_LHS = '${';6667private readonly locations = new Map<string, IReplacementLocation>();68private root: T;69private stringRoot: boolean;70/**71* Callbacks when a new replacement is made, so that nested resolutions from72* `expr.unresolved()` can be fulfilled in the same iteration.73*/74private newReplacementNotifiers = new Set<(r: Replacement) => void>();7576private constructor(object: T) {77// If the input is a string, wrap it in an object so we can use the same logic78if (typeof object === 'string') {79this.stringRoot = true;80// eslint-disable-next-line local/code-no-any-casts81this.root = { value: object } as any;82} else {83this.stringRoot = false;84this.root = structuredClone(object);85}86}8788/**89* Creates a new {@link ConfigurationResolverExpression} from an object.90* Note that platform-specific keys (i.e. `windows`, `osx`, `linux`) are91* applied during parsing.92*/93public static parse<T>(object: T): ConfigurationResolverExpression<T> {94if (object instanceof ConfigurationResolverExpression) {95return object;96}9798const expr = new ConfigurationResolverExpression<T>(object);99expr.applyPlatformSpecificKeys();100expr.parseObject(expr.root);101return expr;102}103104private applyPlatformSpecificKeys() {105// eslint-disable-next-line local/code-no-any-casts106const config = this.root as any; // already cloned by ctor, safe to change107const key = isWindows ? 'windows' : isMacintosh ? 'osx' : isLinux ? 'linux' : undefined;108109if (key && config && typeof config === 'object' && config.hasOwnProperty(key)) {110Object.keys(config[key]).forEach(k => config[k] = config[key][k]);111}112113delete config.windows;114delete config.osx;115delete config.linux;116}117118private parseVariable(str: string, start: number): { replacement: Replacement; end: number } | undefined {119if (str[start] !== '$' || str[start + 1] !== '{') {120return undefined;121}122123let end = start + 2;124let braceCount = 1;125while (end < str.length) {126if (str[end] === '{') {127braceCount++;128} else if (str[end] === '}') {129braceCount--;130if (braceCount === 0) {131break;132}133}134end++;135}136137if (braceCount !== 0) {138return undefined;139}140141const id = str.slice(start, end + 1);142const inner = str.substring(start + 2, end);143const colonIdx = inner.indexOf(':');144if (colonIdx === -1) {145return { replacement: { id, name: inner, inner }, end };146}147148return {149replacement: {150id,151inner,152name: inner.slice(0, colonIdx),153arg: inner.slice(colonIdx + 1)154},155end156};157}158159private parseObject(obj: any): void {160if (typeof obj !== 'object' || obj === null) {161return;162}163164if (Array.isArray(obj)) {165for (let i = 0; i < obj.length; i++) {166const value = obj[i];167if (typeof value === 'string') {168this.parseString(obj, i, value);169} else {170this.parseObject(value);171}172}173return;174}175176for (const [key, value] of Object.entries(obj)) {177this.parseString(obj, key, key, true); // parse key178179if (typeof value === 'string') {180this.parseString(obj, key, value);181} else {182this.parseObject(value);183}184}185}186187private parseString(object: any, propertyName: string | number, value: string, replaceKeyName?: boolean, replacementPath?: string[]): void {188let pos = 0;189while (pos < value.length) {190const match = value.indexOf('${', pos);191if (match === -1) {192break;193}194const parsed = this.parseVariable(value, match);195if (parsed) {196pos = parsed.end + 1;197if (replacementPath?.includes(parsed.replacement.id)) {198continue;199}200201const locations = this.locations.get(parsed.replacement.id) || { locations: [], replacement: parsed.replacement };202const newLocation: PropertyLocation = { object, propertyName, replaceKeyName };203locations.locations.push(newLocation);204this.locations.set(parsed.replacement.id, locations);205206if (locations.resolved) {207this._resolveAtLocation(parsed.replacement, newLocation, locations.resolved, replacementPath);208} else {209this.newReplacementNotifiers.forEach(n => n(parsed.replacement));210}211} else {212pos = match + 2;213}214}215}216217public *unresolved(): Iterable<Replacement> {218const newReplacements = new Map<string, Replacement>();219const notifier = (replacement: Replacement) => {220newReplacements.set(replacement.id, replacement);221};222223for (const location of this.locations.values()) {224if (location.resolved === undefined) {225newReplacements.set(location.replacement.id, location.replacement);226}227}228229this.newReplacementNotifiers.add(notifier);230231while (true) {232const next = Iterable.first(newReplacements);233if (!next) {234break;235}236237const [key, value] = next;238yield value;239newReplacements.delete(key);240}241242this.newReplacementNotifiers.delete(notifier);243}244245public resolved(): Iterable<[Replacement, IResolvedValue]> {246return Iterable.map(Iterable.filter(this.locations.values(), l => !!l.resolved), l => [l.replacement, l.resolved!]);247}248249public resolve(replacement: Replacement, data: string | IResolvedValue): void {250if (typeof data !== 'object') {251data = { value: String(data) };252}253254const location = this.locations.get(replacement.id);255if (!location) {256return;257}258259location.resolved = data;260261if (data.value !== undefined) {262for (const l of location.locations || Iterable.empty()) {263this._resolveAtLocation(replacement, l, data);264}265}266}267268private _resolveAtLocation(replacement: Replacement, { replaceKeyName, propertyName, object }: PropertyLocation, data: IResolvedValue, path: string[] = []) {269if (data.value === undefined) {270return;271}272273// avoid recursive resolution, e.g. ${env:FOO} -> ${env:BAR}=${env:FOO}274path.push(replacement.id);275276// note: in nested `this.parseString`, parse only the new substring for any replacements, don't reparse the whole string277if (replaceKeyName && typeof propertyName === 'string') {278const value = object[propertyName];279const newKey = propertyName.replaceAll(replacement.id, data.value);280delete object[propertyName];281object[newKey] = value;282this._renameKeyInLocations(object, propertyName, newKey);283this.parseString(object, newKey, data.value, true, path);284} else {285object[propertyName] = object[propertyName].replaceAll(replacement.id, data.value);286this.parseString(object, propertyName, data.value, false, path);287}288289path.pop();290}291292private _renameKeyInLocations(obj: object, oldKey: string, newKey: string) {293for (const location of this.locations.values()) {294for (const loc of location.locations) {295if (loc.object === obj && loc.propertyName === oldKey) {296loc.propertyName = newKey;297}298}299}300}301302public toObject(): T {303// If we wrapped a string, unwrap it304if (this.stringRoot) {305// eslint-disable-next-line local/code-no-any-casts306return (this.root as any).value as T;307}308309return this.root;310}311}312313314