@dforge-core/metadata 0.0.20 → 0.0.23
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 +154 -0
- package/README.md +47 -1
- package/dist/actions-DvYTrCbP.d.ts +60 -0
- package/dist/dsl/index.d.ts +223 -0
- package/dist/dsl/index.js +1108 -0
- package/dist/dsl/index.js.map +1 -0
- package/dist/index.d.ts +3 -72
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/schemas/entity.schema.json +49 -5
- package/schemas/jobs.schema.json +2 -2
- package/schemas/reports.schema.json +1 -1
- package/src/dsl/builtins.ts +210 -0
- package/src/dsl/check.ts +450 -0
- package/src/dsl/index.ts +36 -0
- package/src/dsl/lexer.ts +364 -0
- package/src/dsl/parse.ts +639 -0
- package/src/dsl/types.ts +90 -0
- package/src/field-types.ts +3 -3
package/src/dsl/parse.ts
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// Structural scan of a .dsl file: blocks, param declarations, record-field
|
|
2
|
+
// reads, ref navigation chains and built-in calls — everything the diagnostics,
|
|
3
|
+
// completion, hover and definition features need, and nothing else.
|
|
4
|
+
|
|
5
|
+
import { CONTROL_KEYWORDS, type Token, tokenize, stringValue } from "./lexer";
|
|
6
|
+
|
|
7
|
+
export type BlockKind =
|
|
8
|
+
| "params"
|
|
9
|
+
| "canExecute"
|
|
10
|
+
| "schema"
|
|
11
|
+
| "onBeforeStart"
|
|
12
|
+
| "execute";
|
|
13
|
+
|
|
14
|
+
/** In the order `ActionDslCompiler.ParseBlocks` expects them in a file. */
|
|
15
|
+
export const BLOCK_KINDS: BlockKind[] = [
|
|
16
|
+
"params",
|
|
17
|
+
"canExecute",
|
|
18
|
+
"schema",
|
|
19
|
+
"onBeforeStart",
|
|
20
|
+
"execute",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const BLOCK_BY_LOWER = new Map(BLOCK_KINDS.map((k) => [k.toLowerCase(), k]));
|
|
24
|
+
|
|
25
|
+
export interface Span {
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DslBlock {
|
|
31
|
+
kind: BlockKind;
|
|
32
|
+
label: Span;
|
|
33
|
+
/** Span of the block body — label end to the next label (or EOF). */
|
|
34
|
+
body: Span;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DslParam {
|
|
38
|
+
name: string;
|
|
39
|
+
nameSpan: Span;
|
|
40
|
+
/** fieldTypeCd, or the referenced entity code when `isRef`. */
|
|
41
|
+
type: string;
|
|
42
|
+
typeSpan: Span;
|
|
43
|
+
isRef: boolean;
|
|
44
|
+
required: boolean;
|
|
45
|
+
label?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A `[field]` read. `navigation` holds the property hops that follow it, each
|
|
50
|
+
* remembering whether it was written `.[target]` or `.target`: the compiler
|
|
51
|
+
* rewrites the FIRST hop either way, and then only a surviving `.[` is the
|
|
52
|
+
* multi-hop compile error. A dotted tail is plain JavaScript on the value the
|
|
53
|
+
* hop returned, so `[customer].[code]`, `[a].b.c` and `[plate].length` all
|
|
54
|
+
* compile.
|
|
55
|
+
*/
|
|
56
|
+
export interface FieldRef {
|
|
57
|
+
name: string;
|
|
58
|
+
span: Span;
|
|
59
|
+
/** Span including the brackets. */
|
|
60
|
+
outerSpan: Span;
|
|
61
|
+
navigation: Array<{ name: string; span: Span; bracketed: boolean }>;
|
|
62
|
+
block: BlockKind | null;
|
|
63
|
+
/**
|
|
64
|
+
* False when the read is bound to a numbered record rather than the current
|
|
65
|
+
* one — `records[0][status]`, which `RxRecordsFieldRead` rewrites ahead of
|
|
66
|
+
* the generic `[field]` pass. The name is still a column of the entity, so
|
|
67
|
+
* the column rules apply; the batch-mode rule does not.
|
|
68
|
+
*/
|
|
69
|
+
isCurrentRecord: boolean;
|
|
70
|
+
/** Index of the token that opens this read — the `[`. */
|
|
71
|
+
startIndex: number;
|
|
72
|
+
/**
|
|
73
|
+
* Index of the last token this read consumes, navigation included. A hop is
|
|
74
|
+
* 2 tokens as `.prop` but 4 as `.[prop]`, so callers that need the token
|
|
75
|
+
* after the read cannot compute it from `navigation.length`.
|
|
76
|
+
*/
|
|
77
|
+
endIndex: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `params[x]` / `old[x]` — subscript reads off a DSL global. */
|
|
81
|
+
export interface GlobalRef {
|
|
82
|
+
global: "params" | "old" | "records";
|
|
83
|
+
property: string;
|
|
84
|
+
span: Span;
|
|
85
|
+
block: BlockKind | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CallRef {
|
|
89
|
+
name: string;
|
|
90
|
+
nameSpan: Span;
|
|
91
|
+
/** First argument when it is a string literal — the entity code, usually. */
|
|
92
|
+
firstStringArg?: { value: string; span: Span };
|
|
93
|
+
block: BlockKind | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DslDocument {
|
|
97
|
+
blocks: DslBlock[];
|
|
98
|
+
params: DslParam[];
|
|
99
|
+
fieldRefs: FieldRef[];
|
|
100
|
+
globalRefs: GlobalRef[];
|
|
101
|
+
calls: CallRef[];
|
|
102
|
+
tokens: Token[];
|
|
103
|
+
/**
|
|
104
|
+
* Every mention of `records`, subscript (`records[x]`) or bare
|
|
105
|
+
* (`for x in records`). `globalRefs` only holds the subscript form, so a
|
|
106
|
+
* rule that needs to point at the batch loop has nothing to anchor to.
|
|
107
|
+
*/
|
|
108
|
+
recordsRefs: Span[];
|
|
109
|
+
/** Names bound by `var`/`let`/`const` or a `for` head, plus declared params. */
|
|
110
|
+
locals: Set<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Loop variables bound by `for x in records {` — `RxForLoop` and nothing
|
|
113
|
+
* else, so an ordinary `for (var x of xs)` is not one. Their `x[field]`
|
|
114
|
+
* subscripts read the entity's columns, which is why they come back as
|
|
115
|
+
* field reads.
|
|
116
|
+
*/
|
|
117
|
+
recordsLoopVars: Set<string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const GLOBALS = new Set(["params", "old", "records"]);
|
|
121
|
+
|
|
122
|
+
export function parseDsl(text: string): DslDocument {
|
|
123
|
+
const all = tokenize(text);
|
|
124
|
+
const tokens = all.filter((t) => t.kind !== "comment");
|
|
125
|
+
|
|
126
|
+
const blocks = findBlocks(text, tokens);
|
|
127
|
+
const blockAt = (offset: number): BlockKind | null =>
|
|
128
|
+
blocks.find((b) => offset >= b.body.start && offset < b.body.end)?.kind ??
|
|
129
|
+
null;
|
|
130
|
+
|
|
131
|
+
const params = parseParams(tokens, blocks);
|
|
132
|
+
const fieldRefs: FieldRef[] = [];
|
|
133
|
+
const globalRefs: GlobalRef[] = [];
|
|
134
|
+
const calls: CallRef[] = [];
|
|
135
|
+
const recordsRefs: Span[] = [];
|
|
136
|
+
const locals = collectLocals(tokens, params);
|
|
137
|
+
const recordsLoopVars = collectRecordsLoopVars(tokens);
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
140
|
+
const t = tokens[i]!;
|
|
141
|
+
|
|
142
|
+
if (t.kind === "punct" && t.text === "[" && isFieldAccessStart(text, t.start)) {
|
|
143
|
+
const name = tokens[i + 1];
|
|
144
|
+
const close = tokens[i + 2];
|
|
145
|
+
if (name?.kind === "ident" && close?.text === "]") {
|
|
146
|
+
const ref: FieldRef = {
|
|
147
|
+
name: name.text,
|
|
148
|
+
span: { start: name.start, end: name.end },
|
|
149
|
+
outerSpan: { start: t.start, end: close.end },
|
|
150
|
+
navigation: [],
|
|
151
|
+
block: blockAt(t.start),
|
|
152
|
+
isCurrentRecord: true,
|
|
153
|
+
startIndex: i,
|
|
154
|
+
endIndex: i + 2,
|
|
155
|
+
};
|
|
156
|
+
ref.endIndex = collectNavigation(tokens, i + 2, ref);
|
|
157
|
+
i = ref.endIndex;
|
|
158
|
+
fieldRefs.push(ref);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `x.records` is a property, not the batch global.
|
|
164
|
+
if (t.kind === "ident" && t.text === "records" && tokens[i - 1]?.text !== ".") {
|
|
165
|
+
recordsRefs.push({ start: t.start, end: t.end });
|
|
166
|
+
|
|
167
|
+
// `records[0][status]` is one read of a numbered record. Taken
|
|
168
|
+
// apart, its tail looks exactly like a current-record read — which
|
|
169
|
+
// is why the compiler rewrites this form first.
|
|
170
|
+
const field = indexedRecordField(tokens, i);
|
|
171
|
+
if (field) {
|
|
172
|
+
fieldRefs.push({
|
|
173
|
+
name: field.name.text,
|
|
174
|
+
span: { start: field.name.start, end: field.name.end },
|
|
175
|
+
outerSpan: { start: field.open.start, end: field.close.end },
|
|
176
|
+
navigation: [],
|
|
177
|
+
block: blockAt(t.start),
|
|
178
|
+
isCurrentRecord: false,
|
|
179
|
+
startIndex: field.openIndex,
|
|
180
|
+
endIndex: field.openIndex + 2,
|
|
181
|
+
});
|
|
182
|
+
i = field.openIndex + 2;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// `x[field]` on a batch loop variable. The compiler rewrites it to
|
|
188
|
+
// `x.get('field')` and checks the name against the entity's columns
|
|
189
|
+
// exactly as it checks a bare `[field]`.
|
|
190
|
+
if (t.kind === "ident" && recordsLoopVars.has(t.text)) {
|
|
191
|
+
const open = tokens[i + 1];
|
|
192
|
+
const name = tokens[i + 2];
|
|
193
|
+
const close = tokens[i + 3];
|
|
194
|
+
if (open?.text === "[" && name?.kind === "ident" && close?.text === "]") {
|
|
195
|
+
fieldRefs.push({
|
|
196
|
+
name: name.text,
|
|
197
|
+
span: { start: name.start, end: name.end },
|
|
198
|
+
outerSpan: { start: open.start, end: close.end },
|
|
199
|
+
navigation: [],
|
|
200
|
+
block: blockAt(t.start),
|
|
201
|
+
isCurrentRecord: false,
|
|
202
|
+
startIndex: i + 1,
|
|
203
|
+
endIndex: i + 3,
|
|
204
|
+
});
|
|
205
|
+
i += 3;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (t.kind === "ident" && GLOBALS.has(t.text)) {
|
|
211
|
+
const open = tokens[i + 1];
|
|
212
|
+
const prop = tokens[i + 2];
|
|
213
|
+
const close = tokens[i + 3];
|
|
214
|
+
if (open?.text === "[" && prop?.kind === "ident" && close?.text === "]") {
|
|
215
|
+
globalRefs.push({
|
|
216
|
+
global: t.text as GlobalRef["global"],
|
|
217
|
+
property: prop.text,
|
|
218
|
+
span: { start: prop.start, end: prop.end },
|
|
219
|
+
block: blockAt(t.start),
|
|
220
|
+
});
|
|
221
|
+
i += 3;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (t.kind === "ident" && tokens[i + 1]?.text === "(") {
|
|
227
|
+
const prev = tokens[i - 1];
|
|
228
|
+
// `.method(` is a call on a value, not a DSL built-in.
|
|
229
|
+
if (prev?.text === ".") continue;
|
|
230
|
+
const call: CallRef = {
|
|
231
|
+
name: t.text,
|
|
232
|
+
nameSpan: { start: t.start, end: t.end },
|
|
233
|
+
block: blockAt(t.start),
|
|
234
|
+
};
|
|
235
|
+
const firstArg = tokens[i + 2];
|
|
236
|
+
if (firstArg?.kind === "string") {
|
|
237
|
+
call.firstStringArg = {
|
|
238
|
+
value: stringValue(firstArg),
|
|
239
|
+
span: { start: firstArg.start, end: firstArg.end },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
calls.push(call);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
blocks,
|
|
248
|
+
params,
|
|
249
|
+
fieldRefs,
|
|
250
|
+
globalRefs,
|
|
251
|
+
calls,
|
|
252
|
+
tokens,
|
|
253
|
+
recordsRefs,
|
|
254
|
+
locals,
|
|
255
|
+
recordsLoopVars,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `[` opens a record-field read unless a word character sits immediately
|
|
261
|
+
* before it — the compiler's `(?<!\w)\[(\w+)\]`, character for character.
|
|
262
|
+
*
|
|
263
|
+
* It has to be the character and not the previous token: the lookbehind does
|
|
264
|
+
* not skip whitespace, so `rec[qty]` is a subscript but `[qty]` opening a line
|
|
265
|
+
* is a read however the line above ended. The same lookbehind is what keeps
|
|
266
|
+
* `params[x]` and `old[x]` out of the field reads.
|
|
267
|
+
*
|
|
268
|
+
* A single-element array literal (`var ids = [orderId]`) is genuinely
|
|
269
|
+
* ambiguous with a field read at this level: both are `[ident]` in expression
|
|
270
|
+
* position, and `var total = [qty]` is a real field read. The disambiguation
|
|
271
|
+
* needs to know whether the name is a column or a local, so it happens in the
|
|
272
|
+
* diagnostics layer against `locals` rather than here.
|
|
273
|
+
*/
|
|
274
|
+
function isFieldAccessStart(text: string, start: number): boolean {
|
|
275
|
+
return start === 0 || !/\w/.test(text[start - 1]!);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The `[field]` half of a `records[n][field]` read starting at `i`, or null.
|
|
280
|
+
*
|
|
281
|
+
* Mirrors `RxRecordsFieldRead`'s `\brecords\[(\d+)\]\[(\w+)\]`: a literal
|
|
282
|
+
* digit index only. `records[i][x]` with a variable index is not this form for
|
|
283
|
+
* the compiler either — it leaves `records[i]` alone and then rewrites the tail
|
|
284
|
+
* as a current-record read.
|
|
285
|
+
*/
|
|
286
|
+
function indexedRecordField(
|
|
287
|
+
tokens: Token[],
|
|
288
|
+
i: number,
|
|
289
|
+
): { name: Token; open: Token; close: Token; openIndex: number } | null {
|
|
290
|
+
const index = tokens[i + 2];
|
|
291
|
+
if (tokens[i + 1]?.text !== "[") return null;
|
|
292
|
+
if (index?.kind !== "number" || !/^\d+$/.test(index.text)) return null;
|
|
293
|
+
if (tokens[i + 3]?.text !== "]") return null;
|
|
294
|
+
|
|
295
|
+
const open = tokens[i + 4];
|
|
296
|
+
const name = tokens[i + 5];
|
|
297
|
+
const close = tokens[i + 6];
|
|
298
|
+
if (open?.text !== "[" || name?.kind !== "ident" || close?.text !== "]")
|
|
299
|
+
return null;
|
|
300
|
+
return { name, open, close, openIndex: i + 4 };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Walk `.prop` / `.[prop]` hops after a field read. Returns the last index consumed. */
|
|
304
|
+
function collectNavigation(tokens: Token[], closeIdx: number, ref: FieldRef): number {
|
|
305
|
+
let i = closeIdx;
|
|
306
|
+
for (;;) {
|
|
307
|
+
const dot = tokens[i + 1];
|
|
308
|
+
if (dot?.text !== ".") return i;
|
|
309
|
+
const next = tokens[i + 2];
|
|
310
|
+
if (next?.kind === "ident") {
|
|
311
|
+
// A method call is not navigation — `[items].length` is, `[x].trim()` isn't.
|
|
312
|
+
if (tokens[i + 3]?.text === "(") return i;
|
|
313
|
+
ref.navigation.push({
|
|
314
|
+
name: next.text,
|
|
315
|
+
span: { start: next.start, end: next.end },
|
|
316
|
+
bracketed: false,
|
|
317
|
+
});
|
|
318
|
+
i += 2;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (next?.text === "[") {
|
|
322
|
+
const name = tokens[i + 3];
|
|
323
|
+
const close = tokens[i + 4];
|
|
324
|
+
if (name?.kind === "ident" && close?.text === "]") {
|
|
325
|
+
ref.navigation.push({
|
|
326
|
+
name: name.text,
|
|
327
|
+
span: { start: name.start, end: name.end },
|
|
328
|
+
bracketed: true,
|
|
329
|
+
});
|
|
330
|
+
i += 4;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return i;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `for x in records {` — `RxForLoop`'s shape, brace included. A loop written
|
|
340
|
+
* any other way is not a batch loop to the compiler either, and its variable
|
|
341
|
+
* does not carry record fields.
|
|
342
|
+
*/
|
|
343
|
+
function collectRecordsLoopVars(tokens: Token[]): Set<string> {
|
|
344
|
+
const vars = new Set<string>();
|
|
345
|
+
for (let i = 0; i < tokens.length - 4; i++) {
|
|
346
|
+
if (tokens[i]!.text !== "for" || tokens[i]!.kind !== "ident") continue;
|
|
347
|
+
const name = tokens[i + 1]!;
|
|
348
|
+
if (name.kind !== "ident") continue;
|
|
349
|
+
if (tokens[i + 2]!.text !== "in" || tokens[i + 3]!.text !== "records") continue;
|
|
350
|
+
if (tokens[i + 4]!.text !== "{") continue;
|
|
351
|
+
vars.add(name.text);
|
|
352
|
+
}
|
|
353
|
+
return vars;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const DECLARATORS = new Set(["var", "let", "const"]);
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Names bound in the script itself. Used to tell `var ids = [orderId]` (an
|
|
360
|
+
* array literal holding a local) from `[qty]` (a record-field read) — the two
|
|
361
|
+
* are indistinguishable by token shape alone.
|
|
362
|
+
*/
|
|
363
|
+
function collectLocals(tokens: Token[], params: DslParam[]): Set<string> {
|
|
364
|
+
const locals = new Set<string>(params.map((p) => p.name));
|
|
365
|
+
collectFunctionParams(tokens, locals);
|
|
366
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
367
|
+
const t = tokens[i]!;
|
|
368
|
+
if (t.kind !== "ident") continue;
|
|
369
|
+
|
|
370
|
+
if (DECLARATORS.has(t.text)) {
|
|
371
|
+
collectDeclarations(tokens, i + 1, locals);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// `for x in records` / `for (var x of xs)` — the loop variable.
|
|
376
|
+
if (t.text === "for") {
|
|
377
|
+
let j = i + 1;
|
|
378
|
+
if (tokens[j]?.text === "(") j++;
|
|
379
|
+
if (tokens[j]?.kind === "ident" && DECLARATORS.has(tokens[j]!.text)) j++;
|
|
380
|
+
const name = tokens[j];
|
|
381
|
+
if (name?.kind === "ident" && !DECLARATORS.has(name.text)) locals.add(name.text);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return locals;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Parameter names of every function in the script — declarations, expressions,
|
|
389
|
+
* arrows and method shorthand. They bind names exactly as `var` does, so
|
|
390
|
+
* `function wrap(value) { return [value] }` holds an array literal rather than
|
|
391
|
+
* a field read.
|
|
392
|
+
*/
|
|
393
|
+
function collectFunctionParams(tokens: Token[], locals: Set<string>): void {
|
|
394
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
395
|
+
const t = tokens[i]!;
|
|
396
|
+
|
|
397
|
+
if (t.kind === "ident" && t.text === "function") {
|
|
398
|
+
let j = i + 1;
|
|
399
|
+
if (tokens[j]?.kind === "ident") j++; // the name, when it has one
|
|
400
|
+
if (tokens[j]?.text === "(") collectParenIdents(tokens, j, locals);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (t.text === "=>") {
|
|
405
|
+
const prev = tokens[i - 1];
|
|
406
|
+
// `x => …` binds one name; `(x, y) => …` binds the list.
|
|
407
|
+
if (prev?.kind === "ident") locals.add(prev.text);
|
|
408
|
+
else if (prev?.text === ")") {
|
|
409
|
+
const open = matchingOpenParen(tokens, i - 1);
|
|
410
|
+
if (open >= 0) collectParenIdents(tokens, open, locals);
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Method shorthand `f(a, b) { … }`. The body brace is what tells it from
|
|
416
|
+
// a call, and a control keyword's `(…)` is a condition, not a list.
|
|
417
|
+
if (
|
|
418
|
+
t.kind === "ident" &&
|
|
419
|
+
!CONTROL_KEYWORDS.has(t.text) &&
|
|
420
|
+
tokens[i + 1]?.text === "("
|
|
421
|
+
) {
|
|
422
|
+
const close = matchingCloseParen(tokens, i + 1);
|
|
423
|
+
if (close >= 0 && tokens[close + 1]?.text === "{")
|
|
424
|
+
collectParenIdents(tokens, i + 1, locals);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Every ident between the `(` at `openIndex` and its match. Over-collects a
|
|
431
|
+
* default value's names (`f(x = y)`), which only ever suppresses a diagnostic —
|
|
432
|
+
* the same fail-open trade `collectPattern` makes.
|
|
433
|
+
*/
|
|
434
|
+
function collectParenIdents(
|
|
435
|
+
tokens: Token[],
|
|
436
|
+
openIndex: number,
|
|
437
|
+
locals: Set<string>,
|
|
438
|
+
): void {
|
|
439
|
+
let depth = 0;
|
|
440
|
+
for (let i = openIndex; i < tokens.length; i++) {
|
|
441
|
+
const t = tokens[i]!;
|
|
442
|
+
if (t.text === "(") depth++;
|
|
443
|
+
else if (t.text === ")") {
|
|
444
|
+
depth--;
|
|
445
|
+
if (depth === 0) return;
|
|
446
|
+
} else if (t.kind === "ident") locals.add(t.text);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function matchingCloseParen(tokens: Token[], openIndex: number): number {
|
|
451
|
+
let depth = 0;
|
|
452
|
+
for (let i = openIndex; i < tokens.length; i++) {
|
|
453
|
+
if (tokens[i]!.text === "(") depth++;
|
|
454
|
+
else if (tokens[i]!.text === ")" && --depth === 0) return i;
|
|
455
|
+
}
|
|
456
|
+
return -1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function matchingOpenParen(tokens: Token[], closeIndex: number): number {
|
|
460
|
+
let depth = 0;
|
|
461
|
+
for (let i = closeIndex; i >= 0; i--) {
|
|
462
|
+
if (tokens[i]!.text === ")") depth++;
|
|
463
|
+
else if (tokens[i]!.text === "(" && --depth === 0) return i;
|
|
464
|
+
}
|
|
465
|
+
return -1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The binding list after a declarator: `a = 1, b = 2`, where each binding is
|
|
470
|
+
* either a name or a destructuring pattern. Scans ahead without moving the
|
|
471
|
+
* caller's cursor, so a `var` nested in an initializer is still visited.
|
|
472
|
+
*/
|
|
473
|
+
function collectDeclarations(tokens: Token[], start: number, locals: Set<string>): void {
|
|
474
|
+
let j = start;
|
|
475
|
+
for (;;) {
|
|
476
|
+
const head = tokens[j];
|
|
477
|
+
if (!head) return;
|
|
478
|
+
|
|
479
|
+
if (head.text === "{" || head.text === "[") {
|
|
480
|
+
j = collectPattern(tokens, j, locals);
|
|
481
|
+
} else if (head.kind === "ident" && !DECLARATORS.has(head.text)) {
|
|
482
|
+
locals.add(head.text);
|
|
483
|
+
j++;
|
|
484
|
+
} else return;
|
|
485
|
+
|
|
486
|
+
// Skip this binding's initializer and stop at the comma opening the
|
|
487
|
+
// next one. Commas inside the initializer sit at depth 1+ (`f(x, y)`),
|
|
488
|
+
// which is what keeps its arguments out of `locals`.
|
|
489
|
+
let depth = 0;
|
|
490
|
+
for (; j < tokens.length; j++) {
|
|
491
|
+
const n = tokens[j]!;
|
|
492
|
+
if (n.text === "(" || n.text === "[" || n.text === "{") depth++;
|
|
493
|
+
else if (n.text === ")" || n.text === "]" || n.text === "}") depth--;
|
|
494
|
+
if (depth < 0) return;
|
|
495
|
+
if (depth > 0) continue;
|
|
496
|
+
if (n.text === ";") return;
|
|
497
|
+
if (n.text === ",") break;
|
|
498
|
+
// A new line at depth 0 ends the statement; the alternative is
|
|
499
|
+
// running to end-of-file on a declaration with no terminator.
|
|
500
|
+
if (n.startsLine) return;
|
|
501
|
+
}
|
|
502
|
+
if (j >= tokens.length) return;
|
|
503
|
+
j++;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Every ident inside a `{…}` / `[…]` binding pattern; returns the index after
|
|
509
|
+
* it. The names of a pattern sit at depth 1+, out of reach of the comma rule
|
|
510
|
+
* in `collectDeclarations`. This over-collects — the `a` of `{ a: b }` binds
|
|
511
|
+
* nothing — but a name in `locals` only ever suppresses a diagnostic, so the
|
|
512
|
+
* error is in the fail-open direction.
|
|
513
|
+
*/
|
|
514
|
+
function collectPattern(tokens: Token[], start: number, locals: Set<string>): number {
|
|
515
|
+
let depth = 0;
|
|
516
|
+
let i = start;
|
|
517
|
+
for (; i < tokens.length; i++) {
|
|
518
|
+
const t = tokens[i]!;
|
|
519
|
+
if (t.text === "{" || t.text === "[") depth++;
|
|
520
|
+
else if (t.text === "}" || t.text === "]") {
|
|
521
|
+
depth--;
|
|
522
|
+
if (depth === 0) return i + 1;
|
|
523
|
+
} else if (t.kind === "ident") locals.add(t.text);
|
|
524
|
+
}
|
|
525
|
+
return i;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The block kind labelled at `i`, wherever on the line it sits.
|
|
530
|
+
*
|
|
531
|
+
* `findBlocks` additionally requires column 0, which is what the compiler's
|
|
532
|
+
* `^execute:` anchor means. The un-anchored form exists for the diagnostic
|
|
533
|
+
* that reports an indented header — body text as far as the compiler is
|
|
534
|
+
* concerned, leaving the block it meant to open empty.
|
|
535
|
+
*/
|
|
536
|
+
export function blockLabelAt(tokens: Token[], i: number): BlockKind | null {
|
|
537
|
+
const t = tokens[i];
|
|
538
|
+
if (!t || t.kind !== "ident" || !t.startsLine) return null;
|
|
539
|
+
const colon = tokens[i + 1];
|
|
540
|
+
// `params :` does not match `^params:` either.
|
|
541
|
+
if (colon?.text !== ":" || colon.start !== t.end) return null;
|
|
542
|
+
return BLOCK_BY_LOWER.get(t.text.toLowerCase()) ?? null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function findBlocks(text: string, tokens: Token[]): DslBlock[] {
|
|
546
|
+
const labels: Array<{ kind: BlockKind; span: Span }> = [];
|
|
547
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
548
|
+
const t = tokens[i]!;
|
|
549
|
+
// Column 0, like the compiler's `^`-anchored headers: an indented
|
|
550
|
+
// `params:` is an object key inside a block body, not a new block.
|
|
551
|
+
if (t.character !== 0) continue;
|
|
552
|
+
const kind = blockLabelAt(tokens, i);
|
|
553
|
+
if (!kind) continue;
|
|
554
|
+
labels.push({ kind, span: { start: t.start, end: tokens[i + 1]!.end } });
|
|
555
|
+
// `execute:` is `(.*?)\z` — it runs to end-of-file, so a later header
|
|
556
|
+
// is body text.
|
|
557
|
+
if (kind === "execute") break;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return labels.map((l, idx) => ({
|
|
561
|
+
kind: l.kind,
|
|
562
|
+
label: l.span,
|
|
563
|
+
body: {
|
|
564
|
+
start: l.span.end,
|
|
565
|
+
end: labels[idx + 1]?.span.start ?? text.length,
|
|
566
|
+
},
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseParams(tokens: Token[], blocks: DslBlock[]): DslParam[] {
|
|
571
|
+
const block = blocks.find((b) => b.kind === "params");
|
|
572
|
+
if (!block) return [];
|
|
573
|
+
|
|
574
|
+
const inBlock = tokens.filter(
|
|
575
|
+
(t) => t.start >= block.body.start && t.start < block.body.end,
|
|
576
|
+
);
|
|
577
|
+
const params: DslParam[] = [];
|
|
578
|
+
|
|
579
|
+
for (let i = 0; i < inBlock.length; i++) {
|
|
580
|
+
const name = inBlock[i]!;
|
|
581
|
+
// One declaration per line — and `^params:\s*` swallows the newline, so
|
|
582
|
+
// the header's own line counts: `params: qty: number required` declares
|
|
583
|
+
// qty exactly as an indented line below would.
|
|
584
|
+
if ((!name.startsLine && i !== 0) || name.kind !== "ident") continue;
|
|
585
|
+
if (inBlock[i + 1]?.text !== ":") continue;
|
|
586
|
+
|
|
587
|
+
let j = i + 2;
|
|
588
|
+
let isRef = false;
|
|
589
|
+
if (inBlock[j]?.kind === "ident" && inBlock[j]!.text === "ref") {
|
|
590
|
+
isRef = true;
|
|
591
|
+
j++;
|
|
592
|
+
}
|
|
593
|
+
const type = inBlock[j];
|
|
594
|
+
if (!type || type.kind !== "ident") continue;
|
|
595
|
+
// RxParamDecl matches one trimmed line, so the type shares the name's
|
|
596
|
+
// line. Without this a bare `execute:` swallows the next line's first
|
|
597
|
+
// word as its type and passes for a declaration.
|
|
598
|
+
if (type.line !== name.line) continue;
|
|
599
|
+
|
|
600
|
+
// A `ref` target may be qualified and hyphenated: `ref fin-ch.invoice`.
|
|
601
|
+
// The lexer splits on `-` and `.`, so glue back any pieces that are
|
|
602
|
+
// physically adjacent — a space means the name ended.
|
|
603
|
+
let typeEnd = type.end;
|
|
604
|
+
let typeText = type.text;
|
|
605
|
+
if (isRef) {
|
|
606
|
+
while (
|
|
607
|
+
inBlock[j + 1] &&
|
|
608
|
+
(inBlock[j + 1]!.text === "-" || inBlock[j + 1]!.text === ".") &&
|
|
609
|
+
inBlock[j + 2]?.kind === "ident" &&
|
|
610
|
+
inBlock[j + 1]!.start === typeEnd &&
|
|
611
|
+
inBlock[j + 2]!.start === inBlock[j + 1]!.end
|
|
612
|
+
) {
|
|
613
|
+
typeText += inBlock[j + 1]!.text + inBlock[j + 2]!.text;
|
|
614
|
+
typeEnd = inBlock[j + 2]!.end;
|
|
615
|
+
j += 2;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let required = false;
|
|
620
|
+
let label: string | undefined;
|
|
621
|
+
for (let k = j + 1; k < inBlock.length && !inBlock[k]!.startsLine; k++) {
|
|
622
|
+
const t = inBlock[k]!;
|
|
623
|
+
if (t.kind === "ident" && t.text === "required") required = true;
|
|
624
|
+
if (t.kind === "string" && label === undefined) label = stringValue(t);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
params.push({
|
|
628
|
+
name: name.text,
|
|
629
|
+
nameSpan: { start: name.start, end: name.end },
|
|
630
|
+
type: typeText,
|
|
631
|
+
typeSpan: { start: type.start, end: typeEnd },
|
|
632
|
+
isRef,
|
|
633
|
+
required,
|
|
634
|
+
label,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return params;
|
|
639
|
+
}
|