feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
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>
This commit is contained in:
158
frontend/src/plugins/runtime/when.ts
Normal file
158
frontend/src/plugins/runtime/when.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `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}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user