/** * `when` mini-language evaluator (ticket #43, carnet §7.2) — deliberately * limited, evaluated by IdeA itself (never handed to the plugin as code): * boolean variables (`projectOpen`, `gitRepository`, `agentSelected`, * `terminalFocused`, `layoutCellFocused`), `&&`/`||`/`!`, and parentheses. * * An invalid expression never throws past this module: the contribution using * it is disabled with a diagnostic reason instead (carnet: "Toute expression * invalide => contribution disabled + raison diagnostic, pas crash"). */ export type WhenVariable = | "projectOpen" | "gitRepository" | "agentSelected" | "terminalFocused" | "layoutCellFocused"; const VARIABLES: ReadonlySet = new Set([ "projectOpen", "gitRepository", "agentSelected", "terminalFocused", "layoutCellFocused", ]); export type WhenContext = Record; export type WhenEvalResult = | { ok: true; value: boolean } | { ok: false; reason: string }; type Token = | { kind: "var"; name: string } | { kind: "and" | "or" | "not" | "lparen" | "rparen" }; function tokenize(expr: string): Token[] | null { const tokens: Token[] = []; let i = 0; while (i < expr.length) { const ch = expr[i]; if (ch === " " || ch === "\t") { i += 1; continue; } if (ch === "(") { tokens.push({ kind: "lparen" }); i += 1; continue; } if (ch === ")") { tokens.push({ kind: "rparen" }); i += 1; continue; } if (ch === "!") { tokens.push({ kind: "not" }); i += 1; continue; } if (expr.startsWith("&&", i)) { tokens.push({ kind: "and" }); i += 2; continue; } if (expr.startsWith("||", i)) { tokens.push({ kind: "or" }); i += 2; continue; } const match = /^[A-Za-z][A-Za-z0-9]*/.exec(expr.slice(i)); if (match) { tokens.push({ kind: "var", name: match[0] }); i += match[0].length; continue; } return null; } return tokens; } /** Recursive-descent parser/evaluator, precedence: `!` > `&&` > `||`. */ class Parser { private pos = 0; constructor(private readonly tokens: Token[]) {} private peek(): Token | undefined { return this.tokens[this.pos]; } private next(): Token | undefined { return this.tokens[this.pos++]; } parseOr(ctx: WhenContext): boolean { let value = this.parseAnd(ctx); while (this.peek()?.kind === "or") { this.next(); const rhs = this.parseAnd(ctx); value = value || rhs; } return value; } private parseAnd(ctx: WhenContext): boolean { let value = this.parseUnary(ctx); while (this.peek()?.kind === "and") { this.next(); const rhs = this.parseUnary(ctx); value = value && rhs; } return value; } private parseUnary(ctx: WhenContext): boolean { if (this.peek()?.kind === "not") { this.next(); return !this.parseUnary(ctx); } return this.parseAtom(ctx); } private parseAtom(ctx: WhenContext): boolean { const tok = this.next(); if (!tok) throw new Error("unexpected end of expression"); if (tok.kind === "lparen") { const value = this.parseOr(ctx); const close = this.next(); if (close?.kind !== "rparen") throw new Error("expected \")\""); return value; } if (tok.kind === "var") { if (!VARIABLES.has(tok.name)) throw new Error(`unknown variable "${tok.name}"`); return ctx[tok.name as WhenVariable]; } throw new Error("expected a variable, \"!\" or \"(\""); } finished(): boolean { return this.pos >= this.tokens.length; } } /** Evaluates a `when` expression; `undefined` means "no condition" ⇒ always true. */ export function evaluateWhen(expr: string | undefined, ctx: WhenContext): WhenEvalResult { if (expr === undefined || expr.trim() === "") return { ok: true, value: true }; const tokens = tokenize(expr); if (!tokens) return { ok: false, reason: `invalid "when" expression: ${expr}` }; try { const parser = new Parser(tokens); const value = parser.parseOr(ctx); if (!parser.finished()) throw new Error("unexpected trailing tokens"); return { ok: true, value }; } catch (e) { const reason = e instanceof Error ? e.message : String(e); return { ok: false, reason: `invalid "when" expression "${expr}": ${reason}` }; } }