@checkstack/template-engine 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.
- package/CHANGELOG.md +114 -0
- package/package.json +30 -0
- package/src/__tests__/parser.test.ts +74 -0
- package/src/__tests__/renderer.test.ts +183 -0
- package/src/ast.ts +101 -0
- package/src/errors.ts +74 -0
- package/src/filters.ts +145 -0
- package/src/index.ts +6 -0
- package/src/parser.ts +340 -0
- package/src/renderer.ts +274 -0
- package/src/tokenizer.ts +338 -0
- package/src/types.ts +83 -0
- package/tsconfig.json +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @checkstack/template-engine
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 41c77f4: feat(automation): Phase 11 — editor primitives + context type generation
|
|
8
|
+
|
|
9
|
+
Lays the UI + type-generation groundwork for Phase 12's visual automation
|
|
10
|
+
editor. Every primitive reuses the existing Monaco wrapper / template
|
|
11
|
+
engine / `jsonSchemaToTypeScript` helper rather than building parallel
|
|
12
|
+
infrastructure.
|
|
13
|
+
|
|
14
|
+
**`@checkstack/automation-common` — `resolveVariableScope`**
|
|
15
|
+
|
|
16
|
+
Pure walker that returns the in-scope `{{ … }}` paths at a given action
|
|
17
|
+
position. Conservative scoping rules: linear-upstream variables /
|
|
18
|
+
artifacts only (no leaking across `choose` / `parallel` / `repeat`
|
|
19
|
+
branches), `repeat.index` / `repeat.item` exposed only inside a `repeat`,
|
|
20
|
+
and trigger.payload modelled as a **discriminated union over
|
|
21
|
+
`trigger.event`** — every payload field surfaces; ones that come from a
|
|
22
|
+
subset of subscribed triggers carry a `conditionalOnTriggers` annotation
|
|
23
|
+
so the picker can render an "Only when …" hint. Earlier draft used
|
|
24
|
+
schema-intersection; switched to discriminated unions per review
|
|
25
|
+
feedback so Monaco can narrow correctly inside event-gated branches.
|
|
26
|
+
|
|
27
|
+
**Condition-aware narrowing.** When the path descends through a
|
|
28
|
+
`choose-when`, the resolver parses the branch's `when:` expression and
|
|
29
|
+
statically pins `trigger.event` to the set the condition allows —
|
|
30
|
+
patterns covered are `trigger.event == "X"` (either operand order),
|
|
31
|
+
`trigger.event != "X"`, `||`/`&&` of those, and `{ and: [...] }` /
|
|
32
|
+
`{ or: [...] }` combinators. So an action inside
|
|
33
|
+
`when: 'trigger.event == "incident.created"'` sees only the
|
|
34
|
+
`incident.created` variant in scope, the `conditionalOnTriggers`
|
|
35
|
+
annotation disappears, and other-trigger fields drop out entirely.
|
|
36
|
+
Nested choose branches compound (intersection). Anything outside the
|
|
37
|
+
covered patterns falls back to the full union — better to show every
|
|
38
|
+
field than guess wrong.
|
|
39
|
+
|
|
40
|
+
**`@checkstack/template-engine`**
|
|
41
|
+
|
|
42
|
+
The expression AST (`Expr`, `BinaryExpr`, `MemberExpr`, etc.) is now a
|
|
43
|
+
public export — the resolver's condition-narrowing walker needs to
|
|
44
|
+
inspect parsed condition trees. `ParsedCondition.root` is tightened
|
|
45
|
+
from `unknown` to `Expr` so consumers don't need to cast.
|
|
46
|
+
|
|
47
|
+
**`@checkstack/automation-frontend` — `generateAutomationContextTypes`**
|
|
48
|
+
|
|
49
|
+
Consumes `resolveVariableScope`'s output + the trigger / artifact
|
|
50
|
+
registries and emits the `declare const context: { … }` TS declaration
|
|
51
|
+
that `integration-script.run_script`'s Monaco editor injects via
|
|
52
|
+
`addExtraLib`. The emitted shape:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type AutomationTrigger =
|
|
56
|
+
| { event: "incident.created"; payload: { … } }
|
|
57
|
+
| { event: "incident.resolved"; payload: { … } };
|
|
58
|
+
|
|
59
|
+
declare const context: {
|
|
60
|
+
trigger: AutomationTrigger;
|
|
61
|
+
artifacts: { "jira.issue"?: { key: string; … }; … };
|
|
62
|
+
var: { foo?: string; … };
|
|
63
|
+
repeat: { index: number; item: unknown }; // only when inside a repeat
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`jsonSchemaToTypeScript` from `@checkstack/ui` is reused via a deep
|
|
68
|
+
import (rather than the barrel) so the bun test runner doesn't try to
|
|
69
|
+
load Monaco's Vite-only `?worker` modules during unit tests.
|
|
70
|
+
|
|
71
|
+
**`@checkstack/ui` — new editor primitives**
|
|
72
|
+
|
|
73
|
+
- `TemplateValueInput` — single-line `{{ }}` autocomplete input.
|
|
74
|
+
Extracted from `DynamicForm/KeyValueEditor`'s previously-private
|
|
75
|
+
`TemplateInput` so other editor surfaces can share it without
|
|
76
|
+
rebuilding the picker UX. `KeyValueEditor` is now a one-line
|
|
77
|
+
delegation; `detectTemplateContext` is also exported.
|
|
78
|
+
- `VariablePicker` — hierarchical popover for the explicit "fx" /
|
|
79
|
+
"Insert variable" workflow. Renders a filterable tree of
|
|
80
|
+
`VariableNode`s with type chips and `Only when …` hints sourced from
|
|
81
|
+
the resolver's `conditionalOnTriggers`. Defaults to a small "fx" pill
|
|
82
|
+
trigger; callers can pass a custom one.
|
|
83
|
+
- `TemplateInput` — high-level mode switcher: `text` mode delegates to
|
|
84
|
+
`TemplateValueInput`, all other modes (`code` / `bash` / `json` /
|
|
85
|
+
`yaml`) delegate to `CodeEditor` with the matching language so the
|
|
86
|
+
action editor can swap widgets purely from the action's
|
|
87
|
+
`x-editor-types` annotation without touching the consuming code.
|
|
88
|
+
- `TemplateInputToggle` — the small "fx" pill that flips a typed input
|
|
89
|
+
(number / select / date / …) into template mode and back. Auto-infers
|
|
90
|
+
template mode when the saved value already starts with `{{`, so
|
|
91
|
+
round-tripping a previously-templated automation works out of the
|
|
92
|
+
box. Render-prop API for the typed editor so consumers keep control
|
|
93
|
+
over their own input shape.
|
|
94
|
+
- `ActionCard` — collapsible card that hosts a single action in the
|
|
95
|
+
visual editor. Decoupled from `DynamicForm` so container blocks
|
|
96
|
+
(`ChooseBlock` / `ParallelBlock` / `RepeatBlock` in Phase 12) can use
|
|
97
|
+
it as a structural shell over their own children. Toggle / delete /
|
|
98
|
+
drag handle are conditionally rendered on their callback's presence.
|
|
99
|
+
|
|
100
|
+
Storybook stories shipped for each of the new primitives.
|
|
101
|
+
|
|
102
|
+
**`@checkstack/integration-script-backend`**
|
|
103
|
+
|
|
104
|
+
`ScriptContext` docstring and the `scriptRunConfigSchema.script` field
|
|
105
|
+
description now point at `generateAutomationContextTypes` so the Phase
|
|
106
|
+
12 editor wiring is unambiguous — the runtime payload type stays
|
|
107
|
+
`Record<string, unknown>` (the runner can't know the trigger schema),
|
|
108
|
+
but the **editor** narrows it per-automation from the subscribed
|
|
109
|
+
triggers' payload schemas.
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- Updated dependencies [6d52276]
|
|
114
|
+
- @checkstack/common@0.12.0
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/template-engine",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@checkstack/common": "0.11.0",
|
|
14
|
+
"zod": "^4.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@checkstack/scripts": "0.3.3",
|
|
18
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
19
|
+
"typescript": "^5.7.2"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "tsgo -b",
|
|
23
|
+
"lint": "bun run lint:code",
|
|
24
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
25
|
+
"test": "bun test"
|
|
26
|
+
},
|
|
27
|
+
"checkstack": {
|
|
28
|
+
"type": "common"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseCondition, parseTemplate } from "../parser";
|
|
3
|
+
import { TemplateParseError } from "../errors";
|
|
4
|
+
|
|
5
|
+
describe("parseTemplate", () => {
|
|
6
|
+
it("parses pure literal text", () => {
|
|
7
|
+
const t = parseTemplate("hello world");
|
|
8
|
+
expect(t.nodes).toHaveLength(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("parses a single expression", () => {
|
|
12
|
+
const t = parseTemplate("{{ foo.bar }}");
|
|
13
|
+
expect(t.nodes).toHaveLength(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses interleaved text and expressions", () => {
|
|
17
|
+
const t = parseTemplate("Hello {{ name }}, you have {{ count }} messages.");
|
|
18
|
+
expect(t.nodes).toHaveLength(5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects unterminated expression", () => {
|
|
22
|
+
expect(() => parseTemplate("hello {{ foo")).toThrow(TemplateParseError);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects unterminated string", () => {
|
|
26
|
+
expect(() => parseTemplate('{{ "unterminated }}')).toThrow(
|
|
27
|
+
TemplateParseError,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles escape sequences in strings", () => {
|
|
32
|
+
const t = parseTemplate('{{ "line1\\nline2" }}');
|
|
33
|
+
expect(t.nodes).toHaveLength(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("parses string-literal index access with hyphen and dot in the key", () => {
|
|
37
|
+
const t = parseTemplate(
|
|
38
|
+
'{{ artifacts["integration-jira.issue"].issueKey }}',
|
|
39
|
+
);
|
|
40
|
+
expect(t.nodes).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects unbracketed hyphenated member access", () => {
|
|
44
|
+
expect(() =>
|
|
45
|
+
parseTemplate("{{ artifacts.integration-jira.issue }}"),
|
|
46
|
+
).toThrow(TemplateParseError);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("parseCondition", () => {
|
|
51
|
+
it("parses a simple equality", () => {
|
|
52
|
+
const c = parseCondition("trigger.eventId == 'incident.created'");
|
|
53
|
+
expect(c.source).toBe("trigger.eventId == 'incident.created'");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("parses logical expressions", () => {
|
|
57
|
+
const c = parseCondition("a && b || c");
|
|
58
|
+
expect(c).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("parses ternaries", () => {
|
|
62
|
+
const c = parseCondition("severity == 'critical' ? 'high' : 'normal'");
|
|
63
|
+
expect(c).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("parses pipes with filter calls", () => {
|
|
67
|
+
const c = parseCondition("title | truncate(40) | upper");
|
|
68
|
+
expect(c).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects malformed input", () => {
|
|
72
|
+
expect(() => parseCondition("foo ==")).toThrow(TemplateParseError);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseCondition, parseTemplate } from "../parser";
|
|
3
|
+
import {
|
|
4
|
+
evaluate,
|
|
5
|
+
evaluateBoolean,
|
|
6
|
+
render,
|
|
7
|
+
} from "../renderer";
|
|
8
|
+
import { TemplateRenderError, UnknownFilterError } from "../errors";
|
|
9
|
+
|
|
10
|
+
describe("render", () => {
|
|
11
|
+
it("renders literal text unchanged", () => {
|
|
12
|
+
expect(render(parseTemplate("hello"), {})).toBe("hello");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("substitutes simple identifiers", () => {
|
|
16
|
+
const out = render(parseTemplate("Hello {{ name }}!"), {
|
|
17
|
+
name: "Nico",
|
|
18
|
+
});
|
|
19
|
+
expect(out).toBe("Hello Nico!");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("resolves nested member access", () => {
|
|
23
|
+
const out = render(parseTemplate("{{ trigger.payload.title }}"), {
|
|
24
|
+
trigger: { payload: { title: "Database down" } },
|
|
25
|
+
});
|
|
26
|
+
expect(out).toBe("Database down");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("resolves array index access", () => {
|
|
30
|
+
const out = render(parseTemplate("{{ items[0] }}"), {
|
|
31
|
+
items: ["first", "second"],
|
|
32
|
+
});
|
|
33
|
+
expect(out).toBe("first");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves a numeric array index after a bracket-key chain", () => {
|
|
37
|
+
const out = render(
|
|
38
|
+
parseTemplate('{{ artifacts["x"].tags[0] }}'),
|
|
39
|
+
{ artifacts: { x: { tags: ["first", "second"] } } },
|
|
40
|
+
);
|
|
41
|
+
expect(out).toBe("first");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("resolves bracket access with string", () => {
|
|
45
|
+
const out = render(parseTemplate('{{ nodes["jira-create"].issueKey }}'), {
|
|
46
|
+
nodes: { "jira-create": { issueKey: "PROJ-42" } },
|
|
47
|
+
});
|
|
48
|
+
expect(out).toBe("PROJ-42");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders empty string for missing references (non-strict)", () => {
|
|
52
|
+
const out = render(parseTemplate("Hello {{ missing }}!"), {});
|
|
53
|
+
expect(out).toBe("Hello !");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("throws in strict mode for missing references", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
render(parseTemplate("Hello {{ missing }}!"), {}, { strict: true }),
|
|
59
|
+
).toThrow(TemplateRenderError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("supports filter pipes", () => {
|
|
63
|
+
const out = render(parseTemplate("{{ title | upper }}"), {
|
|
64
|
+
title: "fire",
|
|
65
|
+
});
|
|
66
|
+
expect(out).toBe("FIRE");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("supports filter pipes with arguments", () => {
|
|
70
|
+
const out = render(parseTemplate('{{ x | default("none") }}'), {});
|
|
71
|
+
expect(out).toBe("none");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("chains multiple filters", () => {
|
|
75
|
+
const out = render(parseTemplate("{{ x | default('fallback') | upper }}"), {});
|
|
76
|
+
expect(out).toBe("FALLBACK");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("renders ternaries", () => {
|
|
80
|
+
const out = render(
|
|
81
|
+
parseTemplate("{{ severity == 'critical' ? 'PAGE' : 'log' }}"),
|
|
82
|
+
{ severity: "critical" },
|
|
83
|
+
);
|
|
84
|
+
expect(out).toBe("PAGE");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders objects as JSON", () => {
|
|
88
|
+
const out = render(parseTemplate("{{ obj }}"), {
|
|
89
|
+
obj: { a: 1, b: 2 },
|
|
90
|
+
});
|
|
91
|
+
expect(out).toBe('{"a":1,"b":2}');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("throws UnknownFilterError for missing filters", () => {
|
|
95
|
+
expect(() => render(parseTemplate("{{ x | unknown_filter }}"), { x: 1 })).toThrow(
|
|
96
|
+
UnknownFilterError,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("supports truncate filter", () => {
|
|
101
|
+
const out = render(parseTemplate("{{ msg | truncate(5) }}"), {
|
|
102
|
+
msg: "Hello World",
|
|
103
|
+
});
|
|
104
|
+
expect(out).toBe("Hello…");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("string-literal index access (frontend regression contract)", () => {
|
|
109
|
+
it("resolves a quoted index key containing a hyphen and dot", () => {
|
|
110
|
+
const out = render(
|
|
111
|
+
parseTemplate('{{ artifacts["integration-jira.issue"].issueKey }}'),
|
|
112
|
+
{ artifacts: { "integration-jira.issue": { issueKey: "ABC-1" } } },
|
|
113
|
+
);
|
|
114
|
+
expect(out).toBe("ABC-1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("resolves dotted member access on variables", () => {
|
|
118
|
+
const out = render(parseTemplate("{{ variables.foo }}"), {
|
|
119
|
+
variables: { foo: "bar" },
|
|
120
|
+
});
|
|
121
|
+
expect(out).toBe("bar");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("resolves the artifacts.<id>.<name>.<field> reference form", () => {
|
|
125
|
+
const out = render(
|
|
126
|
+
parseTemplate("{{ artifacts.create_issue.issue.issueKey }}"),
|
|
127
|
+
{
|
|
128
|
+
artifacts: {
|
|
129
|
+
create_issue: { issue: { issueKey: "PROJ-7" } },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
expect(out).toBe("PROJ-7");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("evaluate / evaluateBoolean", () => {
|
|
138
|
+
it("evaluates equality conditions", () => {
|
|
139
|
+
expect(
|
|
140
|
+
evaluateBoolean(parseCondition("a == b"), { a: 1, b: 1 }),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("evaluates string equality with quoted RHS", () => {
|
|
145
|
+
expect(
|
|
146
|
+
evaluateBoolean(
|
|
147
|
+
parseCondition("trigger.eventId == 'incident.created'"),
|
|
148
|
+
{ trigger: { eventId: "incident.created" } },
|
|
149
|
+
),
|
|
150
|
+
).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("evaluates logical AND/OR short-circuit", () => {
|
|
154
|
+
expect(evaluateBoolean(parseCondition("false && missing.foo"), {})).toBe(
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
expect(evaluateBoolean(parseCondition("true || missing.foo"), {})).toBe(
|
|
158
|
+
true,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("evaluates negation", () => {
|
|
163
|
+
expect(evaluateBoolean(parseCondition("!enabled"), { enabled: false })).toBe(
|
|
164
|
+
true,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("evaluates comparison operators", () => {
|
|
169
|
+
expect(evaluateBoolean(parseCondition("3 < 4"), {})).toBe(true);
|
|
170
|
+
expect(evaluateBoolean(parseCondition("4 >= 4"), {})).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns raw value from evaluate", () => {
|
|
174
|
+
expect(evaluate(parseCondition("severity"), { severity: "critical" })).toBe(
|
|
175
|
+
"critical",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("treats empty arrays and objects as falsy (consistent with truthiness rule)", () => {
|
|
180
|
+
expect(evaluateBoolean(parseCondition("items"), { items: [] })).toBe(false);
|
|
181
|
+
expect(evaluateBoolean(parseCondition("obj"), { obj: {} })).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/ast.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SourceRange } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AST node kinds for the expression sublanguage. Templates are made up of
|
|
5
|
+
* `TextNode` + `ExpressionNode` at the top level; expressions form their
|
|
6
|
+
* own tree of `Expr` nodes.
|
|
7
|
+
*/
|
|
8
|
+
export type TemplateNode = TextNode | ExpressionNode;
|
|
9
|
+
|
|
10
|
+
export interface TextNode {
|
|
11
|
+
kind: "text";
|
|
12
|
+
value: string;
|
|
13
|
+
range: SourceRange;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExpressionNode {
|
|
17
|
+
kind: "expression";
|
|
18
|
+
expression: Expr;
|
|
19
|
+
range: SourceRange;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Expression AST. Discriminated union over `kind`. Every node carries a
|
|
24
|
+
* source range for error reporting.
|
|
25
|
+
*/
|
|
26
|
+
export type Expr =
|
|
27
|
+
| LiteralExpr
|
|
28
|
+
| IdentifierExpr
|
|
29
|
+
| MemberExpr
|
|
30
|
+
| IndexExpr
|
|
31
|
+
| TernaryExpr
|
|
32
|
+
| BinaryExpr
|
|
33
|
+
| UnaryExpr
|
|
34
|
+
| PipeExpr;
|
|
35
|
+
|
|
36
|
+
export interface LiteralExpr {
|
|
37
|
+
kind: "literal";
|
|
38
|
+
value: string | number | boolean | null;
|
|
39
|
+
range: SourceRange;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IdentifierExpr {
|
|
43
|
+
kind: "identifier";
|
|
44
|
+
name: string;
|
|
45
|
+
range: SourceRange;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MemberExpr {
|
|
49
|
+
kind: "member";
|
|
50
|
+
object: Expr;
|
|
51
|
+
property: string;
|
|
52
|
+
range: SourceRange;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IndexExpr {
|
|
56
|
+
kind: "index";
|
|
57
|
+
object: Expr;
|
|
58
|
+
index: Expr;
|
|
59
|
+
range: SourceRange;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TernaryExpr {
|
|
63
|
+
kind: "ternary";
|
|
64
|
+
condition: Expr;
|
|
65
|
+
consequent: Expr;
|
|
66
|
+
alternate: Expr;
|
|
67
|
+
range: SourceRange;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type BinaryOp =
|
|
71
|
+
| "=="
|
|
72
|
+
| "!="
|
|
73
|
+
| "<"
|
|
74
|
+
| ">"
|
|
75
|
+
| "<="
|
|
76
|
+
| ">="
|
|
77
|
+
| "&&"
|
|
78
|
+
| "||";
|
|
79
|
+
|
|
80
|
+
export interface BinaryExpr {
|
|
81
|
+
kind: "binary";
|
|
82
|
+
op: BinaryOp;
|
|
83
|
+
left: Expr;
|
|
84
|
+
right: Expr;
|
|
85
|
+
range: SourceRange;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UnaryExpr {
|
|
89
|
+
kind: "unary";
|
|
90
|
+
op: "!";
|
|
91
|
+
operand: Expr;
|
|
92
|
+
range: SourceRange;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PipeExpr {
|
|
96
|
+
kind: "pipe";
|
|
97
|
+
value: Expr;
|
|
98
|
+
filter: string;
|
|
99
|
+
args: Expr[];
|
|
100
|
+
range: SourceRange;
|
|
101
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { SourcePosition, SourceRange } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base error for all template-engine failures. Always carries a source
|
|
5
|
+
* range so editors can render an inline marker.
|
|
6
|
+
*/
|
|
7
|
+
export class TemplateError extends Error {
|
|
8
|
+
readonly range: SourceRange;
|
|
9
|
+
readonly source: string;
|
|
10
|
+
|
|
11
|
+
constructor(args: {
|
|
12
|
+
message: string;
|
|
13
|
+
source: string;
|
|
14
|
+
range: SourceRange;
|
|
15
|
+
}) {
|
|
16
|
+
super(args.message);
|
|
17
|
+
this.name = "TemplateError";
|
|
18
|
+
this.range = args.range;
|
|
19
|
+
this.source = args.source;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TemplateParseError extends TemplateError {
|
|
24
|
+
constructor(args: {
|
|
25
|
+
message: string;
|
|
26
|
+
source: string;
|
|
27
|
+
range: SourceRange;
|
|
28
|
+
}) {
|
|
29
|
+
super(args);
|
|
30
|
+
this.name = "TemplateParseError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class TemplateRenderError extends TemplateError {
|
|
35
|
+
/** Path of access that failed, when the failure was a missing reference. */
|
|
36
|
+
readonly path?: string;
|
|
37
|
+
|
|
38
|
+
constructor(args: {
|
|
39
|
+
message: string;
|
|
40
|
+
source: string;
|
|
41
|
+
range: SourceRange;
|
|
42
|
+
path?: string;
|
|
43
|
+
}) {
|
|
44
|
+
super(args);
|
|
45
|
+
this.name = "TemplateRenderError";
|
|
46
|
+
this.path = args.path;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class UnknownFilterError extends TemplateRenderError {
|
|
51
|
+
readonly filterName: string;
|
|
52
|
+
|
|
53
|
+
constructor(args: {
|
|
54
|
+
filterName: string;
|
|
55
|
+
source: string;
|
|
56
|
+
range: SourceRange;
|
|
57
|
+
}) {
|
|
58
|
+
super({
|
|
59
|
+
message: `Unknown filter "${args.filterName}"`,
|
|
60
|
+
source: args.source,
|
|
61
|
+
range: args.range,
|
|
62
|
+
});
|
|
63
|
+
this.name = "UnknownFilterError";
|
|
64
|
+
this.filterName = args.filterName;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Construct a single-point range — useful when the parser knows where
|
|
70
|
+
* something went wrong but not where the offending construct ended.
|
|
71
|
+
*/
|
|
72
|
+
export function pointRange(pos: SourcePosition): SourceRange {
|
|
73
|
+
return { start: pos, end: pos };
|
|
74
|
+
}
|