import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit"; import { isArray } from "es-toolkit/compat"; import type { ConfigValidationIssue } from "./schema/issues"; import type { VariableValue } from "./types"; import { issue, joinPath } from "./schema/issues"; const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g; const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/; const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g; interface VariableReference { defaultValue?: string; key: string; } interface VariableResolutionIssueContext { path: string; targetId?: string; targetName?: string; } export function extractVariables(config: unknown): { issues: ConfigValidationIssue[]; variables: Map; } { const issues: ConfigValidationIssue[] = []; const variables = new Map(); if (!isPlainObject(config)) { return { issues, variables }; } const configRecord = config as Record; if (configRecord["variables"] === undefined) { return { issues, variables }; } const rawVariables: unknown = configRecord["variables"]; if (!isPlainObject(rawVariables)) { issues.push(issue("invalid-type", "variables", "必须为对象")); return { issues, variables }; } for (const [key, value] of Object.entries(rawVariables as Record)) { const path = joinPath("variables", key); if (!VARIABLE_NAME_PATTERN.test(key)) { issues.push(issue("invalid-format", path, "变量名不符合命名规则")); continue; } if (!isVariableValue(value)) { issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`)); continue; } variables.set(key, value); } return { issues, variables }; } export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } { const { issues, variables } = extractVariables(config); if (!isPlainObject(config)) { return { config, issues }; } const configRecord = config as Record; const rawTargets: unknown = configRecord["targets"]; if (!isArray(rawTargets)) { return { config, issues }; } const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues)); return { config: { ...config, targets }, issues }; } function describeInvalidVariableValue(value: unknown): string { if (value === null) return "null"; if (isArray(value)) return "array"; return typeof value; } function inferStringValue(value: string): VariableValue { const numberValue = Number(value); if (Number.isFinite(numberValue)) return numberValue; if (value === "true") return true; if (value === "false") return false; return value; } function isVariableValue(value: unknown): value is VariableValue { return isString(value) || isNumber(value) || isBoolean(value); } function parseVariableReference(match: RegExpExecArray): VariableReference { return { defaultValue: match[2], key: match[1]! }; } function replaceStringValue( value: string, variables: Map, issues: ConfigValidationIssue[], context: VariableResolutionIssueContext, ): string | VariableValue { const trimmed = value.trim(); const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed); if (completeMatch) { const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context); return resolved ?? value; } const escaped: string[] = []; const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => { const token = `\u0000${escaped.length}\u0000`; escaped.push(`\${${body}}`); return token; }); const replaced = protectedValue.replace( VARIABLE_REFERENCE_PATTERN, (match, key: string, defaultValue: string | undefined) => { const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context); return resolved === undefined ? match : String(resolved); }, ); return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced); } function resolveTargetVariables( target: unknown, index: number, variables: Map, issues: ConfigValidationIssue[], ): unknown { if (!isPlainObject(target)) return target; const targetRecord = target as Record; const idValue: unknown = targetRecord["id"]; const nameValue: unknown = targetRecord["name"]; const targetId = isString(idValue) ? idValue : undefined; const targetName = isString(nameValue) ? nameValue : targetId; return resolveValue(target, `targets[${index}]`, variables, issues, { path: `targets[${index}]`, targetId, targetName, }); } function resolveValue( value: unknown, path: string, variables: Map, issues: ConfigValidationIssue[], context: VariableResolutionIssueContext, ): unknown { if (isString(value)) { return replaceStringValue(value, variables, issues, { ...context, path }); } if (isArray(value)) { return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues, { ...context, path: `${path}[${index}]` }), ); } if (!isPlainObject(value)) return value; const result: Record = {}; for (const [key, item] of Object.entries(value)) { const itemPath = joinPath(path, key); result[key] = key === "id" || key === "type" ? item : resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath }); } return result; } function resolveVariableReference( reference: VariableReference, variables: Map, issues: ConfigValidationIssue[], context: VariableResolutionIssueContext, ): undefined | VariableValue { if (variables.has(reference.key)) { return variables.get(reference.key); } if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) { return inferStringValue(process.env[reference.key] ?? ""); } if (reference.defaultValue !== undefined) { return inferStringValue(reference.defaultValue); } issues.push( issue( "unresolved-variable", context.path, `引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`, context.targetName, context.targetId, ), ); return undefined; }