@aigne/ash 0.0.1
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/DESIGN.md +41 -0
- package/dist/ai-dev-loop/ash-run-result.cjs +12 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.mjs +11 -0
- package/dist/ai-dev-loop/ash-run-result.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.cjs +51 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs +50 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-validate.cjs +27 -0
- package/dist/ai-dev-loop/ash-validate.d.cts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.d.mts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.mjs +28 -0
- package/dist/ai-dev-loop/ash-validate.mjs.map +1 -0
- package/dist/ai-dev-loop/dev-loop.cjs +134 -0
- package/dist/ai-dev-loop/dev-loop.d.cts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.cts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.d.mts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.mts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.mjs +135 -0
- package/dist/ai-dev-loop/dev-loop.mjs.map +1 -0
- package/dist/ai-dev-loop/index.cjs +24 -0
- package/dist/ai-dev-loop/index.d.cts +9 -0
- package/dist/ai-dev-loop/index.d.mts +9 -0
- package/dist/ai-dev-loop/index.mjs +10 -0
- package/dist/ai-dev-loop/live-mode.cjs +17 -0
- package/dist/ai-dev-loop/live-mode.d.cts +24 -0
- package/dist/ai-dev-loop/live-mode.d.cts.map +1 -0
- package/dist/ai-dev-loop/live-mode.d.mts +24 -0
- package/dist/ai-dev-loop/live-mode.d.mts.map +1 -0
- package/dist/ai-dev-loop/live-mode.mjs +17 -0
- package/dist/ai-dev-loop/live-mode.mjs.map +1 -0
- package/dist/ai-dev-loop/meta-tools.cjs +123 -0
- package/dist/ai-dev-loop/meta-tools.d.cts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.cts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.d.mts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.mts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.mjs +120 -0
- package/dist/ai-dev-loop/meta-tools.mjs.map +1 -0
- package/dist/ai-dev-loop/structured-runner.cjs +154 -0
- package/dist/ai-dev-loop/structured-runner.d.cts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.cts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.d.mts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.mts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.mjs +155 -0
- package/dist/ai-dev-loop/structured-runner.mjs.map +1 -0
- package/dist/ai-dev-loop/system-prompt.cjs +55 -0
- package/dist/ai-dev-loop/system-prompt.d.cts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.cts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.d.mts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.mts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.mjs +54 -0
- package/dist/ai-dev-loop/system-prompt.mjs.map +1 -0
- package/dist/ast.d.cts +140 -0
- package/dist/ast.d.cts.map +1 -0
- package/dist/ast.d.mts +140 -0
- package/dist/ast.d.mts.map +1 -0
- package/dist/compiler.cjs +802 -0
- package/dist/compiler.d.cts +103 -0
- package/dist/compiler.d.cts.map +1 -0
- package/dist/compiler.d.mts +103 -0
- package/dist/compiler.d.mts.map +1 -0
- package/dist/compiler.mjs +802 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/lexer.cjs +451 -0
- package/dist/lexer.d.cts +14 -0
- package/dist/lexer.d.cts.map +1 -0
- package/dist/lexer.d.mts +14 -0
- package/dist/lexer.d.mts.map +1 -0
- package/dist/lexer.mjs +451 -0
- package/dist/lexer.mjs.map +1 -0
- package/dist/parser.cjs +734 -0
- package/dist/parser.d.cts +40 -0
- package/dist/parser.d.cts.map +1 -0
- package/dist/parser.d.mts +40 -0
- package/dist/parser.d.mts.map +1 -0
- package/dist/parser.mjs +734 -0
- package/dist/parser.mjs.map +1 -0
- package/dist/reference.cjs +130 -0
- package/dist/reference.d.cts +11 -0
- package/dist/reference.d.cts.map +1 -0
- package/dist/reference.d.mts +11 -0
- package/dist/reference.d.mts.map +1 -0
- package/dist/reference.mjs +130 -0
- package/dist/reference.mjs.map +1 -0
- package/dist/template.cjs +85 -0
- package/dist/template.mjs +84 -0
- package/dist/template.mjs.map +1 -0
- package/dist/type-checker.cjs +582 -0
- package/dist/type-checker.d.cts +31 -0
- package/dist/type-checker.d.cts.map +1 -0
- package/dist/type-checker.d.mts +31 -0
- package/dist/type-checker.d.mts.map +1 -0
- package/dist/type-checker.mjs +573 -0
- package/dist/type-checker.mjs.map +1 -0
- package/package.json +29 -0
- package/src/ai-dev-loop/ash-run-result.test.ts +113 -0
- package/src/ai-dev-loop/ash-run-result.ts +46 -0
- package/src/ai-dev-loop/ash-typed-error.test.ts +136 -0
- package/src/ai-dev-loop/ash-typed-error.ts +50 -0
- package/src/ai-dev-loop/ash-validate.test.ts +54 -0
- package/src/ai-dev-loop/ash-validate.ts +34 -0
- package/src/ai-dev-loop/dev-loop.test.ts +364 -0
- package/src/ai-dev-loop/dev-loop.ts +156 -0
- package/src/ai-dev-loop/dry-run.test.ts +107 -0
- package/src/ai-dev-loop/e2e-multi-fix.test.ts +473 -0
- package/src/ai-dev-loop/e2e.test.ts +324 -0
- package/src/ai-dev-loop/index.ts +15 -0
- package/src/ai-dev-loop/invariants.test.ts +253 -0
- package/src/ai-dev-loop/live-mode.test.ts +63 -0
- package/src/ai-dev-loop/live-mode.ts +33 -0
- package/src/ai-dev-loop/meta-tools.test.ts +120 -0
- package/src/ai-dev-loop/meta-tools.ts +142 -0
- package/src/ai-dev-loop/structured-runner.test.ts +159 -0
- package/src/ai-dev-loop/structured-runner.ts +209 -0
- package/src/ai-dev-loop/system-prompt.test.ts +102 -0
- package/src/ai-dev-loop/system-prompt.ts +81 -0
- package/src/ast.ts +186 -0
- package/src/compiler.test.ts +2933 -0
- package/src/compiler.ts +1103 -0
- package/src/e2e.test.ts +552 -0
- package/src/index.ts +16 -0
- package/src/lexer.test.ts +538 -0
- package/src/lexer.ts +222 -0
- package/src/parser.test.ts +1024 -0
- package/src/parser.ts +835 -0
- package/src/reference.test.ts +166 -0
- package/src/reference.ts +125 -0
- package/src/template.test.ts +210 -0
- package/src/template.ts +139 -0
- package/src/type-checker.test.ts +1494 -0
- package/src/type-checker.ts +785 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +12 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import type { Token, TokenType } from "./lexer.js";
|
|
2
|
+
import type { Program, JobDeclaration, PipelineStage, Annotation, WhereClause, QueryCondition, TopLevelStatement, LetStatement, CountExpression, GroupByExpression, ActionExpression, Expression, MapExpression, RouteExpression, RouteBranch, LookupExpression, ParamDeclaration, TriggerDeclaration } from "./ast.js";
|
|
3
|
+
|
|
4
|
+
/** Annotation names that are script-level: propagate to all jobs when declared before any statement. */
|
|
5
|
+
const SCRIPT_LEVEL_ANNOTATIONS = new Set(["caps", "budget"]);
|
|
6
|
+
|
|
7
|
+
/** Merge script-level annotations with job-specific ones. Job overrides script-level by name. */
|
|
8
|
+
function mergeAnnotations(script: Annotation[], job: Annotation[]): Annotation[] {
|
|
9
|
+
if (script.length === 0) return job;
|
|
10
|
+
if (job.length === 0) return [...script];
|
|
11
|
+
const jobNames = new Set(job.map(a => a.name));
|
|
12
|
+
return [...script.filter(a => !jobNames.has(a.name)), ...job];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AshParser {
|
|
16
|
+
private tokens: Token[] = [];
|
|
17
|
+
private pos = 0;
|
|
18
|
+
|
|
19
|
+
private letNames = new Set<string>();
|
|
20
|
+
private paramNames = new Set<string>();
|
|
21
|
+
private static readonly RESERVED_KEYWORDS = new Set(["job", "find", "where", "map", "save", "publish", "tee", "fanout", "input", "output", "let", "action", "route", "lookup", "param", "on"]);
|
|
22
|
+
|
|
23
|
+
parse(tokens: Token[]): Program {
|
|
24
|
+
this.tokens = tokens;
|
|
25
|
+
this.pos = 0;
|
|
26
|
+
this.letNames = new Set();
|
|
27
|
+
this.paramNames = new Set();
|
|
28
|
+
const statements: TopLevelStatement[] = [];
|
|
29
|
+
|
|
30
|
+
this.skipNewlines();
|
|
31
|
+
|
|
32
|
+
// Collect initial annotations (before any statement).
|
|
33
|
+
// Split into script-level (caps, budget → propagate to all jobs) and first-job-specific.
|
|
34
|
+
const initialAnnotations = this.parseAnnotations();
|
|
35
|
+
const scriptAnnotations = initialAnnotations.filter(a => SCRIPT_LEVEL_ANNOTATIONS.has(a.name));
|
|
36
|
+
const firstJobOnly = initialAnnotations.filter(a => !SCRIPT_LEVEL_ANNOTATIONS.has(a.name));
|
|
37
|
+
let firstStatement = true;
|
|
38
|
+
|
|
39
|
+
while (!this.isAtEnd()) {
|
|
40
|
+
const jobAnnotations = this.parseAnnotations();
|
|
41
|
+
if (this.isAtEnd()) break;
|
|
42
|
+
|
|
43
|
+
const t = this.peek();
|
|
44
|
+
if (t.type === "JOB") {
|
|
45
|
+
const extra = firstStatement ? [...firstJobOnly, ...jobAnnotations] : jobAnnotations;
|
|
46
|
+
const merged = mergeAnnotations(scriptAnnotations, extra);
|
|
47
|
+
firstStatement = false;
|
|
48
|
+
statements.push(this.parseJob(merged));
|
|
49
|
+
} else if (t.type === "OUTPUT" && jobAnnotations.length === 0) {
|
|
50
|
+
firstStatement = false;
|
|
51
|
+
this.advance(); // output
|
|
52
|
+
const msg = this.expect("STRING", "Expected string after 'output'");
|
|
53
|
+
statements.push({ kind: "output", message: msg.value });
|
|
54
|
+
} else if (t.type === "LET" && jobAnnotations.length === 0) {
|
|
55
|
+
firstStatement = false;
|
|
56
|
+
statements.push(this.parseLet());
|
|
57
|
+
} else if (t.type === "PARAM" && jobAnnotations.length === 0) {
|
|
58
|
+
firstStatement = false;
|
|
59
|
+
statements.push(this.parseParam());
|
|
60
|
+
} else {
|
|
61
|
+
throw new Error(`Expected 'job', 'output', 'let', or 'param' at top level, got ${t.type} at line ${t.line}, column ${t.column}`);
|
|
62
|
+
}
|
|
63
|
+
this.skipNewlines();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const jobs = statements.filter((s): s is JobDeclaration => s.kind === "job");
|
|
67
|
+
return { statements, jobs };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private parseParam(): ParamDeclaration {
|
|
71
|
+
const paramToken = this.advance(); // param
|
|
72
|
+
if (this.isAtEnd()) {
|
|
73
|
+
throw new Error(`Expected parameter name after 'param' at line ${paramToken.line}, column ${paramToken.column}`);
|
|
74
|
+
}
|
|
75
|
+
const nameToken = this.peek();
|
|
76
|
+
if (nameToken.type !== "IDENTIFIER") {
|
|
77
|
+
throw new Error(`Cannot use reserved keyword '${nameToken.value}' as param name at line ${nameToken.line}, column ${nameToken.column}`);
|
|
78
|
+
}
|
|
79
|
+
this.advance();
|
|
80
|
+
if (AshParser.RESERVED_KEYWORDS.has(nameToken.value)) {
|
|
81
|
+
throw new Error(`Cannot use reserved keyword '${nameToken.value}' as param name at line ${nameToken.line}, column ${nameToken.column}`);
|
|
82
|
+
}
|
|
83
|
+
if (this.paramNames.has(nameToken.value)) {
|
|
84
|
+
throw new Error(`Duplicate param name '${nameToken.value}' at line ${nameToken.line}, column ${nameToken.column}`);
|
|
85
|
+
}
|
|
86
|
+
if (this.letNames.has(nameToken.value)) {
|
|
87
|
+
throw new Error(`Param '${nameToken.value}' conflicts with existing let variable at line ${nameToken.line}, column ${nameToken.column}`);
|
|
88
|
+
}
|
|
89
|
+
this.expect("ASSIGN", "Expected '=' after param name");
|
|
90
|
+
const valToken = this.advance();
|
|
91
|
+
let defaultValue: string | number;
|
|
92
|
+
if (valToken.type === "NUMBER") {
|
|
93
|
+
defaultValue = parseFloat(valToken.value);
|
|
94
|
+
} else if (valToken.type === "STRING") {
|
|
95
|
+
defaultValue = valToken.value;
|
|
96
|
+
} else if (valToken.type === "PATH") {
|
|
97
|
+
defaultValue = valToken.value;
|
|
98
|
+
} else if (valToken.type === "IDENTIFIER") {
|
|
99
|
+
defaultValue = valToken.value;
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error(`Expected default value after '=' at line ${valToken.line}, column ${valToken.column}`);
|
|
102
|
+
}
|
|
103
|
+
this.paramNames.add(nameToken.value);
|
|
104
|
+
return { kind: "param", name: nameToken.value, defaultValue };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static readonly PIPELINE_STARTERS = new Set(["FIND", "ACTION", "LOOKUP", "INPUT"]);
|
|
108
|
+
|
|
109
|
+
private parseLet(): LetStatement {
|
|
110
|
+
const letToken = this.advance(); // let
|
|
111
|
+
if (this.isAtEnd() || this.peek().type !== "IDENTIFIER") {
|
|
112
|
+
throw new Error(`Expected variable name after 'let' at line ${letToken.line}, column ${letToken.column}`);
|
|
113
|
+
}
|
|
114
|
+
const nameToken = this.advance();
|
|
115
|
+
if (AshParser.RESERVED_KEYWORDS.has(nameToken.value)) {
|
|
116
|
+
throw new Error(`Cannot use reserved keyword '${nameToken.value}' as variable name at line ${nameToken.line}, column ${nameToken.column}`);
|
|
117
|
+
}
|
|
118
|
+
if (this.letNames.has(nameToken.value)) {
|
|
119
|
+
throw new Error(`Duplicate variable name '${nameToken.value}' at line ${nameToken.line}, column ${nameToken.column}`);
|
|
120
|
+
}
|
|
121
|
+
if (this.paramNames.has(nameToken.value)) {
|
|
122
|
+
throw new Error(`Let variable '${nameToken.value}' conflicts with existing param at line ${nameToken.line}, column ${nameToken.column}`);
|
|
123
|
+
}
|
|
124
|
+
this.expect("ASSIGN", "Expected '=' after variable name");
|
|
125
|
+
|
|
126
|
+
// Check if the value is a pipeline (starts with find, action, etc.)
|
|
127
|
+
if (!this.isAtEnd() && AshParser.PIPELINE_STARTERS.has(this.peek().type)) {
|
|
128
|
+
const pipeline = this.parseLetPipeline();
|
|
129
|
+
this.letNames.add(nameToken.value);
|
|
130
|
+
return { kind: "let", name: nameToken.value, value: 0, pipeline };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const valToken = this.advance();
|
|
134
|
+
let value: string | number;
|
|
135
|
+
if (valToken.type === "NUMBER") {
|
|
136
|
+
value = parseFloat(valToken.value);
|
|
137
|
+
} else if (valToken.type === "STRING") {
|
|
138
|
+
value = valToken.value;
|
|
139
|
+
} else if (valToken.type === "IDENTIFIER") {
|
|
140
|
+
value = valToken.value;
|
|
141
|
+
} else {
|
|
142
|
+
throw new Error(`Expected value after '=' at line ${valToken.line}, column ${valToken.column}`);
|
|
143
|
+
}
|
|
144
|
+
this.letNames.add(nameToken.value);
|
|
145
|
+
return { kind: "let", name: nameToken.value, value };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private parseLetPipeline(): PipelineStage[] {
|
|
149
|
+
// Parse pipeline stages until NEWLINE, EOF, or JOB/LET/OUTPUT (top-level)
|
|
150
|
+
const stages: PipelineStage[] = [];
|
|
151
|
+
while (!this.isAtEnd()) {
|
|
152
|
+
const t = this.peek();
|
|
153
|
+
// Stop at newline or top-level keywords
|
|
154
|
+
if (t.type === "NEWLINE" || t.type === "JOB" || t.type === "LET" || t.type === "OUTPUT" || t.type === "AT") break;
|
|
155
|
+
|
|
156
|
+
if (t.type === "PIPE") {
|
|
157
|
+
this.advance(); // |
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stages.push(this.parseStage());
|
|
162
|
+
}
|
|
163
|
+
return stages;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private parseAnnotations(): Annotation[] {
|
|
167
|
+
const annotations: Annotation[] = [];
|
|
168
|
+
while (!this.isAtEnd() && this.peek().type === "AT") {
|
|
169
|
+
const at = this.advance(); // @
|
|
170
|
+
if (this.isAtEnd() || this.peek().type !== "IDENTIFIER") {
|
|
171
|
+
throw new Error(`Expected annotation name after '@' at line ${at.line}, column ${at.column}`);
|
|
172
|
+
}
|
|
173
|
+
const nameToken = this.advance();
|
|
174
|
+
const args: string[] = [];
|
|
175
|
+
|
|
176
|
+
if (!this.isAtEnd() && this.peek().type === "LPAREN") {
|
|
177
|
+
this.advance(); // (
|
|
178
|
+
while (!this.isAtEnd() && this.peek().type !== "RPAREN") {
|
|
179
|
+
const arg = this.advance();
|
|
180
|
+
args.push(arg.value);
|
|
181
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
182
|
+
this.advance();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (this.isAtEnd()) {
|
|
186
|
+
throw new Error(`Unclosed annotation parenthesis at line ${at.line}, column ${at.column}`);
|
|
187
|
+
}
|
|
188
|
+
this.advance(); // )
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
annotations.push({ name: nameToken.value, args, line: at.line, column: at.column });
|
|
192
|
+
this.skipNewlines();
|
|
193
|
+
}
|
|
194
|
+
return annotations;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private parseJob(annotations: Annotation[]): JobDeclaration {
|
|
198
|
+
this.advance(); // 'job'
|
|
199
|
+
this.skipNewlines();
|
|
200
|
+
|
|
201
|
+
if (this.isAtEnd()) {
|
|
202
|
+
throw new Error("Expected job name");
|
|
203
|
+
}
|
|
204
|
+
const nameToken = this.advance();
|
|
205
|
+
if (AshParser.RESERVED_KEYWORDS.has(nameToken.value)) {
|
|
206
|
+
throw new Error(`Cannot use reserved keyword '${nameToken.value}' as job name at line ${nameToken.line}, column ${nameToken.column}`);
|
|
207
|
+
}
|
|
208
|
+
this.skipNewlines();
|
|
209
|
+
|
|
210
|
+
// Optional trigger: `on /path:event` or `on cron("expr")`
|
|
211
|
+
let trigger: TriggerDeclaration | undefined;
|
|
212
|
+
if (!this.isAtEnd() && this.peek().type === "ON") {
|
|
213
|
+
const onToken = this.advance(); // on
|
|
214
|
+
|
|
215
|
+
if (!this.isAtEnd() && this.peek().type === "IDENTIFIER" && this.peek().value === "cron") {
|
|
216
|
+
// Cron trigger: on cron("*/5 * * * *")
|
|
217
|
+
this.advance(); // cron
|
|
218
|
+
if (this.isAtEnd() || this.peek().type !== "LPAREN") {
|
|
219
|
+
throw new Error(`Expected '(' after 'cron' at line ${onToken.line}, column ${onToken.column}`);
|
|
220
|
+
}
|
|
221
|
+
this.advance(); // (
|
|
222
|
+
if (this.isAtEnd() || this.peek().type !== "STRING") {
|
|
223
|
+
throw new Error(`Expected cron expression string after 'cron(' at line ${onToken.line}, column ${onToken.column}`);
|
|
224
|
+
}
|
|
225
|
+
const exprToken = this.advance();
|
|
226
|
+
if (this.isAtEnd() || this.peek().type !== "RPAREN") {
|
|
227
|
+
throw new Error(`Expected ')' after cron expression at line ${onToken.line}, column ${onToken.column}`);
|
|
228
|
+
}
|
|
229
|
+
this.advance(); // )
|
|
230
|
+
trigger = { kind: "cron", expression: exprToken.value };
|
|
231
|
+
} else {
|
|
232
|
+
// Event trigger: on /path:event
|
|
233
|
+
if (this.isAtEnd() || this.peek().type !== "PATH") {
|
|
234
|
+
throw new Error(`Expected path after 'on' at line ${onToken.line}, column ${onToken.column}`);
|
|
235
|
+
}
|
|
236
|
+
const pathToken = this.advance();
|
|
237
|
+
if (this.isAtEnd() || this.peek().type !== "COLON") {
|
|
238
|
+
throw new Error(`Expected ':event' after trigger path at line ${pathToken.line}, column ${pathToken.column}`);
|
|
239
|
+
}
|
|
240
|
+
this.advance(); // :
|
|
241
|
+
if (this.isAtEnd() || this.peek().type !== "IDENTIFIER") {
|
|
242
|
+
throw new Error(`Expected event name after ':' at line ${pathToken.line}, column ${pathToken.column}`);
|
|
243
|
+
}
|
|
244
|
+
const eventToken = this.advance();
|
|
245
|
+
trigger = { kind: "event", path: pathToken.value, event: eventToken.value };
|
|
246
|
+
}
|
|
247
|
+
this.skipNewlines();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (this.isAtEnd() || this.peek().type !== "LBRACE") {
|
|
251
|
+
const t = this.isAtEnd() ? nameToken : this.peek();
|
|
252
|
+
throw new Error(`Expected '{' after job name at line ${t.line}, column ${t.column}`);
|
|
253
|
+
}
|
|
254
|
+
this.advance(); // {
|
|
255
|
+
this.skipNewlines();
|
|
256
|
+
|
|
257
|
+
const pipeline = this.parsePipeline();
|
|
258
|
+
this.skipNewlines();
|
|
259
|
+
|
|
260
|
+
if (this.isAtEnd() || this.peek().type !== "RBRACE") {
|
|
261
|
+
throw new Error("Expected '}' to close job body");
|
|
262
|
+
}
|
|
263
|
+
this.advance(); // }
|
|
264
|
+
|
|
265
|
+
return { kind: "job", name: nameToken.value, annotations, pipeline, trigger };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private parsePipeline(): PipelineStage[] {
|
|
269
|
+
const stages: PipelineStage[] = [];
|
|
270
|
+
|
|
271
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
272
|
+
if (this.peek().type === "PIPE") {
|
|
273
|
+
if (stages.length === 0) {
|
|
274
|
+
const t = this.peek();
|
|
275
|
+
throw new Error(`Unexpected PIPE at line ${t.line}, column ${t.column}`);
|
|
276
|
+
}
|
|
277
|
+
this.advance(); // |
|
|
278
|
+
this.skipNewlines();
|
|
279
|
+
if (this.isAtEnd() || this.peek().type === "RBRACE") {
|
|
280
|
+
throw new Error("Unexpected end after pipe operator");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.skipNewlines();
|
|
285
|
+
if (this.isAtEnd() || this.peek().type === "RBRACE") break;
|
|
286
|
+
|
|
287
|
+
const stage = this.parseStage();
|
|
288
|
+
stages.push(stage);
|
|
289
|
+
this.skipNewlines();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return stages;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private parseStage(): PipelineStage {
|
|
296
|
+
const t = this.peek();
|
|
297
|
+
|
|
298
|
+
switch (t.type) {
|
|
299
|
+
case "FIND": {
|
|
300
|
+
this.advance();
|
|
301
|
+
const path = this.expect("PATH", "Expected path after 'find'");
|
|
302
|
+
// Check for inline where: `find /path where condition`
|
|
303
|
+
let query: QueryCondition | undefined;
|
|
304
|
+
if (!this.isAtEnd() && this.peek().type === "WHERE") {
|
|
305
|
+
this.advance(); // consume WHERE
|
|
306
|
+
const where = this.parseWhere();
|
|
307
|
+
query = { field: where.left, op: where.op, value: where.right };
|
|
308
|
+
}
|
|
309
|
+
return { kind: "find", path: path.value, query };
|
|
310
|
+
}
|
|
311
|
+
case "WHERE": {
|
|
312
|
+
this.advance();
|
|
313
|
+
return this.parseWhere();
|
|
314
|
+
}
|
|
315
|
+
case "MAP": {
|
|
316
|
+
this.advance();
|
|
317
|
+
return this.parseMapBody();
|
|
318
|
+
}
|
|
319
|
+
case "SAVE": {
|
|
320
|
+
this.advance();
|
|
321
|
+
const path = this.expect("PATH", "Expected path after 'save'");
|
|
322
|
+
return { kind: "save", path: path.value };
|
|
323
|
+
}
|
|
324
|
+
case "PUBLISH": {
|
|
325
|
+
this.advance();
|
|
326
|
+
const path = this.expect("PATH", "Expected path after 'publish'");
|
|
327
|
+
return { kind: "publish", path: path.value };
|
|
328
|
+
}
|
|
329
|
+
case "TEE": {
|
|
330
|
+
this.advance();
|
|
331
|
+
const path = this.expect("PATH", "Expected path after 'tee'");
|
|
332
|
+
return { kind: "tee", path: path.value };
|
|
333
|
+
}
|
|
334
|
+
case "FANOUT": {
|
|
335
|
+
this.advance();
|
|
336
|
+
return this.parseFanout();
|
|
337
|
+
}
|
|
338
|
+
case "OUTPUT": {
|
|
339
|
+
this.advance();
|
|
340
|
+
// Try parsing as expression (supports field access, binary, string literals)
|
|
341
|
+
const expr = this.parseExpression();
|
|
342
|
+
if (expr.kind === "literal" && typeof expr.value === "string") {
|
|
343
|
+
// Backward compatible: string literal → use message field
|
|
344
|
+
return { kind: "output", message: expr.value };
|
|
345
|
+
}
|
|
346
|
+
// Expression mode: evaluate per stream item at runtime
|
|
347
|
+
return { kind: "output", message: "", expression: expr };
|
|
348
|
+
}
|
|
349
|
+
case "INPUT": {
|
|
350
|
+
this.advance();
|
|
351
|
+
const prompt = this.expect("STRING", "Expected string after 'input'");
|
|
352
|
+
return { kind: "input", prompt: prompt.value };
|
|
353
|
+
}
|
|
354
|
+
case "COUNT": {
|
|
355
|
+
this.advance();
|
|
356
|
+
return { kind: "count" } as CountExpression;
|
|
357
|
+
}
|
|
358
|
+
case "GROUP_BY": {
|
|
359
|
+
this.advance();
|
|
360
|
+
if (this.isAtEnd() || this.peek().type === "PIPE" || this.peek().type === "RBRACE" || this.peek().type === "NEWLINE") {
|
|
361
|
+
throw new Error("Expected field name after 'group-by'");
|
|
362
|
+
}
|
|
363
|
+
const field = this.parseFieldAccess();
|
|
364
|
+
return { kind: "group-by", field } as GroupByExpression;
|
|
365
|
+
}
|
|
366
|
+
case "ACTION": {
|
|
367
|
+
return this.parseAction();
|
|
368
|
+
}
|
|
369
|
+
case "ROUTE": {
|
|
370
|
+
return this.parseRoute();
|
|
371
|
+
}
|
|
372
|
+
case "LOOKUP": {
|
|
373
|
+
return this.parseLookup();
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
throw new Error(`Unexpected token ${t.type} "${t.value}" at line ${t.line}, column ${t.column}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private parseWhere(): WhereClause {
|
|
381
|
+
const left = this.parseFieldAccess();
|
|
382
|
+
const opToken = this.advance();
|
|
383
|
+
const opMap: Record<string, WhereClause["op"]> = {
|
|
384
|
+
"==": "==", "!=": "!=", ">": ">", "<": "<", ">=": ">=", "<=": "<=",
|
|
385
|
+
};
|
|
386
|
+
const op = opMap[opToken.value];
|
|
387
|
+
if (!op) {
|
|
388
|
+
throw new Error(`Expected comparison operator, got ${opToken.type} at line ${opToken.line}, column ${opToken.column}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let right: string | number;
|
|
392
|
+
// Check for $variable reference
|
|
393
|
+
if (!this.isAtEnd() && this.peek().type === "DOLLAR") {
|
|
394
|
+
this.advance(); // $
|
|
395
|
+
const varName = this.advance();
|
|
396
|
+
right = "$" + varName.value;
|
|
397
|
+
} else {
|
|
398
|
+
const rightToken = this.advance();
|
|
399
|
+
if (rightToken.type === "NUMBER") {
|
|
400
|
+
right = parseFloat(rightToken.value);
|
|
401
|
+
} else if (rightToken.type === "STRING") {
|
|
402
|
+
right = rightToken.value;
|
|
403
|
+
} else if (rightToken.type === "IDENTIFIER" || rightToken.type === "PATH") {
|
|
404
|
+
let val = rightToken.value;
|
|
405
|
+
while (!this.isAtEnd() && this.peek().type === "DOT") {
|
|
406
|
+
this.advance();
|
|
407
|
+
const next = this.advance();
|
|
408
|
+
val += "." + next.value;
|
|
409
|
+
}
|
|
410
|
+
right = val;
|
|
411
|
+
} else {
|
|
412
|
+
right = rightToken.value;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { kind: "where", left, op, right };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private parseFieldAccess(): string {
|
|
420
|
+
const first = this.advance();
|
|
421
|
+
let field = first.value;
|
|
422
|
+
while (!this.isAtEnd() && this.peek().type === "DOT") {
|
|
423
|
+
this.advance(); // .
|
|
424
|
+
const next = this.advance();
|
|
425
|
+
field += "." + next.value;
|
|
426
|
+
}
|
|
427
|
+
return field;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private parseMapBody(): MapExpression {
|
|
431
|
+
// map without braces: either simple field or expression
|
|
432
|
+
if (this.isAtEnd() || this.peek().type === "PIPE" || this.peek().type === "RBRACE" || this.peek().type === "NEWLINE") {
|
|
433
|
+
throw new Error("Expected field or expression after 'map'");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (this.peek().type !== "LBRACE") {
|
|
437
|
+
// map field or map expr (no braces)
|
|
438
|
+
// Try to parse as expression; if it's just a field access, use backward-compat field mode
|
|
439
|
+
const expr = this.parseExpression();
|
|
440
|
+
if (expr.kind === "field_access") {
|
|
441
|
+
return { kind: "map", field: expr.path };
|
|
442
|
+
}
|
|
443
|
+
return { kind: "map", field: "", expression: expr };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// map { ... }
|
|
447
|
+
this.advance(); // {
|
|
448
|
+
this.skipNewlines();
|
|
449
|
+
|
|
450
|
+
// Detect mode: peek ahead to see if first pair uses COLON (expression) or not (legacy)
|
|
451
|
+
if (this.isAtEnd() || this.peek().type === "RBRACE") {
|
|
452
|
+
this.advance(); // }
|
|
453
|
+
return { kind: "map", field: "" };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Save position to peek ahead
|
|
457
|
+
const savedPos = this.pos;
|
|
458
|
+
// Skip the first key token
|
|
459
|
+
this.advance();
|
|
460
|
+
const hasColon = !this.isAtEnd() && this.peek().type === "COLON";
|
|
461
|
+
this.pos = savedPos; // restore
|
|
462
|
+
|
|
463
|
+
if (hasColon) {
|
|
464
|
+
// Expression mode: map { key: expr, ... }
|
|
465
|
+
// Use Object.create(null) to prevent __proto__ pollution
|
|
466
|
+
const exprMappings: Record<string, Expression> = Object.create(null);
|
|
467
|
+
const seenExprKeys = new Set<string>();
|
|
468
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
469
|
+
const keyToken = this.expect("IDENTIFIER", "Expected key in map expression");
|
|
470
|
+
if (seenExprKeys.has(keyToken.value)) {
|
|
471
|
+
throw new Error(`Duplicate map key '${keyToken.value}' at line ${keyToken.line}, column ${keyToken.column}`);
|
|
472
|
+
}
|
|
473
|
+
seenExprKeys.add(keyToken.value);
|
|
474
|
+
this.expect("COLON", "Expected ':' after key in map expression");
|
|
475
|
+
const expr = this.parseExpression();
|
|
476
|
+
exprMappings[keyToken.value] = expr;
|
|
477
|
+
this.skipNewlines();
|
|
478
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
479
|
+
this.advance();
|
|
480
|
+
this.skipNewlines();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close map expression");
|
|
484
|
+
this.advance(); // }
|
|
485
|
+
return { kind: "map", field: "", exprMappings };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Legacy mode: map { outputKey inputField, outputKey inputField }
|
|
489
|
+
// Use Object.create(null) to prevent __proto__ pollution
|
|
490
|
+
const mappings: Record<string, string> = Object.create(null);
|
|
491
|
+
const seenMapKeys = new Set<string>();
|
|
492
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
493
|
+
const key = this.advance();
|
|
494
|
+
if (seenMapKeys.has(key.value)) {
|
|
495
|
+
throw new Error(`Duplicate map key '${key.value}' at line ${key.line}, column ${key.column}`);
|
|
496
|
+
}
|
|
497
|
+
seenMapKeys.add(key.value);
|
|
498
|
+
const field = this.advance();
|
|
499
|
+
mappings[key.value] = field.value;
|
|
500
|
+
this.skipNewlines();
|
|
501
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
502
|
+
this.advance();
|
|
503
|
+
this.skipNewlines();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close map object");
|
|
507
|
+
this.advance(); // }
|
|
508
|
+
const firstKey = Object.keys(mappings)[0] ?? "";
|
|
509
|
+
return { kind: "map", field: firstKey, mappings };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Expression parsing (recursive descent with precedence) ──
|
|
513
|
+
// expr = term (('+' | '-') term)*
|
|
514
|
+
// term = factor (('*' | '/') factor)*
|
|
515
|
+
// factor = '(' expr ')' | $var | literal | fieldAccess
|
|
516
|
+
|
|
517
|
+
private parseExpression(): Expression {
|
|
518
|
+
let left = this.parseTerm();
|
|
519
|
+
while (!this.isAtEnd() && (this.peek().type === "PLUS" || this.peek().type === "MINUS")) {
|
|
520
|
+
const opToken = this.advance();
|
|
521
|
+
const right = this.parseTerm();
|
|
522
|
+
left = { kind: "binary", op: opToken.value as "+" | "-", left, right };
|
|
523
|
+
}
|
|
524
|
+
return left;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private parseTerm(): Expression {
|
|
528
|
+
let left = this.parseFactor();
|
|
529
|
+
while (!this.isAtEnd() && (this.peek().type === "STAR" || this.peek().type === "SLASH")) {
|
|
530
|
+
const opToken = this.advance();
|
|
531
|
+
const right = this.parseFactor();
|
|
532
|
+
left = { kind: "binary", op: opToken.value as "*" | "/", left, right };
|
|
533
|
+
}
|
|
534
|
+
return left;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private parseFactor(): Expression {
|
|
538
|
+
if (this.isAtEnd()) {
|
|
539
|
+
throw new Error("Expected expression");
|
|
540
|
+
}
|
|
541
|
+
const t = this.peek();
|
|
542
|
+
|
|
543
|
+
// Parenthesized expression
|
|
544
|
+
if (t.type === "LPAREN") {
|
|
545
|
+
this.advance(); // (
|
|
546
|
+
const expr = this.parseExpression();
|
|
547
|
+
this.expect("RPAREN", "Expected ')' after expression");
|
|
548
|
+
return expr;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Variable reference: $name
|
|
552
|
+
if (t.type === "DOLLAR") {
|
|
553
|
+
this.advance(); // $
|
|
554
|
+
const nameToken = this.expect("IDENTIFIER", "Expected variable name after '$'");
|
|
555
|
+
return { kind: "var_ref", name: nameToken.value };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// String literal
|
|
559
|
+
if (t.type === "STRING") {
|
|
560
|
+
this.advance();
|
|
561
|
+
return { kind: "literal", value: t.value };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Number literal
|
|
565
|
+
if (t.type === "NUMBER") {
|
|
566
|
+
this.advance();
|
|
567
|
+
return { kind: "literal", value: parseFloat(t.value) };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Field access: identifier (possibly dotted)
|
|
571
|
+
if (t.type === "IDENTIFIER") {
|
|
572
|
+
let field = this.advance().value;
|
|
573
|
+
while (!this.isAtEnd() && this.peek().type === "DOT") {
|
|
574
|
+
this.advance(); // .
|
|
575
|
+
const next = this.advance();
|
|
576
|
+
field += "." + next.value;
|
|
577
|
+
}
|
|
578
|
+
return { kind: "field_access", path: field };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
throw new Error(`Unexpected token in expression: ${t.type} "${t.value}" at line ${t.line}, column ${t.column}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private parseAction(): ActionExpression {
|
|
585
|
+
const actionToken = this.advance(); // action
|
|
586
|
+
if (this.isAtEnd()) {
|
|
587
|
+
throw new Error(`Expected path or action name after 'action' at line ${actionToken.line}, column ${actionToken.column}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const nextToken = this.peek();
|
|
591
|
+
let path: string;
|
|
592
|
+
let relative: boolean | undefined;
|
|
593
|
+
|
|
594
|
+
if (nextToken.type === "PATH") {
|
|
595
|
+
// Absolute action: action /tesla/.actions/honk
|
|
596
|
+
path = this.advance().value;
|
|
597
|
+
} else if (nextToken.type === "IDENTIFIER") {
|
|
598
|
+
// Relative action: action turn_off
|
|
599
|
+
path = this.advance().value;
|
|
600
|
+
relative = true;
|
|
601
|
+
} else {
|
|
602
|
+
throw new Error(`Expected path or action name after 'action' at line ${actionToken.line}, column ${actionToken.column}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let params: Record<string, unknown> | undefined;
|
|
606
|
+
|
|
607
|
+
// Optional params: { key: value, ... }
|
|
608
|
+
if (!this.isAtEnd() && this.peek().type === "LBRACE") {
|
|
609
|
+
this.advance(); // {
|
|
610
|
+
params = {};
|
|
611
|
+
this.skipNewlines();
|
|
612
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
613
|
+
const keyToken = this.expect("IDENTIFIER", "Expected parameter name in action params");
|
|
614
|
+
this.expect("COLON", "Expected ':' after parameter name");
|
|
615
|
+
if (this.isAtEnd()) {
|
|
616
|
+
throw new Error(`Expected value after ':' at line ${keyToken.line}, column ${keyToken.column}`);
|
|
617
|
+
}
|
|
618
|
+
// Check for json() wrapper
|
|
619
|
+
if (this.peek().type === "IDENTIFIER" && this.peek().value === "json") {
|
|
620
|
+
const jsonToken = this.advance(); // consume "json"
|
|
621
|
+
this.expect("LPAREN", `Expected '(' after json at line ${jsonToken.line}, column ${jsonToken.column}`);
|
|
622
|
+
params[keyToken.value] = this.parseJsonValue();
|
|
623
|
+
this.expect("RPAREN", "Expected ')' to close json()");
|
|
624
|
+
} else {
|
|
625
|
+
const valToken = this.advance();
|
|
626
|
+
if (valToken.type === "STRING") {
|
|
627
|
+
params[keyToken.value] = valToken.value;
|
|
628
|
+
} else if (valToken.type === "NUMBER") {
|
|
629
|
+
params[keyToken.value] = parseFloat(valToken.value);
|
|
630
|
+
} else {
|
|
631
|
+
throw new Error(`Expected string or number value for parameter '${keyToken.value}' at line ${valToken.line}, column ${valToken.column}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
this.skipNewlines();
|
|
635
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
636
|
+
this.advance();
|
|
637
|
+
this.skipNewlines();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close action params");
|
|
641
|
+
this.advance(); // }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { kind: "action", path, relative, params };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private parseRoute(): RouteExpression {
|
|
648
|
+
const routeToken = this.advance(); // route
|
|
649
|
+
if (this.isAtEnd() || (this.peek().type !== "IDENTIFIER" && this.peek().type !== "DOT")) {
|
|
650
|
+
throw new Error(`Expected field name after 'route' at line ${routeToken.line}, column ${routeToken.column}`);
|
|
651
|
+
}
|
|
652
|
+
const field = this.parseFieldAccess();
|
|
653
|
+
this.expect("LBRACE", "Expected '{' after route field");
|
|
654
|
+
this.skipNewlines();
|
|
655
|
+
|
|
656
|
+
const branches: RouteBranch[] = [];
|
|
657
|
+
let fallback: string | undefined;
|
|
658
|
+
|
|
659
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
660
|
+
// Each branch: "value" -> job target OR _ -> job target
|
|
661
|
+
let value: string;
|
|
662
|
+
if (this.peek().type === "STRING") {
|
|
663
|
+
value = this.advance().value;
|
|
664
|
+
} else if (this.peek().type === "IDENTIFIER" && this.peek().value === "_") {
|
|
665
|
+
value = "_";
|
|
666
|
+
this.advance();
|
|
667
|
+
} else {
|
|
668
|
+
throw new Error(`Expected string value or '_' in route branch at line ${this.peek().line}, column ${this.peek().column}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.expect("ARROW", "Expected '->' in route branch");
|
|
672
|
+
this.expect("JOB", "Expected 'job' keyword in route branch target");
|
|
673
|
+
const targetToken = this.advance(); // job name
|
|
674
|
+
|
|
675
|
+
if (value === "_") {
|
|
676
|
+
fallback = targetToken.value;
|
|
677
|
+
} else {
|
|
678
|
+
branches.push({ value, targetJob: targetToken.value });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
this.skipNewlines();
|
|
682
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
683
|
+
this.advance();
|
|
684
|
+
this.skipNewlines();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (branches.length === 0 && !fallback) {
|
|
689
|
+
throw new Error(`Route must have at least one branch at line ${routeToken.line}, column ${routeToken.column}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close route");
|
|
693
|
+
this.advance(); // }
|
|
694
|
+
|
|
695
|
+
return { kind: "route", field, branches, fallback };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private parseLookup(): LookupExpression {
|
|
699
|
+
const lookupToken = this.advance(); // lookup
|
|
700
|
+
if (this.isAtEnd() || this.peek().type !== "PATH") {
|
|
701
|
+
throw new Error(`Expected path after 'lookup' at line ${lookupToken.line}, column ${lookupToken.column}`);
|
|
702
|
+
}
|
|
703
|
+
const pathToken = this.advance();
|
|
704
|
+
// Expect "on" keyword
|
|
705
|
+
if (this.isAtEnd() || this.peek().type !== "ON") {
|
|
706
|
+
throw new Error(`Expected 'on' keyword after lookup path at line ${lookupToken.line}, column ${lookupToken.column}`);
|
|
707
|
+
}
|
|
708
|
+
this.advance(); // on
|
|
709
|
+
const joinKey = this.parseFieldAccess();
|
|
710
|
+
return { kind: "lookup", path: pathToken.value, joinKey };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private parseFanout(): PipelineStage {
|
|
714
|
+
this.expect("LBRACE", "Expected '{' after 'fanout'");
|
|
715
|
+
const branches: PipelineStage[][] = [];
|
|
716
|
+
|
|
717
|
+
this.skipNewlines();
|
|
718
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
719
|
+
// Each branch is a pipeline until COMMA or RBRACE
|
|
720
|
+
const branch: PipelineStage[] = [];
|
|
721
|
+
while (!this.isAtEnd() && this.peek().type !== "COMMA" && this.peek().type !== "RBRACE" && this.peek().type !== "NEWLINE") {
|
|
722
|
+
if (this.peek().type === "PIPE") {
|
|
723
|
+
this.advance();
|
|
724
|
+
}
|
|
725
|
+
branch.push(this.parseStage());
|
|
726
|
+
}
|
|
727
|
+
branches.push(branch);
|
|
728
|
+
this.skipNewlines();
|
|
729
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
730
|
+
this.advance();
|
|
731
|
+
this.skipNewlines();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close fanout");
|
|
736
|
+
this.advance(); // }
|
|
737
|
+
|
|
738
|
+
return { kind: "fanout", branches };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Recursive descent JSON value parser using existing lexer tokens. */
|
|
742
|
+
private parseJsonValue(): unknown {
|
|
743
|
+
if (this.isAtEnd()) throw new Error("Expected JSON value (unexpected EOF)");
|
|
744
|
+
const t = this.peek();
|
|
745
|
+
|
|
746
|
+
// Object: { ... }
|
|
747
|
+
if (t.type === "LBRACE") {
|
|
748
|
+
this.advance(); // {
|
|
749
|
+
const obj: Record<string, unknown> = {};
|
|
750
|
+
this.skipNewlines();
|
|
751
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACE") {
|
|
752
|
+
const keyToken = this.advance();
|
|
753
|
+
if (keyToken.type !== "STRING" && keyToken.type !== "IDENTIFIER") {
|
|
754
|
+
throw new Error(`Expected string key in JSON object at line ${keyToken.line}, column ${keyToken.column}`);
|
|
755
|
+
}
|
|
756
|
+
this.expect("COLON", "Expected ':' after key in JSON object");
|
|
757
|
+
obj[keyToken.value] = this.parseJsonValue();
|
|
758
|
+
this.skipNewlines();
|
|
759
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
760
|
+
this.advance();
|
|
761
|
+
this.skipNewlines();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (this.isAtEnd()) throw new Error("Expected '}' to close JSON object");
|
|
765
|
+
this.advance(); // }
|
|
766
|
+
return obj;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Array: [ ... ]
|
|
770
|
+
if (t.type === "LBRACKET") {
|
|
771
|
+
this.advance(); // [
|
|
772
|
+
const arr: unknown[] = [];
|
|
773
|
+
this.skipNewlines();
|
|
774
|
+
while (!this.isAtEnd() && this.peek().type !== "RBRACKET") {
|
|
775
|
+
arr.push(this.parseJsonValue());
|
|
776
|
+
this.skipNewlines();
|
|
777
|
+
if (!this.isAtEnd() && this.peek().type === "COMMA") {
|
|
778
|
+
this.advance();
|
|
779
|
+
this.skipNewlines();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (this.isAtEnd()) throw new Error("Expected ']' to close JSON array");
|
|
783
|
+
this.advance(); // ]
|
|
784
|
+
return arr;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// String
|
|
788
|
+
if (t.type === "STRING") {
|
|
789
|
+
this.advance();
|
|
790
|
+
return t.value;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Number
|
|
794
|
+
if (t.type === "NUMBER") {
|
|
795
|
+
this.advance();
|
|
796
|
+
return parseFloat(t.value);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Boolean / null: true, false, null are lexed as IDENTIFIER
|
|
800
|
+
if (t.type === "IDENTIFIER") {
|
|
801
|
+
if (t.value === "true") { this.advance(); return true; }
|
|
802
|
+
if (t.value === "false") { this.advance(); return false; }
|
|
803
|
+
if (t.value === "null") { this.advance(); return null; }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
throw new Error(`Unexpected token in json(): ${t.type} "${t.value}" at line ${t.line}, column ${t.column}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private peek(): Token {
|
|
810
|
+
return this.tokens[this.pos];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private advance(): Token {
|
|
814
|
+
return this.tokens[this.pos++];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private expect(type: TokenType, message: string): Token {
|
|
818
|
+
if (this.isAtEnd()) throw new Error(message + " (unexpected EOF)");
|
|
819
|
+
const t = this.peek();
|
|
820
|
+
if (t.type !== type) {
|
|
821
|
+
throw new Error(`${message}, got ${t.type} at line ${t.line}, column ${t.column}`);
|
|
822
|
+
}
|
|
823
|
+
return this.advance();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private isAtEnd(): boolean {
|
|
827
|
+
return this.pos >= this.tokens.length || this.tokens[this.pos].type === "EOF";
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private skipNewlines(): void {
|
|
831
|
+
while (!this.isAtEnd() && this.peek().type === "NEWLINE") {
|
|
832
|
+
this.pos++;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|