import { evaluate, stringify } from "./expressions"; import type { ExprContext } from "./expressions"; /** A piece of interpolated text; non-text segments are resolved by the job script at runtime. */ export type Segment = | { kind: "text"; value: string } | { kind: "secret"; name: string } | { kind: "step"; variable: string } | { kind: "hash"; patterns: string[] }; const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g; const SECRET_REFERENCE = /^\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*$/; const STEP_REFERENCE = /^\s*steps\.([A-Za-z_][A-Za-z0-9_-]*)\.(?:outputs\.([A-Za-z_][A-Za-z0-9_-]*)|(outcome|conclusion))\s*$/; const HASH_FILES = /^\s*hashFiles\(([\s\S]*)\)\s*$/i; const QUOTED = /'((?:[^']|'')*)'/g; export function shellName(value: string): string { return value.replace(/[^A-Za-z0-9_]/g, "_"); } /** Shell variable holding a step output (`field` is an output name) or result (`outcome`/`conclusion`). */ export function stepVariable(stepId: string, field: string, kind: "output" | "result"): string { return kind === "output" ? `GITFOR_STEPOUT_${shellName(stepId)}__${shellName(field)}` : `GITFOR_STEP${field.toUpperCase()}_${shellName(stepId)}`; } function special(expression: string): Segment | null { const secret = SECRET_REFERENCE.exec(expression); if (secret) return { kind: "secret", name: secret[1] }; const step = STEP_REFERENCE.exec(expression); if (step) { return { kind: "step", variable: step[2] ? stepVariable(step[1], step[2], "output") : stepVariable(step[1], step[3] ?? "", "result"), }; } const hash = HASH_FILES.exec(expression); if (hash) { const patterns = [...(hash[1] ?? "").matchAll(QUOTED)].map((match) => match[1].replace(/''/g, "'")); if (patterns.length === 0) throw new Error("hashFiles() needs at least one quoted pattern"); return { kind: "hash", patterns }; } return null; } export function interpolate(source: string, context: ExprContext): Segment[] { const segments: Segment[] = []; let cursor = 0; for (const match of source.matchAll(EXPRESSION)) { const index = match.index ?? 0; if (index > cursor) segments.push({ kind: "text", value: source.slice(cursor, index) }); const expression = match[1] ?? ""; segments.push(special(expression) ?? { kind: "text", value: stringify(evaluate(expression, context)) }); cursor = index + match[0].length; } if (cursor < source.length) segments.push({ kind: "text", value: source.slice(cursor) }); return segments; } export function plainText(segments: Segment[]): string { return segments .map((segment) => (segment.kind === "text" ? segment.value : segment.kind === "secret" ? "***" : "")) .join(""); } function quotedPattern(pattern: string): string { return `'${pattern.replace(/'/g, "'\\''")}'`; } /** Renders a segment as a shell expansion valid both bare and inside double quotes. */ export function runtimeExpansion(segment: Exclude): string { switch (segment.kind) { case "secret": return `\${GITFOR_SECRET_${segment.name}}`; case "step": return `\${${segment.variable}:-}`; case "hash": return `$(__gitfor_hash_files ${segment.patterns.map(quotedPattern).join(" ")})`; } } export function scriptText(segments: Segment[]): string { return segments.map((segment) => (segment.kind === "text" ? segment.value : runtimeExpansion(segment))).join(""); }