Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
159 lines
4.3 KiB
TypeScript
159 lines
4.3 KiB
TypeScript
/**
|
|
* `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<string> = new Set([
|
|
"projectOpen",
|
|
"gitRepository",
|
|
"agentSelected",
|
|
"terminalFocused",
|
|
"layoutCellFocused",
|
|
]);
|
|
|
|
export type WhenContext = Record<WhenVariable, boolean>;
|
|
|
|
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}` };
|
|
}
|
|
}
|