@wildwinter/expr-editor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ import { BinaryOp, ExprNode, AstPath, UnaryOp, PropertyType, ExpressionValidationResult, ExpressionValidationIssue, ExpressionSchema, Dialect } from '@wildwinter/expr';
2
+ export { ExpressionValidationIssue, ExpressionValidationResult, PropertyType } from '@wildwinter/expr';
3
+
4
+ declare const boolLit: (value: boolean) => ExprNode;
5
+ declare const numLit: (value: number) => ExprNode;
6
+ declare const strLit: (value: string) => ExprNode;
7
+ declare const scopedVar: (scope: string, name: string) => ExprNode;
8
+ declare const binary: (op: BinaryOp, left: ExprNode, right: ExprNode) => ExprNode;
9
+ declare const notNode: (operand: ExprNode) => ExprNode;
10
+ declare const callNode: (name: string, args: ExprNode[]) => ExprNode;
11
+ declare const flagDelta: (sign: "+" | "-", name: string) => ExprNode;
12
+ /** A no-op sentinel: `true and X === X`, `false or X === X`, so a half-filled slot is inert. */
13
+ declare const placeholderForOp: (op: "and" | "or") => ExprNode;
14
+ declare const isPlaceholderForOp: (node: ExprNode, op: "and" | "or") => boolean;
15
+ declare const isComparisonOp: (op: BinaryOp) => boolean;
16
+ /** The node at `path`, or null if any segment fails to resolve. */
17
+ declare function getNodeAt(ast: ExprNode, path: AstPath): ExprNode | null;
18
+ /** Replace the node at `path` with `next`. `path.length === 0` returns `next`. */
19
+ declare function setNodeAt(ast: ExprNode, path: AstPath, next: ExprNode): ExprNode;
20
+ /**
21
+ * Delete the node at `path`, collapsing its parent:
22
+ * - binary parent -> the surviving sibling replaces the parent (`A and B`, del B -> `A`)
23
+ * - unary parent -> the operand replaces the unary (strips the wrapper)
24
+ * - call parent -> the arg is spliced out
25
+ * Deleting the root (`path === []`) returns null (the caller clears the expression).
26
+ */
27
+ declare function deleteAt(ast: ExprNode, path: AstPath): ExprNode | null;
28
+ /** Wrap the node at `path` in a new `binary(op, …)`, the clause on `side`. */
29
+ declare function insertSiblingClauseAt(ast: ExprNode, path: AstPath, op: "and" | "or", side: "left" | "right", clause: ExprNode): ExprNode;
30
+ /** True iff the node at `path` is the operand of a `not`. */
31
+ declare function isWrappedInNot(ast: ExprNode, path: AstPath): boolean;
32
+ /** Wrap the node at `path` in `not(…)`. */
33
+ declare function wrapInNotAt(ast: ExprNode, path: AstPath): ExprNode;
34
+ /** Add a `not` if absent, strip it if present. */
35
+ declare function toggleNotAt(ast: ExprNode, path: AstPath): ExprNode;
36
+ /**
37
+ * If the node at `path` is one operand of an equality comparison (`==` / `!=`)
38
+ * whose OTHER operand is a property reference, return that property — so a string
39
+ * literal can offer the property's enum values. Null otherwise.
40
+ */
41
+ declare function findEnumPeer(ast: ExprNode, path: AstPath): {
42
+ scope: string;
43
+ name: string;
44
+ } | null;
45
+ /**
46
+ * DFS for the first unfilled slot in a template-inserted clause: an empty string
47
+ * literal (a tag / value the author still has to set) or an unnamed flag delta.
48
+ * Returns its path relative to `node`, or null when the clause is complete —
49
+ * wizard-built clauses have no empty slots, so they never auto-open anything.
50
+ */
51
+ declare function firstEmptyLeafPath(node: ExprNode, base?: AstPath): AstPath | null;
52
+
53
+ type TreeRow = {
54
+ kind: "container";
55
+ op: "and" | "or";
56
+ negated: boolean;
57
+ children: TreeRow[];
58
+ path: AstPath;
59
+ chainPath: AstPath;
60
+ } | {
61
+ kind: "comparison";
62
+ left: ExprNode;
63
+ op: BinaryOp;
64
+ right: ExprNode;
65
+ negated: boolean;
66
+ path: AstPath;
67
+ contentPath: AstPath;
68
+ } | {
69
+ kind: "wrapped";
70
+ node: ExprNode;
71
+ negated: boolean;
72
+ path: AstPath;
73
+ contentPath: AstPath;
74
+ };
75
+ /** Derive the renderable row for `node` (at `path` in the whole AST). */
76
+ declare function astToTree(node: ExprNode, path?: AstPath): TreeRow;
77
+ /** Append `clause` to the chain at `chainPath` (left-folded; inherits the chain op, default "and"). */
78
+ declare function addChildToContainer(ast: ExprNode, chainPath: AstPath, clause: ExprNode): ExprNode;
79
+ /** Flip every binary in the chain at `chainPath` from its current op to `newOp` (placeholders re-polarise). */
80
+ declare function flipContainerOp(ast: ExprNode, chainPath: AstPath, newOp: "and" | "or"): ExprNode;
81
+ /** Wrap the node at `path` in `not(…)`, or strip the `not` if it already is one. */
82
+ declare function toggleContainerNot(ast: ExprNode, path: AstPath): ExprNode;
83
+ /** A new sub-group: its op is the OPPOSITE of the parent's (same-op would just flatten away). */
84
+ declare function buildSubGroupClause(parentOp: "and" | "or", firstClause: ExprNode): ExprNode;
85
+ /**
86
+ * Walk one step up from `path` to its parent binary AND/OR; if the sibling at
87
+ * that level is a placeholder for the parent's op, return the PARENT's path so a
88
+ * downstream `deleteAt` collapses the whole half-filled sub-group rather than
89
+ * leaving the placeholder (`true`/`false`) floating up as a bare clause. Returns
90
+ * `path` unchanged otherwise. Ported from the storylets tree editor; fixes the
91
+ * "delete the last real clause of a temp OR/AND group leaves `false`" case.
92
+ */
93
+ declare function redirectDeleteForPlaceholderSibling(ast: ExprNode, path: AstPath): AstPath;
94
+ /** Move a child within its container chain (reorder). Out-of-range is a no-op. */
95
+ declare function moveChildInContainer(ast: ExprNode, chainPath: AstPath, from: number, to: number): ExprNode;
96
+
97
+ /** Human label for a binary operator (words for logical, glyphs for relational). */
98
+ declare const BINARY_LABEL: Record<BinaryOp, string>;
99
+ declare const UNARY_LABEL: Record<UnaryOp, string>;
100
+ declare const COMPARISON_OPS: BinaryOp[];
101
+ declare const ARITHMETIC_OPS: BinaryOp[];
102
+ /** The set of operators a given operator can be swapped to inline, or null if structural (and/or/not). */
103
+ declare function opSwapGroup(op: BinaryOp): BinaryOp[] | null;
104
+ /** Whether a child binary needs parentheses inside a parent binary on the given side. */
105
+ declare function needsParens(childOp: BinaryOp, parentOp: BinaryOp, side: "left" | "right"): boolean;
106
+ /** Render a number without IEEE-754 noise (integers plain; floats trimmed). */
107
+ declare function formatNumber(n: number): string;
108
+
109
+ interface CatalogueEntry {
110
+ scope: string;
111
+ /** Property name (stored lowercased to match parsed scopedvar names). */
112
+ name: string;
113
+ type: PropertyType;
114
+ enumValues?: string[];
115
+ /** Free-text description; the picker search matches it alongside the name. */
116
+ purpose?: string;
117
+ }
118
+ interface Filter {
119
+ acceptTypes?: PropertyType[];
120
+ acceptScopes?: string[];
121
+ }
122
+ /** The reference string for an entry: `@name` for the default scope, else `@scope.name`. */
123
+ declare const refOf: (e: {
124
+ scope: string;
125
+ name: string;
126
+ }, defaultScope: string) => string;
127
+ /** The label shown in the picker / on a property pill. */
128
+ declare const displayName: (e: {
129
+ scope: string;
130
+ name: string;
131
+ }, defaultScope: string) => string;
132
+ declare function filterCatalogue(entries: readonly CatalogueEntry[], filter?: Filter): CatalogueEntry[];
133
+ /** Filter by a case-insensitive query against the display name AND the purpose text. */
134
+ declare function searchCatalogue(entries: readonly CatalogueEntry[], query: string, defaultScope: string): CatalogueEntry[];
135
+ /** Group entries by scope, scopes in `scopeOrder` first (then alphabetical), names sorted within. */
136
+ declare function groupByScope(entries: readonly CatalogueEntry[], scopeOrder?: string[]): Array<{
137
+ scope: string;
138
+ entries: CatalogueEntry[];
139
+ }>;
140
+ /** Find an entry by scope + name (name compared case-insensitively). */
141
+ declare function lookup(entries: readonly CatalogueEntry[], scope: string, name: string): CatalogueEntry | null;
142
+
143
+ /** Stable string key for an AST path (for the issue index). */
144
+ declare const pathKey: (p: AstPath) => string;
145
+ interface Validation extends ExpressionValidationResult {
146
+ ast: ExprNode | null;
147
+ /** Issues keyed by `pathKey(issue.path)`. */
148
+ byPath: Map<string, ExpressionValidationIssue[]>;
149
+ /** True when the source could not even be parsed (the editor should fall back to raw text). */
150
+ unparseable: boolean;
151
+ }
152
+ declare function validateSource(src: string, schema: ExpressionSchema, dialect: Dialect): Validation;
153
+ /** Issues attached to a specific node path. */
154
+ declare const issuesAt: (byPath: Map<string, ExpressionValidationIssue[]>, path: AstPath) => ExpressionValidationIssue[];
155
+
156
+ /** One step of a declarative clause wizard: a text entry, a number entry, or an
157
+ * operator pick. The generic runner walks the steps in order (with back/cancel
158
+ * chrome) and hands the collected values to the spec's `build()`. */
159
+ type WizardStepSpec = {
160
+ kind: "string";
161
+ title: string;
162
+ caption?: string;
163
+ placeholder?: string;
164
+ } | {
165
+ kind: "number";
166
+ title: string;
167
+ caption?: string;
168
+ placeholder?: string;
169
+ initial?: number;
170
+ }
171
+ /** Operator pick; `ops` defaults to the comparison set. */
172
+ | {
173
+ kind: "op";
174
+ title: string;
175
+ ops?: BinaryOp[];
176
+ };
177
+ type WizardValue = string | number | BinaryOp;
178
+ /** A declarative multi-step wizard for a dialect function template. Lets a host
179
+ * add guided flows (e.g. tag -> operator -> threshold) without upstream code. */
180
+ interface WizardSpec {
181
+ steps: WizardStepSpec[];
182
+ /** Build the finished clause from the step values (index-aligned with `steps`). */
183
+ build(values: WizardValue[]): ExprNode;
184
+ }
185
+ /** A "+ Add condition" template — a named node the wizard inserts; args are then
186
+ * refined by clicking the resulting pills. Dialect-specific functions (e.g.
187
+ * patter's `seen` / `check_flags`) are supplied by the host as these. */
188
+ interface FunctionTemplateSpec {
189
+ name: string;
190
+ label: string;
191
+ hint?: string;
192
+ /** Shown greyed and non-pickable (e.g. `check_flags` with no flags property
193
+ * declared) so the option's existence is still discoverable. */
194
+ disabled?: boolean;
195
+ /** When set, picking this template runs a guided multi-step wizard instead of
196
+ * inserting `build()` directly: one of the named built-ins (matching the
197
+ * storylets condition editor) or a declarative `WizardSpec`. */
198
+ wizard?: "check_flags" | "random" | WizardSpec;
199
+ /** Build the node to insert (used when there is no `wizard`, e.g. seen / visits insert-then-pick). */
200
+ build(): ExprNode;
201
+ }
202
+ /** The editing context every renderer receives. */
203
+ interface EditCtx {
204
+ schema: ExpressionSchema;
205
+ dialect: Dialect;
206
+ defaultScope: string;
207
+ catalogue: CatalogueEntry[];
208
+ scopeOrder: string[];
209
+ functions: FunctionTemplateSpec[];
210
+ byPath: Map<string, ExpressionValidationIssue[]>;
211
+ /** The current root AST (never null here; the empty/always state is handled by mount). */
212
+ getAst(): ExprNode;
213
+ /** Commit a new root AST (null clears the whole expression to "always"). */
214
+ apply(next: ExprNode | null): void;
215
+ /** Open a popover anchored to `anchor`; `render(close)` builds the content. */
216
+ openPopover(anchor: HTMLElement, render: (close: () => void) => Node): void;
217
+ /** Host-provided picker for a flow-node reference arg (e.g. `seen(...)` / `visits(...)`). When set,
218
+ * the node-ref arg renders as a pill that opens this instead of a free-text field; `onPick` receives
219
+ * the chosen node id. Absent in dialects / hosts that have no node catalogue. */
220
+ pickNode?(anchor: HTMLElement, current: string, onPick: (id: string) => void): void;
221
+ /** Resolve a node id to its readable label for the node-ref pill (falls back to the raw id). */
222
+ nodeLabel?(id: string): string;
223
+ /** Ask the mount to auto-open the micro-editor of the pill at `path` after the
224
+ * next render — used by insert-then-refine templates so the author lands
225
+ * straight in the first unfilled slot instead of chasing the error ring. */
226
+ requestFocus?(path: AstPath): void;
227
+ }
228
+
229
+ interface ExpressionEditorOptions {
230
+ /** Current expression in name-form (`@gold > 0 and @met`); "" = always / empty. */
231
+ value: string;
232
+ schema: ExpressionSchema;
233
+ dialect: Dialect;
234
+ /** Properties the picker offers. */
235
+ catalogue: CatalogueEntry[];
236
+ /** Scope display order for the picker groups. */
237
+ scopeOrder?: string[];
238
+ /** Dialect-specific clause templates (beyond the generic property comparisons). */
239
+ functions?: FunctionTemplateSpec[];
240
+ /** "tree" (default) for conditions, "flat" for a single inline expression. */
241
+ mode?: "tree" | "flat";
242
+ /** Flat mode only: show an optional "+ term" affordance that extends the value with one more term -
243
+ * type-led: "arithmetic" extends a number (`5` → `5 + @bonus`); "boolean" extends a true/false value
244
+ * with a logical term (`true` → `true and @met`). Omit for text / enum (no meaningful extension). */
245
+ addTerm?: "arithmetic" | "boolean";
246
+ /** Label for the empty/always pill (default "always"). */
247
+ nullLabel?: string;
248
+ /** Host picker for flow-node reference args (seen/visits); renders those args as a node pill. */
249
+ pickNode?: EditCtx["pickNode"];
250
+ /** Resolve a node id to a readable label for node-ref pills. */
251
+ nodeLabel?: EditCtx["nodeLabel"];
252
+ /** Start in raw-text mode (the host's global "show as text" toggle drives this; no inline `</>`). */
253
+ text?: boolean;
254
+ /** Render the editor's own validation message list under the pills (default true).
255
+ * Hosts that display their own validation messages pass false to avoid doubling up. */
256
+ messages?: boolean;
257
+ /** Emitted on every edit (name-form; "" when cleared). */
258
+ onChange: (src: string) => void;
259
+ /** Notified when the author starts (true) / stops (false) editing inside a popover
260
+ * micro-editor — lets the host suppress its own validation display mid-edit. */
261
+ onEditingChange?: (editing: boolean) => void;
262
+ }
263
+ interface ExpressionEditorHandle {
264
+ setValue(v: string): void;
265
+ /** Flip between the pill view and the raw-text view (driven by the host's global toggle). */
266
+ setText(on: boolean): void;
267
+ destroy(): void;
268
+ }
269
+ declare function mountExpressionEditor(host: HTMLElement, opts: ExpressionEditorOptions): ExpressionEditorHandle;
270
+
271
+ /** One effect in name-form: a property assignment or a host event. Mirrors the
272
+ * host's own Effect model (patter's `set` / `emit`) so it round-trips verbatim. */
273
+ type EditorEffect = {
274
+ kind: "set";
275
+ target: string;
276
+ value: string;
277
+ } | {
278
+ kind: "emit";
279
+ event: string;
280
+ args: string[];
281
+ };
282
+ interface EffectsEditorOptions {
283
+ /** The current effect list (edited in place via onChange). */
284
+ effects: EditorEffect[];
285
+ schema: ExpressionSchema;
286
+ dialect: Dialect;
287
+ /** Properties the target picker + value editors offer. */
288
+ catalogue: CatalogueEntry[];
289
+ scopeOrder?: string[];
290
+ /** Dialect clause templates passed through to each value editor. */
291
+ functions?: FunctionTemplateSpec[];
292
+ /** Known host event names to suggest when adding an `emit` (optional). */
293
+ events?: string[];
294
+ /** Offer the "+ emit event" affordance (default true). A host whose effects are SET-ONLY (e.g.
295
+ * patter, where host events ride on gameData, not effects) passes false to hide it entirely. */
296
+ allowEmit?: boolean;
297
+ /** Start each inline value editor in raw-text mode (host's global "show as text" toggle drives this). */
298
+ text?: boolean;
299
+ /** Emitted on every structural / value edit with the whole new list. */
300
+ onChange: (effects: EditorEffect[]) => void;
301
+ }
302
+ interface EffectsEditorHandle {
303
+ setValue(effects: EditorEffect[]): void;
304
+ /** Flip every inline value editor between pills and raw text (host's global toggle). */
305
+ setText(on: boolean): void;
306
+ destroy(): void;
307
+ }
308
+ declare const addSet: (list: EditorEffect[], target: string, value: string) => EditorEffect[];
309
+ declare const addEmit: (list: EditorEffect[], event: string) => EditorEffect[];
310
+ declare const removeAt: (list: EditorEffect[], i: number) => EditorEffect[];
311
+ declare function moveAt(list: EditorEffect[], i: number, dir: -1 | 1): EditorEffect[];
312
+ declare function updateAt(list: EditorEffect[], i: number, patch: Partial<EditorEffect>): EditorEffect[];
313
+ declare function setArgAt(list: EditorEffect[], i: number, argIdx: number, value: string): EditorEffect[];
314
+ declare function addArg(list: EditorEffect[], i: number, value?: string): EditorEffect[];
315
+ declare function removeArgAt(list: EditorEffect[], i: number, argIdx: number): EditorEffect[];
316
+ /** A sensible starting value-expression for a freshly targeted property. Literals
317
+ * are seeded so the value editor opens on an editable pill, never the empty state. */
318
+ declare function seedValueSrc(type: PropertyType, enumValues?: string[]): string;
319
+ declare function mountEffectsEditor(host: HTMLElement, opts: EffectsEditorOptions): EffectsEditorHandle;
320
+
321
+ interface PreviewOptions {
322
+ schema: ExpressionSchema;
323
+ dialect: Dialect;
324
+ catalogue: CatalogueEntry[];
325
+ scopeOrder?: string[];
326
+ /** Resolve a node id to a readable label for seen()/visits() node pills. */
327
+ nodeLabel?: (id: string) => string;
328
+ }
329
+ /** Read-only pill strip for a condition (name-form). Empty/unparseable is the caller's concern; a
330
+ * non-empty unparseable string falls back to its raw text. */
331
+ declare function renderConditionPreview(src: string, o: PreviewOptions): HTMLElement;
332
+ /** Read-only pill strip for an effects list: each `set` as `target = value`, each `emit` as
333
+ * `emit event(args…)`, one per line. */
334
+ declare function renderEffectsPreview(effects: EditorEffect[], o: PreviewOptions): HTMLElement;
335
+
336
+ interface ValueWizardOptions {
337
+ catalogue: CatalogueEntry[];
338
+ scopeOrder: string[];
339
+ defaultScope: string;
340
+ /** When known (a `set` target's declared type), the picker leads straight to that input. */
341
+ expectedType?: PropertyType;
342
+ expectedEnumValues?: string[];
343
+ /** Receives the chosen value as name-form source. */
344
+ onCommit: (src: string) => void;
345
+ /** Optional cancel (the ✕ on the step). */
346
+ onCancel?: () => void;
347
+ }
348
+ /** Build the wizard UI into a fresh element (drive it via the host's popover). */
349
+ declare function valueWizard(opts: ValueWizardOptions): HTMLElement;
350
+
351
+ export { ARITHMETIC_OPS, BINARY_LABEL, COMPARISON_OPS, type CatalogueEntry, type EditCtx, type EditorEffect, type EffectsEditorHandle, type EffectsEditorOptions, type ExpressionEditorHandle, type ExpressionEditorOptions, type Filter, type FunctionTemplateSpec, type PreviewOptions, type TreeRow, UNARY_LABEL, type Validation, type ValueWizardOptions, type WizardSpec, type WizardStepSpec, type WizardValue, addArg, addChildToContainer, addEmit, addSet, astToTree, binary, boolLit, buildSubGroupClause, callNode, deleteAt, displayName, filterCatalogue, findEnumPeer, firstEmptyLeafPath, flagDelta, flipContainerOp, formatNumber, getNodeAt, groupByScope, insertSiblingClauseAt, isComparisonOp, isPlaceholderForOp, isWrappedInNot, issuesAt, lookup, mountEffectsEditor, mountExpressionEditor, moveAt, moveChildInContainer, needsParens, notNode, numLit, opSwapGroup, pathKey, placeholderForOp, redirectDeleteForPlaceholderSibling, refOf, removeArgAt, removeAt, renderConditionPreview, renderEffectsPreview, scopedVar, searchCatalogue, seedValueSrc, setArgAt, setNodeAt, strLit, toggleContainerNot, toggleNotAt, updateAt, validateSource, valueWizard, wrapInNotAt };