@dforge-core/metadata 0.0.22 → 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.
@@ -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
+ }