import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit"; import type { ConfigValidationIssue } from "./issues"; import type { ConfigVariableValue } from "./types"; import { issue, joinPath } from "./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 VariableResolutionContext { path: 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 }; } return { config: resolveConfigValue(config, variables, issues), issues }; } function describeInvalidVariableValue(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } function inferStringValue(value: string): ConfigVariableValue { if (value === "") return value; 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 ConfigVariableValue { 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: VariableResolutionContext, ): ConfigVariableValue | string { 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 resolveConfigValue( value: unknown, variables: Map, issues: ConfigValidationIssue[], ): unknown { if (!isPlainObject(value)) return value; const result: Record = {}; for (const [key, item] of Object.entries(value)) { if (key === "variables") { continue; } const itemPath = joinPath("", key); result[key] = key === "server" ? resolveValue(item, itemPath, variables, issues) : item; } return result; } function resolveValue( value: unknown, path: string, variables: Map, issues: ConfigValidationIssue[], ): unknown { if (isString(value)) { return replaceStringValue(value, variables, issues, { path }); } if (Array.isArray(value)) { return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues)); } if (!isPlainObject(value)) return value; const result: Record = {}; for (const [key, item] of Object.entries(value)) { const itemPath = joinPath(path, key); result[key] = resolveValue(item, itemPath, variables, issues); } return result; } function resolveVariableReference( reference: VariableReference, variables: Map, issues: ConfigValidationIssue[], context: VariableResolutionContext, ): ConfigVariableValue | undefined { 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}",且环境变量中也不存在,未设置默认值`, ), ); return undefined; }