@duckcodeailabs/dql-core 0.1.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/LICENSE +123 -0
- package/dist/ast/index.d.ts +4 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +4 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/nodes.d.ts +205 -0
- package/dist/ast/nodes.d.ts.map +1 -0
- package/dist/ast/nodes.js +33 -0
- package/dist/ast/nodes.js.map +1 -0
- package/dist/ast/printer.d.ts +3 -0
- package/dist/ast/printer.d.ts.map +1 -0
- package/dist/ast/printer.js +157 -0
- package/dist/ast/printer.js.map +1 -0
- package/dist/ast/visitor.d.ts +45 -0
- package/dist/ast/visitor.d.ts.map +1 -0
- package/dist/ast/visitor.js +155 -0
- package/dist/ast/visitor.js.map +1 -0
- package/dist/errors/diagnostic.d.ts +23 -0
- package/dist/errors/diagnostic.d.ts.map +1 -0
- package/dist/errors/diagnostic.js +13 -0
- package/dist/errors/diagnostic.js.map +1 -0
- package/dist/errors/index.d.ts +3 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/reporter.d.ts +22 -0
- package/dist/errors/reporter.d.ts.map +1 -0
- package/dist/errors/reporter.js +64 -0
- package/dist/errors/reporter.js.map +1 -0
- package/dist/formatter/formatter.d.ts +8 -0
- package/dist/formatter/formatter.d.ts.map +1 -0
- package/dist/formatter/formatter.js +243 -0
- package/dist/formatter/formatter.js.map +1 -0
- package/dist/formatter/index.d.ts +2 -0
- package/dist/formatter/index.d.ts.map +1 -0
- package/dist/formatter/index.js +2 -0
- package/dist/formatter/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer/index.d.ts +3 -0
- package/dist/lexer/index.d.ts.map +1 -0
- package/dist/lexer/index.js +3 -0
- package/dist/lexer/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +43 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/lexer.js +346 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/token.d.ts +72 -0
- package/dist/lexer/token.d.ts.map +1 -0
- package/dist/lexer/token.js +170 -0
- package/dist/lexer/token.js.map +1 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +2 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/parser.d.ts +50 -0
- package/dist/parser/parser.d.ts.map +1 -0
- package/dist/parser/parser.js +1024 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/semantic/analyzer.d.ts +27 -0
- package/dist/semantic/analyzer.d.ts.map +1 -0
- package/dist/semantic/analyzer.js +580 -0
- package/dist/semantic/analyzer.js.map +1 -0
- package/dist/semantic/index.d.ts +4 -0
- package/dist/semantic/index.d.ts.map +1 -0
- package/dist/semantic/index.js +3 -0
- package/dist/semantic/index.js.map +1 -0
- package/dist/semantic/semantic-layer.d.ts +118 -0
- package/dist/semantic/semantic-layer.d.ts.map +1 -0
- package/dist/semantic/semantic-layer.js +264 -0
- package/dist/semantic/semantic-layer.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
import { DiagnosticReporter, DQLSyntaxError } from '../errors/reporter.js';
|
|
2
|
+
import { Lexer } from '../lexer/lexer.js';
|
|
3
|
+
import { TokenType, CHART_TYPES, FILTER_TYPES, normalizeChartType } from '../lexer/token.js';
|
|
4
|
+
import { NodeKind, } from '../ast/nodes.js';
|
|
5
|
+
export class Parser {
|
|
6
|
+
tokens;
|
|
7
|
+
pos = 0;
|
|
8
|
+
source;
|
|
9
|
+
file;
|
|
10
|
+
reporter;
|
|
11
|
+
constructor(source, file) {
|
|
12
|
+
this.source = source;
|
|
13
|
+
this.file = file;
|
|
14
|
+
this.reporter = new DiagnosticReporter();
|
|
15
|
+
const lexer = new Lexer(source, file);
|
|
16
|
+
this.tokens = lexer.tokenize();
|
|
17
|
+
}
|
|
18
|
+
parse() {
|
|
19
|
+
const start = this.currentSpan();
|
|
20
|
+
const statements = [];
|
|
21
|
+
while (!this.isAtEnd()) {
|
|
22
|
+
try {
|
|
23
|
+
const stmt = this.parseStatement();
|
|
24
|
+
if (stmt) {
|
|
25
|
+
statements.push(stmt);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
// Skip to next statement-level token on error
|
|
30
|
+
this.synchronize();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (this.reporter.hasErrors()) {
|
|
34
|
+
throw new DQLSyntaxError(`Parse errors in ${this.file ?? '<input>'}`, this.reporter.getErrors());
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
kind: NodeKind.Program,
|
|
38
|
+
statements,
|
|
39
|
+
span: this.makeSpan(start, this.currentSpan()),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
parseStatement() {
|
|
43
|
+
// Collect leading decorators
|
|
44
|
+
const decorators = this.parseDecoratorList();
|
|
45
|
+
if (this.check(TokenType.WorkbookKeyword)) {
|
|
46
|
+
return this.parseWorkbook(decorators);
|
|
47
|
+
}
|
|
48
|
+
if (this.check(TokenType.DashboardKeyword)) {
|
|
49
|
+
return this.parseDashboard(decorators);
|
|
50
|
+
}
|
|
51
|
+
if (this.check(TokenType.ChartKeyword)) {
|
|
52
|
+
return this.parseChartCall(decorators);
|
|
53
|
+
}
|
|
54
|
+
if (this.check(TokenType.BlockKeyword)) {
|
|
55
|
+
return this.parseBlockDecl(decorators);
|
|
56
|
+
}
|
|
57
|
+
if (this.check(TokenType.ImportKeyword)) {
|
|
58
|
+
if (decorators.length > 0) {
|
|
59
|
+
this.error('Decorators cannot be applied to import statements.');
|
|
60
|
+
}
|
|
61
|
+
return this.parseImportDecl();
|
|
62
|
+
}
|
|
63
|
+
if (this.check(TokenType.EOF)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
this.error(`Unexpected token '${this.current().value}'. Expected 'dashboard', 'workbook', 'chart', 'block', or 'import'.`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
parseFilterCall() {
|
|
70
|
+
const start = this.currentSpan();
|
|
71
|
+
this.expect(TokenType.FilterKeyword);
|
|
72
|
+
this.expect(TokenType.Dot);
|
|
73
|
+
const filterTypeToken = this.expect(TokenType.Identifier);
|
|
74
|
+
if (!FILTER_TYPES.has(filterTypeToken.value)) {
|
|
75
|
+
this.reporter.error(`Unknown filter type '${filterTypeToken.value}'. Valid types: ${[...FILTER_TYPES].join(', ')}`, filterTypeToken.span, `Did you mean one of: ${[...FILTER_TYPES].join(', ')}?`);
|
|
76
|
+
}
|
|
77
|
+
this.expect(TokenType.LeftParen);
|
|
78
|
+
// Check if there's a SQL query (SELECT/WITH) or just named args
|
|
79
|
+
let query;
|
|
80
|
+
const currentToken = this.current();
|
|
81
|
+
const tokenVal = currentToken.value.toUpperCase();
|
|
82
|
+
if (tokenVal === 'SELECT' || tokenVal === 'WITH') {
|
|
83
|
+
query = this.parseSQLQuery();
|
|
84
|
+
}
|
|
85
|
+
// Parse named arguments
|
|
86
|
+
const args = [];
|
|
87
|
+
while (this.check(TokenType.Comma) || (!query && (this.check(TokenType.Identifier) || this.isKeywordUsableAsArgName(this.current().type)))) {
|
|
88
|
+
if (this.check(TokenType.Comma)) {
|
|
89
|
+
this.advance(); // consume comma
|
|
90
|
+
}
|
|
91
|
+
if (this.check(TokenType.RightParen))
|
|
92
|
+
break; // trailing comma
|
|
93
|
+
args.push(this.parseNamedArg());
|
|
94
|
+
}
|
|
95
|
+
this.expect(TokenType.RightParen);
|
|
96
|
+
return {
|
|
97
|
+
kind: NodeKind.FilterCall,
|
|
98
|
+
filterType: filterTypeToken.value,
|
|
99
|
+
query,
|
|
100
|
+
args,
|
|
101
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
parseDashboard(decorators) {
|
|
105
|
+
const start = decorators.length > 0 ? decorators[0].span : this.currentSpan();
|
|
106
|
+
this.expect(TokenType.DashboardKeyword);
|
|
107
|
+
const titleToken = this.expect(TokenType.StringLiteral);
|
|
108
|
+
this.expect(TokenType.LeftBrace);
|
|
109
|
+
const body = [];
|
|
110
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
111
|
+
// Collect decorators for items inside dashboard
|
|
112
|
+
const itemDecorators = this.parseDecoratorList();
|
|
113
|
+
if (this.check(TokenType.LetKeyword)) {
|
|
114
|
+
if (itemDecorators.length > 0) {
|
|
115
|
+
this.error('Decorators cannot be applied to variable declarations.');
|
|
116
|
+
}
|
|
117
|
+
body.push(this.parseVariableDecl());
|
|
118
|
+
}
|
|
119
|
+
else if (this.check(TokenType.ParamKeyword)) {
|
|
120
|
+
if (itemDecorators.length > 0) {
|
|
121
|
+
this.error('Decorators cannot be applied to param declarations.');
|
|
122
|
+
}
|
|
123
|
+
body.push(this.parseParamDecl());
|
|
124
|
+
}
|
|
125
|
+
else if (this.check(TokenType.ChartKeyword)) {
|
|
126
|
+
body.push(this.parseChartCall(itemDecorators));
|
|
127
|
+
}
|
|
128
|
+
else if (this.check(TokenType.FilterKeyword)) {
|
|
129
|
+
if (itemDecorators.length > 0) {
|
|
130
|
+
this.error('Decorators cannot be applied to filter declarations.');
|
|
131
|
+
}
|
|
132
|
+
body.push(this.parseFilterCall());
|
|
133
|
+
}
|
|
134
|
+
else if (this.check(TokenType.UseKeyword)) {
|
|
135
|
+
if (itemDecorators.length > 0) {
|
|
136
|
+
this.error('Decorators cannot be applied to use declarations.');
|
|
137
|
+
}
|
|
138
|
+
body.push(this.parseUseDecl());
|
|
139
|
+
}
|
|
140
|
+
else if (this.check(TokenType.LayoutKeyword)) {
|
|
141
|
+
if (itemDecorators.length > 0) {
|
|
142
|
+
this.error('Decorators cannot be applied to layout blocks.');
|
|
143
|
+
}
|
|
144
|
+
body.push(this.parseLayoutBlock());
|
|
145
|
+
}
|
|
146
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.error(`Unexpected token '${this.current().value}' inside dashboard. Expected 'let', 'param', 'chart', 'filter', 'use', 'layout', or '}'.`);
|
|
151
|
+
this.advance();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.expect(TokenType.RightBrace);
|
|
155
|
+
return {
|
|
156
|
+
kind: NodeKind.Dashboard,
|
|
157
|
+
title: titleToken.value,
|
|
158
|
+
decorators,
|
|
159
|
+
body,
|
|
160
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
parseChartCall(decorators) {
|
|
164
|
+
const start = decorators.length > 0 ? decorators[0].span : this.currentSpan();
|
|
165
|
+
this.expect(TokenType.ChartKeyword);
|
|
166
|
+
this.expect(TokenType.Dot);
|
|
167
|
+
const chartTypeStart = this.currentSpan();
|
|
168
|
+
const chartTypeToken = this.expect(TokenType.Identifier);
|
|
169
|
+
let rawChartType = chartTypeToken.value;
|
|
170
|
+
while (this.check(TokenType.Minus)) {
|
|
171
|
+
this.advance();
|
|
172
|
+
rawChartType += `-${this.expect(TokenType.Identifier).value}`;
|
|
173
|
+
}
|
|
174
|
+
const chartTypeSpan = this.makeSpan(chartTypeStart, this.previousSpan());
|
|
175
|
+
if (!CHART_TYPES.has(rawChartType)) {
|
|
176
|
+
this.reporter.error(`Unknown chart type '${rawChartType}'. Valid types: ${[...CHART_TYPES].join(', ')}`, chartTypeSpan, `Did you mean one of: ${[...CHART_TYPES].join(', ')}?`);
|
|
177
|
+
}
|
|
178
|
+
const normalizedChartType = normalizeChartType(rawChartType);
|
|
179
|
+
this.expect(TokenType.LeftParen);
|
|
180
|
+
// Parse the SQL query using boundary detection on the raw source
|
|
181
|
+
const query = this.parseSQLQuery();
|
|
182
|
+
// Parse named arguments
|
|
183
|
+
const args = [];
|
|
184
|
+
while (this.check(TokenType.Comma)) {
|
|
185
|
+
this.advance(); // consume comma
|
|
186
|
+
if (this.check(TokenType.RightParen))
|
|
187
|
+
break; // trailing comma
|
|
188
|
+
args.push(this.parseNamedArg());
|
|
189
|
+
}
|
|
190
|
+
this.expect(TokenType.RightParen);
|
|
191
|
+
return {
|
|
192
|
+
kind: NodeKind.ChartCall,
|
|
193
|
+
chartType: normalizedChartType,
|
|
194
|
+
query,
|
|
195
|
+
args,
|
|
196
|
+
decorators,
|
|
197
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
parseSQLQuery() {
|
|
201
|
+
const start = this.currentSpan();
|
|
202
|
+
// We need to find the SQL text from the raw source.
|
|
203
|
+
// The current token should be the start of the SQL (SELECT, WITH, etc.)
|
|
204
|
+
// We use Lexer.scanSQLBoundary on the raw source to find where SQL ends.
|
|
205
|
+
const currentToken = this.current();
|
|
206
|
+
const sqlStartOffset = currentToken.span.start.offset;
|
|
207
|
+
// Scan the raw source for SQL boundary
|
|
208
|
+
const { sql, endOffset } = Lexer.scanSQLBoundary(this.source, sqlStartOffset, 1);
|
|
209
|
+
// Extract template interpolations from the SQL text
|
|
210
|
+
const interpolations = this.extractInterpolations(sql, start);
|
|
211
|
+
// Now advance our token position past all tokens that fall within the SQL range
|
|
212
|
+
while (!this.isAtEnd() &&
|
|
213
|
+
this.current().span.start.offset < endOffset) {
|
|
214
|
+
this.advance();
|
|
215
|
+
}
|
|
216
|
+
const span = this.makeSpan(start, this.currentSpan());
|
|
217
|
+
return {
|
|
218
|
+
kind: NodeKind.SQLQuery,
|
|
219
|
+
rawSQL: sql,
|
|
220
|
+
interpolations,
|
|
221
|
+
span,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
extractInterpolations(sql, baseSpan) {
|
|
225
|
+
const interpolations = [];
|
|
226
|
+
const regex = /\$\{(\w+)\}|\{(\w+)\}/g;
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = regex.exec(sql)) !== null) {
|
|
229
|
+
const variableName = match[1] || match[2];
|
|
230
|
+
if (!variableName)
|
|
231
|
+
continue;
|
|
232
|
+
interpolations.push({
|
|
233
|
+
variableName,
|
|
234
|
+
offsetInSQL: match.index,
|
|
235
|
+
span: baseSpan, // simplified; ideally compute exact position
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return interpolations;
|
|
239
|
+
}
|
|
240
|
+
parseNamedArg() {
|
|
241
|
+
const start = this.currentSpan();
|
|
242
|
+
// Accept identifiers and keywords that may be used as argument names (e.g. 'param', 'label', 'from')
|
|
243
|
+
const nameToken = this.current();
|
|
244
|
+
if (nameToken.type === TokenType.Identifier || this.isKeywordUsableAsArgName(nameToken.type)) {
|
|
245
|
+
this.advance();
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.error(`Expected argument name, got '${nameToken.value}'.`);
|
|
249
|
+
this.advance();
|
|
250
|
+
}
|
|
251
|
+
this.expect(TokenType.Equals);
|
|
252
|
+
const value = this.parseExpression();
|
|
253
|
+
return {
|
|
254
|
+
kind: NodeKind.NamedArg,
|
|
255
|
+
name: nameToken.value,
|
|
256
|
+
value,
|
|
257
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
isKeywordUsableAsArgName(type) {
|
|
261
|
+
return type === TokenType.ParamKeyword
|
|
262
|
+
|| type === TokenType.FromKeyword
|
|
263
|
+
|| type === TokenType.LayoutKeyword
|
|
264
|
+
|| type === TokenType.RowKeyword
|
|
265
|
+
|| type === TokenType.UseKeyword
|
|
266
|
+
|| type === TokenType.ImportKeyword;
|
|
267
|
+
}
|
|
268
|
+
parseVariableDecl() {
|
|
269
|
+
const start = this.currentSpan();
|
|
270
|
+
this.expect(TokenType.LetKeyword);
|
|
271
|
+
const nameToken = this.expect(TokenType.Identifier);
|
|
272
|
+
this.expect(TokenType.Equals);
|
|
273
|
+
const initializer = this.parseExpression();
|
|
274
|
+
return {
|
|
275
|
+
kind: NodeKind.VariableDecl,
|
|
276
|
+
name: nameToken.value,
|
|
277
|
+
initializer,
|
|
278
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
parseDecoratorList() {
|
|
282
|
+
const decorators = [];
|
|
283
|
+
while (this.check(TokenType.AtSign)) {
|
|
284
|
+
decorators.push(this.parseDecorator());
|
|
285
|
+
}
|
|
286
|
+
return decorators;
|
|
287
|
+
}
|
|
288
|
+
parseDecorator() {
|
|
289
|
+
const start = this.currentSpan();
|
|
290
|
+
this.expect(TokenType.AtSign);
|
|
291
|
+
const nameToken = this.expect(TokenType.Identifier);
|
|
292
|
+
const args = [];
|
|
293
|
+
if (this.check(TokenType.LeftParen)) {
|
|
294
|
+
this.advance(); // consume (
|
|
295
|
+
if (!this.check(TokenType.RightParen)) {
|
|
296
|
+
args.push(this.parseExpression());
|
|
297
|
+
while (this.check(TokenType.Comma)) {
|
|
298
|
+
this.advance();
|
|
299
|
+
args.push(this.parseExpression());
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this.expect(TokenType.RightParen);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
kind: NodeKind.Decorator,
|
|
306
|
+
name: nameToken.value,
|
|
307
|
+
arguments: args,
|
|
308
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
parseExpression() {
|
|
312
|
+
let left = this.parsePrimary();
|
|
313
|
+
// Handle binary + and - operators
|
|
314
|
+
while (this.check(TokenType.Plus) || this.check(TokenType.Minus)) {
|
|
315
|
+
const opToken = this.advance();
|
|
316
|
+
const right = this.parsePrimary();
|
|
317
|
+
left = {
|
|
318
|
+
kind: NodeKind.BinaryExpr,
|
|
319
|
+
operator: opToken.value,
|
|
320
|
+
left,
|
|
321
|
+
right,
|
|
322
|
+
span: this.makeSpan(left.span, right.span),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return left;
|
|
326
|
+
}
|
|
327
|
+
parsePrimary() {
|
|
328
|
+
const token = this.current();
|
|
329
|
+
// String literal
|
|
330
|
+
if (this.check(TokenType.StringLiteral)) {
|
|
331
|
+
this.advance();
|
|
332
|
+
return {
|
|
333
|
+
kind: NodeKind.StringLiteral,
|
|
334
|
+
value: token.value,
|
|
335
|
+
span: token.span,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// Number literal
|
|
339
|
+
if (this.check(TokenType.NumberLiteral)) {
|
|
340
|
+
this.advance();
|
|
341
|
+
return {
|
|
342
|
+
kind: NodeKind.NumberLiteral,
|
|
343
|
+
value: Number(token.value),
|
|
344
|
+
span: token.span,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// Boolean literal
|
|
348
|
+
if (this.check(TokenType.BooleanLiteral)) {
|
|
349
|
+
this.advance();
|
|
350
|
+
return {
|
|
351
|
+
kind: NodeKind.BooleanLiteral,
|
|
352
|
+
value: token.value === 'true',
|
|
353
|
+
span: token.span,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// Array literal
|
|
357
|
+
if (this.check(TokenType.LeftBracket)) {
|
|
358
|
+
return this.parseArrayLiteral();
|
|
359
|
+
}
|
|
360
|
+
// INTERVAL expression
|
|
361
|
+
if (this.check(TokenType.IntervalKeyword)) {
|
|
362
|
+
const start = this.currentSpan();
|
|
363
|
+
this.advance(); // consume INTERVAL
|
|
364
|
+
const valueToken = this.expect(TokenType.StringLiteral);
|
|
365
|
+
return {
|
|
366
|
+
kind: NodeKind.IntervalExpr,
|
|
367
|
+
value: valueToken.value,
|
|
368
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// Identifier (may be function call)
|
|
372
|
+
if (this.check(TokenType.Identifier)) {
|
|
373
|
+
this.advance();
|
|
374
|
+
const name = token.value;
|
|
375
|
+
// Check for function call: ident(
|
|
376
|
+
if (this.check(TokenType.LeftParen)) {
|
|
377
|
+
return this.parseFunctionCall(name, token.span);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
kind: NodeKind.Identifier,
|
|
381
|
+
name,
|
|
382
|
+
span: token.span,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
// Negative number (minus followed by number)
|
|
386
|
+
if (this.check(TokenType.Minus)) {
|
|
387
|
+
const start = this.currentSpan();
|
|
388
|
+
this.advance();
|
|
389
|
+
const numToken = this.expect(TokenType.NumberLiteral);
|
|
390
|
+
return {
|
|
391
|
+
kind: NodeKind.NumberLiteral,
|
|
392
|
+
value: -Number(numToken.value),
|
|
393
|
+
span: this.makeSpan(start, numToken.span),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
this.error(`Unexpected token '${token.value}'. Expected an expression.`);
|
|
397
|
+
this.advance();
|
|
398
|
+
return {
|
|
399
|
+
kind: NodeKind.Identifier,
|
|
400
|
+
name: '__error__',
|
|
401
|
+
span: token.span,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
parseArrayLiteral() {
|
|
405
|
+
const start = this.currentSpan();
|
|
406
|
+
this.expect(TokenType.LeftBracket);
|
|
407
|
+
const elements = [];
|
|
408
|
+
if (!this.check(TokenType.RightBracket)) {
|
|
409
|
+
elements.push(this.parseExpression());
|
|
410
|
+
while (this.check(TokenType.Comma)) {
|
|
411
|
+
this.advance();
|
|
412
|
+
if (this.check(TokenType.RightBracket))
|
|
413
|
+
break; // trailing comma
|
|
414
|
+
elements.push(this.parseExpression());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.expect(TokenType.RightBracket);
|
|
418
|
+
return {
|
|
419
|
+
kind: NodeKind.ArrayLiteral,
|
|
420
|
+
elements,
|
|
421
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
parseFunctionCall(callee, start) {
|
|
425
|
+
this.expect(TokenType.LeftParen);
|
|
426
|
+
const args = [];
|
|
427
|
+
if (!this.check(TokenType.RightParen)) {
|
|
428
|
+
args.push(this.parseExpression());
|
|
429
|
+
while (this.check(TokenType.Comma)) {
|
|
430
|
+
this.advance();
|
|
431
|
+
args.push(this.parseExpression());
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.expect(TokenType.RightParen);
|
|
435
|
+
return {
|
|
436
|
+
kind: NodeKind.FunctionCall,
|
|
437
|
+
callee,
|
|
438
|
+
arguments: args,
|
|
439
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// ---- Helpers ----
|
|
443
|
+
current() {
|
|
444
|
+
return this.tokens[this.pos] ?? this.tokens[this.tokens.length - 1];
|
|
445
|
+
}
|
|
446
|
+
previous() {
|
|
447
|
+
return this.tokens[this.pos - 1] ?? this.tokens[0];
|
|
448
|
+
}
|
|
449
|
+
check(type) {
|
|
450
|
+
return this.current().type === type;
|
|
451
|
+
}
|
|
452
|
+
advance() {
|
|
453
|
+
const token = this.current();
|
|
454
|
+
if (!this.isAtEnd()) {
|
|
455
|
+
this.pos++;
|
|
456
|
+
}
|
|
457
|
+
return token;
|
|
458
|
+
}
|
|
459
|
+
expect(type) {
|
|
460
|
+
if (this.check(type)) {
|
|
461
|
+
return this.advance();
|
|
462
|
+
}
|
|
463
|
+
const token = this.current();
|
|
464
|
+
this.error(`Expected ${type} but found '${token.value}' (${token.type}).`);
|
|
465
|
+
// Return a synthetic token to keep parsing
|
|
466
|
+
return {
|
|
467
|
+
type,
|
|
468
|
+
value: '',
|
|
469
|
+
span: token.span,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
isAtEnd() {
|
|
473
|
+
return this.current().type === TokenType.EOF;
|
|
474
|
+
}
|
|
475
|
+
error(message) {
|
|
476
|
+
this.reporter.error(message, this.currentSpan());
|
|
477
|
+
}
|
|
478
|
+
currentSpan() {
|
|
479
|
+
return this.current().span;
|
|
480
|
+
}
|
|
481
|
+
previousSpan() {
|
|
482
|
+
return this.previous().span;
|
|
483
|
+
}
|
|
484
|
+
makeSpan(start, end) {
|
|
485
|
+
return { start: start.start, end: end.end };
|
|
486
|
+
}
|
|
487
|
+
// ---- Block Declaration ----
|
|
488
|
+
parseBlockDecl(decorators) {
|
|
489
|
+
const start = decorators.length > 0 ? decorators[0].span : this.currentSpan();
|
|
490
|
+
this.expect(TokenType.BlockKeyword);
|
|
491
|
+
const nameToken = this.expect(TokenType.StringLiteral);
|
|
492
|
+
this.expect(TokenType.LeftBrace);
|
|
493
|
+
let domain;
|
|
494
|
+
let blockType;
|
|
495
|
+
let metricRef;
|
|
496
|
+
let description;
|
|
497
|
+
let tags;
|
|
498
|
+
let owner;
|
|
499
|
+
let params;
|
|
500
|
+
let query;
|
|
501
|
+
let visualization;
|
|
502
|
+
let tests;
|
|
503
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
504
|
+
if (this.check(TokenType.DomainKeyword)) {
|
|
505
|
+
this.advance();
|
|
506
|
+
this.expect(TokenType.Equals);
|
|
507
|
+
const val = this.expect(TokenType.StringLiteral);
|
|
508
|
+
domain = val.value;
|
|
509
|
+
}
|
|
510
|
+
else if (this.check(TokenType.TypeKeyword)) {
|
|
511
|
+
this.advance();
|
|
512
|
+
this.expect(TokenType.Equals);
|
|
513
|
+
const val = this.expect(TokenType.StringLiteral);
|
|
514
|
+
const raw = val.value;
|
|
515
|
+
if (raw === 'semantic' || raw === 'custom') {
|
|
516
|
+
blockType = raw;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
this.error(`Block type must be "semantic" or "custom", got "${raw}". Use type = "semantic" for dbt metric blocks or type = "custom" for SQL blocks.`);
|
|
520
|
+
blockType = 'custom'; // recover gracefully
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
else if (this.check(TokenType.MetricKeyword)) {
|
|
524
|
+
this.advance();
|
|
525
|
+
this.expect(TokenType.Equals);
|
|
526
|
+
const val = this.expect(TokenType.StringLiteral);
|
|
527
|
+
metricRef = val.value;
|
|
528
|
+
}
|
|
529
|
+
else if (this.check(TokenType.DescriptionKeyword)) {
|
|
530
|
+
this.advance();
|
|
531
|
+
this.expect(TokenType.Equals);
|
|
532
|
+
const val = this.expect(TokenType.StringLiteral);
|
|
533
|
+
description = val.value;
|
|
534
|
+
}
|
|
535
|
+
else if (this.check(TokenType.TagsKeyword)) {
|
|
536
|
+
this.advance();
|
|
537
|
+
this.expect(TokenType.Equals);
|
|
538
|
+
const arrExpr = this.parseArrayLiteral();
|
|
539
|
+
if (arrExpr.kind === NodeKind.ArrayLiteral) {
|
|
540
|
+
tags = arrExpr.elements
|
|
541
|
+
.filter((e) => e.kind === NodeKind.StringLiteral)
|
|
542
|
+
.map((e) => e.value);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
else if (this.check(TokenType.OwnerKeyword)) {
|
|
546
|
+
this.advance();
|
|
547
|
+
this.expect(TokenType.Equals);
|
|
548
|
+
const val = this.expect(TokenType.StringLiteral);
|
|
549
|
+
owner = val.value;
|
|
550
|
+
}
|
|
551
|
+
else if (this.check(TokenType.ParamsKeyword)) {
|
|
552
|
+
params = this.parseBlockParams();
|
|
553
|
+
}
|
|
554
|
+
else if (this.check(TokenType.QueryKeyword)) {
|
|
555
|
+
this.advance();
|
|
556
|
+
this.expect(TokenType.Equals);
|
|
557
|
+
if (this.check(TokenType.TripleQuoteString)) {
|
|
558
|
+
const sqlToken = this.advance();
|
|
559
|
+
const interpolations = this.extractInterpolations(sqlToken.value, sqlToken.span);
|
|
560
|
+
query = {
|
|
561
|
+
kind: NodeKind.SQLQuery,
|
|
562
|
+
rawSQL: sqlToken.value,
|
|
563
|
+
interpolations,
|
|
564
|
+
span: sqlToken.span,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// Fallback: parse as inline SQL
|
|
569
|
+
query = this.parseSQLQuery();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else if (this.check(TokenType.VisualizationKeyword)) {
|
|
573
|
+
visualization = this.parseBlockVisualization();
|
|
574
|
+
}
|
|
575
|
+
else if (this.check(TokenType.TestsKeyword)) {
|
|
576
|
+
tests = this.parseBlockTests();
|
|
577
|
+
}
|
|
578
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
this.error(`Unexpected token '${this.current().value}' inside block. Expected 'domain', 'type', 'metric', 'description', 'tags', 'owner', 'params', 'query', 'visualization', 'tests', or '}'.`);
|
|
583
|
+
this.advance();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!blockType) {
|
|
587
|
+
this.error(`Block "${nameToken.value}" is missing a required type declaration. Add type = "semantic" for dbt metric blocks or type = "custom" for SQL blocks.`);
|
|
588
|
+
blockType = 'custom'; // error-recovery default — keeps downstream analysis working
|
|
589
|
+
}
|
|
590
|
+
this.expect(TokenType.RightBrace);
|
|
591
|
+
return {
|
|
592
|
+
kind: NodeKind.BlockDecl,
|
|
593
|
+
name: nameToken.value,
|
|
594
|
+
domain,
|
|
595
|
+
blockType,
|
|
596
|
+
metricRef,
|
|
597
|
+
description,
|
|
598
|
+
tags,
|
|
599
|
+
owner,
|
|
600
|
+
params,
|
|
601
|
+
query,
|
|
602
|
+
visualization,
|
|
603
|
+
tests,
|
|
604
|
+
decorators,
|
|
605
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
parseBlockParams() {
|
|
609
|
+
const start = this.currentSpan();
|
|
610
|
+
this.expect(TokenType.ParamsKeyword);
|
|
611
|
+
this.expect(TokenType.LeftBrace);
|
|
612
|
+
const paramEntries = [];
|
|
613
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
614
|
+
const paramStart = this.currentSpan();
|
|
615
|
+
const nameToken = this.current();
|
|
616
|
+
if (nameToken.type === TokenType.Identifier || this.isBlockFieldKeyword(nameToken.type)) {
|
|
617
|
+
this.advance();
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
this.error(`Expected parameter name, got '${nameToken.value}'.`);
|
|
621
|
+
this.advance();
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
this.expect(TokenType.Equals);
|
|
625
|
+
const initializer = this.parseExpression();
|
|
626
|
+
paramEntries.push({
|
|
627
|
+
name: nameToken.value,
|
|
628
|
+
initializer,
|
|
629
|
+
span: this.makeSpan(paramStart, this.previousSpan()),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
this.expect(TokenType.RightBrace);
|
|
633
|
+
return {
|
|
634
|
+
kind: NodeKind.BlockParams,
|
|
635
|
+
params: paramEntries,
|
|
636
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
parseBlockVisualization() {
|
|
640
|
+
const start = this.currentSpan();
|
|
641
|
+
this.expect(TokenType.VisualizationKeyword);
|
|
642
|
+
this.expect(TokenType.LeftBrace);
|
|
643
|
+
const properties = [];
|
|
644
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
645
|
+
const propStart = this.currentSpan();
|
|
646
|
+
const nameToken = this.current();
|
|
647
|
+
if (nameToken.type === TokenType.Identifier || this.isBlockFieldKeyword(nameToken.type)) {
|
|
648
|
+
this.advance();
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
this.error(`Expected property name, got '${nameToken.value}'.`);
|
|
652
|
+
this.advance();
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
this.expect(TokenType.Equals);
|
|
656
|
+
const value = this.parseExpression();
|
|
657
|
+
properties.push({
|
|
658
|
+
kind: NodeKind.NamedArg,
|
|
659
|
+
name: nameToken.value,
|
|
660
|
+
value,
|
|
661
|
+
span: this.makeSpan(propStart, this.previousSpan()),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
this.expect(TokenType.RightBrace);
|
|
665
|
+
return {
|
|
666
|
+
kind: NodeKind.BlockVisualization,
|
|
667
|
+
properties,
|
|
668
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
parseBlockTests() {
|
|
672
|
+
const start = this.currentSpan();
|
|
673
|
+
this.expect(TokenType.TestsKeyword);
|
|
674
|
+
this.expect(TokenType.LeftBrace);
|
|
675
|
+
const testNodes = [];
|
|
676
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
677
|
+
if (this.check(TokenType.AssertKeyword)) {
|
|
678
|
+
const assertStart = this.currentSpan();
|
|
679
|
+
this.advance(); // consume 'assert'
|
|
680
|
+
const fieldToken = this.current();
|
|
681
|
+
if (fieldToken.type === TokenType.Identifier || this.isBlockFieldKeyword(fieldToken.type)) {
|
|
682
|
+
this.advance();
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
this.error(`Expected field name after 'assert', got '${fieldToken.value}'.`);
|
|
686
|
+
this.advance();
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
// Parse operator
|
|
690
|
+
let operator;
|
|
691
|
+
if (this.check(TokenType.GreaterThan)) {
|
|
692
|
+
this.advance();
|
|
693
|
+
operator = '>';
|
|
694
|
+
}
|
|
695
|
+
else if (this.check(TokenType.LessThan)) {
|
|
696
|
+
this.advance();
|
|
697
|
+
operator = '<';
|
|
698
|
+
}
|
|
699
|
+
else if (this.check(TokenType.GreaterThanOrEqual)) {
|
|
700
|
+
this.advance();
|
|
701
|
+
operator = '>=';
|
|
702
|
+
}
|
|
703
|
+
else if (this.check(TokenType.LessThanOrEqual)) {
|
|
704
|
+
this.advance();
|
|
705
|
+
operator = '<=';
|
|
706
|
+
}
|
|
707
|
+
else if (this.check(TokenType.DoubleEquals)) {
|
|
708
|
+
this.advance();
|
|
709
|
+
operator = '==';
|
|
710
|
+
}
|
|
711
|
+
else if (this.check(TokenType.NotEquals)) {
|
|
712
|
+
this.advance();
|
|
713
|
+
operator = '!=';
|
|
714
|
+
}
|
|
715
|
+
else if (this.check(TokenType.InKeyword)) {
|
|
716
|
+
this.advance();
|
|
717
|
+
operator = 'IN';
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
this.error(`Expected comparison operator (>, <, >=, <=, ==, !=, IN) after field name, got '${this.current().value}'.`);
|
|
721
|
+
this.advance();
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const expected = this.parseExpression();
|
|
725
|
+
testNodes.push({
|
|
726
|
+
kind: NodeKind.BlockTest,
|
|
727
|
+
field: fieldToken.value,
|
|
728
|
+
operator,
|
|
729
|
+
expected,
|
|
730
|
+
span: this.makeSpan(assertStart, this.previousSpan()),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
this.error(`Unexpected token '${this.current().value}' inside tests. Expected 'assert' or '}'.`);
|
|
738
|
+
this.advance();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
this.expect(TokenType.RightBrace);
|
|
742
|
+
return testNodes;
|
|
743
|
+
}
|
|
744
|
+
isBlockFieldKeyword(type) {
|
|
745
|
+
return type === TokenType.DomainKeyword
|
|
746
|
+
|| type === TokenType.TypeKeyword
|
|
747
|
+
|| type === TokenType.MetricKeyword
|
|
748
|
+
|| type === TokenType.DescriptionKeyword
|
|
749
|
+
|| type === TokenType.TagsKeyword
|
|
750
|
+
|| type === TokenType.OwnerKeyword
|
|
751
|
+
|| type === TokenType.ChartKeyword
|
|
752
|
+
|| type === TokenType.QueryKeyword
|
|
753
|
+
|| type === TokenType.DefaultKeyword
|
|
754
|
+
|| type === TokenType.FromKeyword;
|
|
755
|
+
}
|
|
756
|
+
// ---- Workbook / Page ----
|
|
757
|
+
parseWorkbook(decorators) {
|
|
758
|
+
const start = decorators.length > 0 ? decorators[0].span : this.currentSpan();
|
|
759
|
+
this.expect(TokenType.WorkbookKeyword);
|
|
760
|
+
const titleToken = this.expect(TokenType.StringLiteral);
|
|
761
|
+
this.expect(TokenType.LeftBrace);
|
|
762
|
+
const pages = [];
|
|
763
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
764
|
+
if (this.check(TokenType.PageKeyword)) {
|
|
765
|
+
pages.push(this.parsePage());
|
|
766
|
+
}
|
|
767
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
this.error(`Unexpected token '${this.current().value}' inside workbook. Expected 'page' or '}'.`);
|
|
772
|
+
this.advance();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
this.expect(TokenType.RightBrace);
|
|
776
|
+
return {
|
|
777
|
+
kind: NodeKind.Workbook,
|
|
778
|
+
title: titleToken.value,
|
|
779
|
+
decorators,
|
|
780
|
+
pages,
|
|
781
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
parsePage() {
|
|
785
|
+
const start = this.currentSpan();
|
|
786
|
+
this.expect(TokenType.PageKeyword);
|
|
787
|
+
const titleToken = this.expect(TokenType.StringLiteral);
|
|
788
|
+
this.expect(TokenType.LeftBrace);
|
|
789
|
+
const body = [];
|
|
790
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
791
|
+
const itemDecorators = this.parseDecoratorList();
|
|
792
|
+
if (this.check(TokenType.LetKeyword)) {
|
|
793
|
+
if (itemDecorators.length > 0) {
|
|
794
|
+
this.error('Decorators cannot be applied to variable declarations.');
|
|
795
|
+
}
|
|
796
|
+
body.push(this.parseVariableDecl());
|
|
797
|
+
}
|
|
798
|
+
else if (this.check(TokenType.ParamKeyword)) {
|
|
799
|
+
if (itemDecorators.length > 0) {
|
|
800
|
+
this.error('Decorators cannot be applied to param declarations.');
|
|
801
|
+
}
|
|
802
|
+
body.push(this.parseParamDecl());
|
|
803
|
+
}
|
|
804
|
+
else if (this.check(TokenType.ChartKeyword)) {
|
|
805
|
+
body.push(this.parseChartCall(itemDecorators));
|
|
806
|
+
}
|
|
807
|
+
else if (this.check(TokenType.FilterKeyword)) {
|
|
808
|
+
if (itemDecorators.length > 0) {
|
|
809
|
+
this.error('Decorators cannot be applied to filter declarations.');
|
|
810
|
+
}
|
|
811
|
+
body.push(this.parseFilterCall());
|
|
812
|
+
}
|
|
813
|
+
else if (this.check(TokenType.UseKeyword)) {
|
|
814
|
+
if (itemDecorators.length > 0) {
|
|
815
|
+
this.error('Decorators cannot be applied to use declarations.');
|
|
816
|
+
}
|
|
817
|
+
body.push(this.parseUseDecl());
|
|
818
|
+
}
|
|
819
|
+
else if (this.check(TokenType.LayoutKeyword)) {
|
|
820
|
+
if (itemDecorators.length > 0) {
|
|
821
|
+
this.error('Decorators cannot be applied to layout blocks.');
|
|
822
|
+
}
|
|
823
|
+
body.push(this.parseLayoutBlock());
|
|
824
|
+
}
|
|
825
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
this.error(`Unexpected token '${this.current().value}' inside page. Expected 'let', 'param', 'chart', 'filter', 'use', 'layout', or '}'.`);
|
|
830
|
+
this.advance();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
this.expect(TokenType.RightBrace);
|
|
834
|
+
return {
|
|
835
|
+
kind: NodeKind.Page,
|
|
836
|
+
title: titleToken.value,
|
|
837
|
+
body,
|
|
838
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
// ---- Param Declaration ----
|
|
842
|
+
parseParamDecl() {
|
|
843
|
+
const start = this.currentSpan();
|
|
844
|
+
this.expect(TokenType.ParamKeyword);
|
|
845
|
+
const nameToken = this.expect(TokenType.Identifier);
|
|
846
|
+
// Optional type annotation: param name: type
|
|
847
|
+
let paramType = 'string';
|
|
848
|
+
if (this.check(TokenType.ColonToken)) {
|
|
849
|
+
this.advance(); // consume ':'
|
|
850
|
+
const typeToken = this.expect(TokenType.Identifier);
|
|
851
|
+
const validTypes = ['string', 'number', 'boolean', 'date'];
|
|
852
|
+
if (validTypes.includes(typeToken.value)) {
|
|
853
|
+
paramType = typeToken.value;
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
this.reporter.error(`Invalid param type '${typeToken.value}'. Valid types: ${validTypes.join(', ')}`, typeToken.span);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// Optional default value: = expression
|
|
860
|
+
let defaultValue;
|
|
861
|
+
if (this.check(TokenType.Equals)) {
|
|
862
|
+
this.advance(); // consume '='
|
|
863
|
+
defaultValue = this.parseExpression();
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
kind: NodeKind.ParamDecl,
|
|
867
|
+
name: nameToken.value,
|
|
868
|
+
paramType,
|
|
869
|
+
defaultValue,
|
|
870
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
// ---- Import / Use ----
|
|
874
|
+
parseImportDecl() {
|
|
875
|
+
const start = this.currentSpan();
|
|
876
|
+
this.expect(TokenType.ImportKeyword);
|
|
877
|
+
// import { name1, name2 } from "path"
|
|
878
|
+
this.expect(TokenType.LeftBrace);
|
|
879
|
+
const names = [];
|
|
880
|
+
if (!this.check(TokenType.RightBrace)) {
|
|
881
|
+
names.push(this.expect(TokenType.Identifier).value);
|
|
882
|
+
while (this.check(TokenType.Comma)) {
|
|
883
|
+
this.advance();
|
|
884
|
+
if (this.check(TokenType.RightBrace))
|
|
885
|
+
break; // trailing comma
|
|
886
|
+
names.push(this.expect(TokenType.Identifier).value);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
this.expect(TokenType.RightBrace);
|
|
890
|
+
this.expect(TokenType.FromKeyword);
|
|
891
|
+
const pathToken = this.expect(TokenType.StringLiteral);
|
|
892
|
+
return {
|
|
893
|
+
kind: NodeKind.ImportDecl,
|
|
894
|
+
names,
|
|
895
|
+
path: pathToken.value,
|
|
896
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
parseUseDecl() {
|
|
900
|
+
const start = this.currentSpan();
|
|
901
|
+
this.expect(TokenType.UseKeyword);
|
|
902
|
+
let nameToken;
|
|
903
|
+
if (this.check(TokenType.Identifier) || this.check(TokenType.StringLiteral)) {
|
|
904
|
+
nameToken = this.advance();
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
nameToken = this.expect(TokenType.Identifier);
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
kind: NodeKind.UseDecl,
|
|
911
|
+
name: nameToken.value,
|
|
912
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
// ---- Layout ----
|
|
916
|
+
parseLayoutBlock() {
|
|
917
|
+
const start = this.currentSpan();
|
|
918
|
+
this.expect(TokenType.LayoutKeyword);
|
|
919
|
+
// Optional: layout(columns = 12)
|
|
920
|
+
let columns = 12;
|
|
921
|
+
if (this.check(TokenType.LeftParen)) {
|
|
922
|
+
this.advance();
|
|
923
|
+
const args = [];
|
|
924
|
+
if (!this.check(TokenType.RightParen)) {
|
|
925
|
+
args.push(this.parseNamedArg());
|
|
926
|
+
while (this.check(TokenType.Comma)) {
|
|
927
|
+
this.advance();
|
|
928
|
+
if (this.check(TokenType.RightParen))
|
|
929
|
+
break;
|
|
930
|
+
args.push(this.parseNamedArg());
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
this.expect(TokenType.RightParen);
|
|
934
|
+
const colArg = args.find((a) => a.name === 'columns');
|
|
935
|
+
if (colArg && colArg.value.kind === NodeKind.NumberLiteral) {
|
|
936
|
+
columns = colArg.value.value;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
this.expect(TokenType.LeftBrace);
|
|
940
|
+
const rows = [];
|
|
941
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
942
|
+
if (this.check(TokenType.RowKeyword)) {
|
|
943
|
+
rows.push(this.parseLayoutRow());
|
|
944
|
+
}
|
|
945
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
this.error(`Unexpected token '${this.current().value}' inside layout. Expected 'row' or '}'.`);
|
|
950
|
+
this.advance();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
this.expect(TokenType.RightBrace);
|
|
954
|
+
return {
|
|
955
|
+
kind: NodeKind.LayoutBlock,
|
|
956
|
+
columns,
|
|
957
|
+
rows,
|
|
958
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
parseLayoutRow() {
|
|
962
|
+
const start = this.currentSpan();
|
|
963
|
+
this.expect(TokenType.RowKeyword);
|
|
964
|
+
this.expect(TokenType.LeftBrace);
|
|
965
|
+
const items = [];
|
|
966
|
+
while (!this.check(TokenType.RightBrace) && !this.isAtEnd()) {
|
|
967
|
+
const itemDecorators = this.parseDecoratorList();
|
|
968
|
+
let node;
|
|
969
|
+
if (this.check(TokenType.ChartKeyword)) {
|
|
970
|
+
node = this.parseChartCall(itemDecorators);
|
|
971
|
+
}
|
|
972
|
+
else if (this.check(TokenType.FilterKeyword)) {
|
|
973
|
+
if (itemDecorators.length > 0) {
|
|
974
|
+
this.error('Decorators cannot be applied to filter declarations.');
|
|
975
|
+
}
|
|
976
|
+
node = this.parseFilterCall();
|
|
977
|
+
}
|
|
978
|
+
else if (this.check(TokenType.RightBrace)) {
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
this.error(`Unexpected token '${this.current().value}' inside row. Expected 'chart', 'filter', or '}'.`);
|
|
983
|
+
this.advance();
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
// Check for optional 'span N' after the chart/filter call
|
|
987
|
+
let span;
|
|
988
|
+
if (this.check(TokenType.Identifier) && this.current().value === 'span') {
|
|
989
|
+
this.advance(); // consume 'span'
|
|
990
|
+
const spanToken = this.expect(TokenType.NumberLiteral);
|
|
991
|
+
span = Number(spanToken.value);
|
|
992
|
+
}
|
|
993
|
+
items.push({ node, span });
|
|
994
|
+
}
|
|
995
|
+
this.expect(TokenType.RightBrace);
|
|
996
|
+
return {
|
|
997
|
+
kind: NodeKind.LayoutRow,
|
|
998
|
+
items,
|
|
999
|
+
span: this.makeSpan(start, this.previousSpan()),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
// ---- Helpers ----
|
|
1003
|
+
synchronize() {
|
|
1004
|
+
while (!this.isAtEnd()) {
|
|
1005
|
+
const token = this.current();
|
|
1006
|
+
if (token.type === TokenType.DashboardKeyword ||
|
|
1007
|
+
token.type === TokenType.WorkbookKeyword ||
|
|
1008
|
+
token.type === TokenType.ChartKeyword ||
|
|
1009
|
+
token.type === TokenType.FilterKeyword ||
|
|
1010
|
+
token.type === TokenType.ImportKeyword ||
|
|
1011
|
+
token.type === TokenType.BlockKeyword ||
|
|
1012
|
+
token.type === TokenType.AtSign ||
|
|
1013
|
+
token.type === TokenType.RightBrace) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
this.advance();
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
export function parse(source, file) {
|
|
1021
|
+
const parser = new Parser(source, file);
|
|
1022
|
+
return parser.parse();
|
|
1023
|
+
}
|
|
1024
|
+
//# sourceMappingURL=parser.js.map
|