@dforge-core/metadata 0.0.22 → 0.0.24

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