@malloydata/malloy-tag 0.0.335 → 0.0.336

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.
Files changed (59) hide show
  1. package/CONTEXT.md +83 -9
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +5 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/peggy/dist/peg-tag-parser.d.ts +11 -0
  6. package/dist/peggy/dist/peg-tag-parser.js +3130 -0
  7. package/dist/peggy/dist/peg-tag-parser.js.map +1 -0
  8. package/dist/peggy/index.d.ts +13 -0
  9. package/dist/peggy/index.js +117 -0
  10. package/dist/peggy/index.js.map +1 -0
  11. package/dist/peggy/interpreter.d.ts +32 -0
  12. package/dist/peggy/interpreter.js +208 -0
  13. package/dist/peggy/interpreter.js.map +1 -0
  14. package/dist/peggy/statements.d.ts +51 -0
  15. package/dist/peggy/statements.js +7 -0
  16. package/dist/peggy/statements.js.map +1 -0
  17. package/dist/schema.d.ts +41 -0
  18. package/dist/schema.js +573 -0
  19. package/dist/schema.js.map +1 -0
  20. package/dist/schema.spec.d.ts +1 -0
  21. package/dist/schema.spec.js +980 -0
  22. package/dist/schema.spec.js.map +1 -0
  23. package/dist/tags.d.ts +144 -37
  24. package/dist/tags.js +535 -344
  25. package/dist/tags.js.map +1 -1
  26. package/dist/tags.spec.js +524 -45
  27. package/dist/tags.spec.js.map +1 -1
  28. package/package.json +6 -8
  29. package/src/index.ts +3 -0
  30. package/src/motly-schema.motly +52 -0
  31. package/src/peggy/dist/peg-tag-parser.js +2790 -0
  32. package/src/peggy/index.ts +89 -0
  33. package/src/peggy/interpreter.ts +265 -0
  34. package/src/peggy/malloy-tag.peggy +224 -0
  35. package/src/peggy/statements.ts +49 -0
  36. package/src/schema.spec.ts +1280 -0
  37. package/src/schema.ts +852 -0
  38. package/src/tags.spec.ts +591 -46
  39. package/src/tags.ts +597 -398
  40. package/tsconfig.json +3 -2
  41. package/dist/lib/Malloy/MalloyTagLexer.d.ts +0 -42
  42. package/dist/lib/Malloy/MalloyTagLexer.js +0 -395
  43. package/dist/lib/Malloy/MalloyTagLexer.js.map +0 -1
  44. package/dist/lib/Malloy/MalloyTagParser.d.ts +0 -180
  45. package/dist/lib/Malloy/MalloyTagParser.js +0 -1077
  46. package/dist/lib/Malloy/MalloyTagParser.js.map +0 -1
  47. package/dist/lib/Malloy/MalloyTagVisitor.d.ts +0 -120
  48. package/dist/lib/Malloy/MalloyTagVisitor.js +0 -4
  49. package/dist/lib/Malloy/MalloyTagVisitor.js.map +0 -1
  50. package/scripts/build_parser.js +0 -98
  51. package/src/MalloyTag.g4 +0 -104
  52. package/src/lib/Malloy/MalloyTag.interp +0 -61
  53. package/src/lib/Malloy/MalloyTag.tokens +0 -32
  54. package/src/lib/Malloy/MalloyTagLexer.interp +0 -85
  55. package/src/lib/Malloy/MalloyTagLexer.tokens +0 -32
  56. package/src/lib/Malloy/MalloyTagLexer.ts +0 -386
  57. package/src/lib/Malloy/MalloyTagParser.ts +0 -1065
  58. package/src/lib/Malloy/MalloyTagVisitor.ts +0 -141
  59. package/src/lib/Malloy/_BUILD_DIGEST_ +0 -1
package/src/tags.ts CHANGED
@@ -21,29 +21,6 @@
21
21
  * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
  */
23
23
 
24
- import {AbstractParseTreeVisitor} from 'antlr4ts/tree';
25
- import {MalloyTagLexer} from './lib/Malloy/MalloyTagLexer';
26
- import type {
27
- ArrayElementContext,
28
- ArrayValueContext,
29
- PropNameContext,
30
- PropertiesContext,
31
- ReferenceContext,
32
- StringContext,
33
- TagDefContext,
34
- TagEmptyContext,
35
- TagEqContext,
36
- TagLineContext,
37
- TagReplacePropertiesContext,
38
- TagSpecContext,
39
- TagUpdatePropertiesContext,
40
- } from './lib/Malloy/MalloyTagParser';
41
- import {MalloyTagParser} from './lib/Malloy/MalloyTagParser';
42
- import type {MalloyTagVisitor} from './lib/Malloy/MalloyTagVisitor';
43
- import type {ANTLRErrorListener, ParserRuleContext, Token} from 'antlr4ts';
44
- import {CharStreams, CommonTokenStream} from 'antlr4ts';
45
- import {parseString} from './util';
46
-
47
24
  export interface TagError {
48
25
  message: string;
49
26
  line: number;
@@ -51,20 +28,37 @@ export interface TagError {
51
28
  code: string;
52
29
  }
53
30
 
54
- // The distinction between the interface and the Tag class exists solely to
55
- // make it possible to write tests and specify expected results This
56
- // is why only TagDict interface is exported.
31
+ // TagInterface exists for tests and serialization only.
32
+ // Internally, Tag uses Tag instances for properties and array elements.
57
33
  export type TagDict = Record<string, TagInterface>;
58
34
 
59
- type TagValue = string | TagInterface[];
35
+ export type TagScalar = string | number | boolean | Date;
36
+ type TagValue = TagScalar | Tag[];
60
37
 
38
+ // Input format - for tests and constructors (no links)
61
39
  export interface TagInterface {
62
- eq?: TagValue;
40
+ eq?: TagScalar | TagInterface[];
63
41
  properties?: TagDict;
64
42
  deleted?: boolean;
65
43
  prefix?: string;
66
44
  }
67
45
 
46
+ // Output format - for toJSON (can contain links)
47
+ // This format is round-trippable: a Tag.fromJSON(TagJSON) could reconstruct
48
+ // the original Tag tree, including RefTags from TagLink.linkTo strings.
49
+ export interface TagLink {
50
+ linkTo: string;
51
+ }
52
+
53
+ export interface TagJSONInterface {
54
+ eq?: TagScalar | TagJSON[];
55
+ properties?: Record<string, TagJSON>;
56
+ deleted?: boolean;
57
+ prefix?: string;
58
+ }
59
+
60
+ export type TagJSON = TagJSONInterface | TagLink;
61
+
68
62
  export interface TagParse {
69
63
  tag: Tag;
70
64
  log: TagError[];
@@ -73,7 +67,15 @@ export interface TagParse {
73
67
  export type PathSegment = string | number;
74
68
  export type Path = PathSegment[];
75
69
 
76
- export type TagSetValue = string | number | string[] | number[] | null;
70
+ export type TagSetValue =
71
+ | string
72
+ | number
73
+ | boolean
74
+ | Date
75
+ | string[]
76
+ | number[]
77
+ | Tag
78
+ | null;
77
79
 
78
80
  /**
79
81
  * Class for interacting with the parsed output of an annotation
@@ -81,27 +83,46 @@ export type TagSetValue = string | number | string[] | number[] | null;
81
83
  * generate parsed data, and as an API to that data.
82
84
  * ```
83
85
  * tag.text(p?) => string value of tag.p or undefined
84
- * tag.array(p?) => Tag[] value of tag.p or undefined
85
86
  * tag.numeric(p?) => numeric value of tag.p or undefined
86
- * tag.textArray(p ?) => string[] value of elements in tag.p or undefined
87
- * tag.numericArray(p?) => string[] value of elements in tag.p or undefined
88
- * tag.tag(p?) => Tag value of tag.p
89
- * tag.has(p?) => boolean "tag contains tag.p"
90
- * tag.bare(p?) => tag.p exists and has no properties
87
+ * tag.boolean(p?) => boolean value of tag.p or undefined
88
+ * tag.isTrue(p?) => true if tag.p is boolean true
89
+ * tag.isFalse(p?) => true if tag.p is boolean false
90
+ * tag.date(p?) => Date value of tag.p or undefined
91
+ * tag.array(p?) => Tag[] value of tag.p or undefined
92
+ * tag.textArray(p?) => string[] value of elements in tag.p or undefined
93
+ * tag.numericArray(p?) => number[] value of elements in tag.p or undefined
94
+ * tag.tag(p?) => Tag value of tag.p
95
+ * tag.has(p?) => boolean "tag contains tag.p"
96
+ * tag.bare(p?) => tag.p exists and has no properties
91
97
  * tag.dict => Record<string,Tag> of tag properties
98
+ * tag.toObject() => Plain JS object representation
92
99
  * ```
93
100
  */
94
- export class Tag implements TagInterface {
101
+ export class Tag {
95
102
  eq?: TagValue;
96
- properties?: TagDict;
103
+ properties?: Record<string, Tag>;
97
104
  prefix?: string;
98
105
  deleted?: boolean;
106
+ private _parent?: Tag;
107
+
108
+ /**
109
+ * Get the parent tag, if this tag is part of a tree.
110
+ */
111
+ get parent(): Tag | undefined {
112
+ return this._parent;
113
+ }
99
114
 
100
- static tagFrom(from: TagInterface = {}) {
101
- if (from instanceof Tag) {
102
- return from;
115
+ /**
116
+ * Get the root tag by traversing up the parent chain.
117
+ * Returns this tag if it has no parent.
118
+ */
119
+ get root(): Tag {
120
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
121
+ let current: Tag = this;
122
+ while (current._parent !== undefined) {
123
+ current = current._parent;
103
124
  }
104
- return new Tag(from);
125
+ return current;
105
126
  }
106
127
 
107
128
  // --- Just for debugging ---
@@ -127,70 +148,45 @@ export class Tag implements TagInterface {
127
148
  return str + `=${this.eq}`;
128
149
  }
129
150
  str += ' {';
130
- if (this.eq) {
131
- if (typeof this.eq === 'string') {
132
- str += `\n${spaces} =: ${this.eq}`;
133
- } else {
151
+ if (this.eq !== undefined) {
152
+ if (Array.isArray(this.eq)) {
134
153
  str += `\n${spaces} =: [\n${spaces} ${this.eq
135
- .map(el => Tag.tagFrom(el).peek(indent + 4))
154
+ .map(el => el.peek(indent + 4))
136
155
  .join(`\n${spaces} `)}\n${spaces} ]`;
156
+ } else {
157
+ str += `\n${spaces} =: ${this.eq}`;
137
158
  }
138
159
  }
139
160
 
140
161
  if (this.properties) {
141
162
  for (const k in this.properties) {
142
- const val = Tag.tagFrom(this.properties[k]);
143
- str += `\n${spaces} ${k}: ${val.peek(indent + 2)}`;
163
+ str += `\n${spaces} ${k}: ${this.properties[k].peek(indent + 2)}`;
144
164
  }
145
165
  }
146
166
  str += `\n${spaces}}`;
147
167
  return str;
148
168
  }
149
169
 
150
- /**
151
- * Parse a line of Malloy tag language into a Tag which can be queried
152
- * @param source -- The source line to be parsed. If the string starts with #, then it skips
153
- * all characters up to the first space.
154
- * @param lineNumber -- A line number to be associated with the parse errors.
155
- * @param extending A tag which this line will be extending
156
- * @param importing Outer "scopes" for $() references
157
- * @returns Something shaped like { tag: Tag, log: ParseErrors[] }
158
- */
159
- static fromTagLine(
160
- source: string,
161
- lineNumber = 0,
162
- extending?: Tag,
163
- ...importing: Tag[]
164
- ): TagParse {
165
- return parseTagLine(source, extending, importing, lineNumber);
166
- }
167
-
168
- /**
169
- * Parse multiple lines of Malloy tag language, merging them into a single Tag
170
- * @param lines -- The source line to be parsed. If the string starts with #, then it skips
171
- * all characters up to the first space.
172
- * @param extending A tag which this line will be extending
173
- * @param importing Outer "scopes" for $() references
174
- * @returns Something shaped like { tag: Tag, log: ParseErrors[] }
175
- */
176
- static fromTagLines(lines: string[], extending?: Tag, ...importing: Tag[]) {
177
- const allErrs: TagError[] = [];
178
- let current: Tag | undefined = extending;
179
- for (let i = 0; i < lines.length; i++) {
180
- const text = lines[i];
181
- const noteParse = parseTagLine(text, current, importing, i);
182
- current = noteParse.tag;
183
- allErrs.push(...noteParse.log);
170
+ constructor(from: TagInterface = {}, parent?: Tag) {
171
+ if (parent !== undefined) {
172
+ this._parent = parent;
184
173
  }
185
- return {tag: current, log: allErrs};
186
- }
187
-
188
- constructor(from: TagInterface = {}) {
189
- if (from.eq) {
190
- this.eq = from.eq;
174
+ if (from.eq !== undefined) {
175
+ if (Array.isArray(from.eq)) {
176
+ // Convert array elements to Tags
177
+ this.eq = from.eq.map(el =>
178
+ el instanceof Tag ? el : new Tag(el, this)
179
+ );
180
+ } else {
181
+ this.eq = from.eq;
182
+ }
191
183
  }
192
184
  if (from.properties) {
193
- this.properties = from.properties;
185
+ // Convert property values to Tags
186
+ this.properties = {};
187
+ for (const [key, val] of Object.entries(from.properties)) {
188
+ this.properties[key] = val instanceof Tag ? val : new Tag(val, this);
189
+ }
194
190
  }
195
191
  if (from.deleted) {
196
192
  this.deleted = from.deleted;
@@ -209,20 +205,52 @@ export class Tag implements TagInterface {
209
205
  }
210
206
 
211
207
  text(...at: Path): string | undefined {
212
- const str = this.find(at)?.eq;
213
- if (typeof str === 'string') {
214
- return str;
208
+ const val = this.find(at)?.getEq();
209
+ if (val === undefined || Array.isArray(val)) {
210
+ return undefined;
211
+ }
212
+ if (val instanceof Date) {
213
+ return val.toISOString();
215
214
  }
215
+ return String(val);
216
216
  }
217
217
 
218
218
  numeric(...at: Path): number | undefined {
219
- const str = this.find(at)?.eq;
220
- if (typeof str === 'string') {
221
- const num = Number.parseFloat(str);
219
+ const val = this.find(at)?.getEq();
220
+ if (typeof val === 'number') {
221
+ return val;
222
+ }
223
+ if (typeof val === 'string') {
224
+ const num = Number.parseFloat(val);
222
225
  if (!Number.isNaN(num)) {
223
226
  return num;
224
227
  }
225
228
  }
229
+ return undefined;
230
+ }
231
+
232
+ boolean(...at: Path): boolean | undefined {
233
+ const val = this.find(at)?.getEq();
234
+ if (typeof val === 'boolean') {
235
+ return val;
236
+ }
237
+ return undefined;
238
+ }
239
+
240
+ isTrue(...at: Path): boolean {
241
+ return this.find(at)?.getEq() === true;
242
+ }
243
+
244
+ isFalse(...at: Path): boolean {
245
+ return this.find(at)?.getEq() === false;
246
+ }
247
+
248
+ date(...at: Path): Date | undefined {
249
+ const val = this.find(at)?.getEq();
250
+ if (val instanceof Date) {
251
+ return val;
252
+ }
253
+ return undefined;
226
254
  }
227
255
 
228
256
  bare(...at: Path): boolean | undefined {
@@ -230,51 +258,107 @@ export class Tag implements TagInterface {
230
258
  if (p === undefined) {
231
259
  return;
232
260
  }
233
- return (
234
- p.properties === undefined || Object.entries(p.properties).length === 0
235
- );
261
+ return !p.hasProperties();
262
+ }
263
+
264
+ /** Virtual accessor for eq - overridden in RefTag to resolve */
265
+ getEq(): TagValue | undefined {
266
+ return this.eq;
267
+ }
268
+
269
+ /** Virtual accessor for a property - overridden in RefTag to resolve */
270
+ getProperty(name: string): Tag | undefined {
271
+ return this.properties?.[name];
272
+ }
273
+
274
+ /** Virtual accessor for an array element - overridden in RefTag to resolve */
275
+ getArrayElement(index: number): Tag | undefined {
276
+ if (Array.isArray(this.eq) && index < this.eq.length) {
277
+ return this.eq[index];
278
+ }
279
+ return undefined;
236
280
  }
237
281
 
238
282
  get dict(): Record<string, Tag> {
239
- const newDict: Record<string, Tag> = {};
283
+ return this.properties ?? {};
284
+ }
285
+
286
+ /** Iterate over [name, Tag] pairs for each property */
287
+ *entries(): Generator<[string, Tag]> {
240
288
  if (this.properties) {
241
289
  for (const key in this.properties) {
242
- newDict[key] = Tag.tagFrom(this.properties[key]);
290
+ yield [key, this.properties[key]];
243
291
  }
244
292
  }
245
- return newDict;
293
+ }
294
+
295
+ /** Iterate over property names */
296
+ *keys(): Generator<string> {
297
+ if (this.properties) {
298
+ for (const key in this.properties) {
299
+ yield key;
300
+ }
301
+ }
302
+ }
303
+
304
+ /** Check if this tag has any properties */
305
+ hasProperties(): boolean {
306
+ return (
307
+ this.properties !== undefined && Object.keys(this.properties).length > 0
308
+ );
246
309
  }
247
310
 
248
311
  array(...at: Path): Tag[] | undefined {
249
- const array = this.find(at)?.eq;
250
- if (array === undefined || typeof array === 'string') {
312
+ const found = this.find(at);
313
+ if (found === undefined) {
251
314
  return undefined;
252
315
  }
253
- return array.map(el =>
254
- typeof el === 'string' ? new Tag({eq: el}) : Tag.tagFrom(el)
255
- );
316
+ const arr = found.getEq();
317
+ if (!Array.isArray(arr)) {
318
+ return undefined;
319
+ }
320
+ return arr.map((_, i) => found.getArrayElement(i)!);
256
321
  }
257
322
 
258
323
  textArray(...at: Path): string[] | undefined {
259
- const array = this.find(at)?.eq;
260
- if (array === undefined || typeof array === 'string') {
324
+ const found = this.find(at);
325
+ if (found === undefined) {
261
326
  return undefined;
262
327
  }
263
- return array.reduce<string[]>(
264
- (allStrs, el) =>
265
- typeof el.eq === 'string' ? allStrs.concat(el.eq) : allStrs,
266
- []
267
- );
328
+ const arr = found.getEq();
329
+ if (!Array.isArray(arr)) {
330
+ return undefined;
331
+ }
332
+ return arr.reduce<string[]>((allStrs, _, i) => {
333
+ const el = found.getArrayElement(i);
334
+ const val = el?.getEq();
335
+ if (val === undefined || Array.isArray(val)) {
336
+ return allStrs;
337
+ }
338
+ if (val instanceof Date) {
339
+ return allStrs.concat(val.toISOString());
340
+ }
341
+ return allStrs.concat(String(val));
342
+ }, []);
268
343
  }
269
344
 
270
345
  numericArray(...at: Path): number[] | undefined {
271
- const array = this.find(at)?.eq;
272
- if (array === undefined || typeof array === 'string') {
346
+ const found = this.find(at);
347
+ if (found === undefined) {
348
+ return undefined;
349
+ }
350
+ const arr = found.getEq();
351
+ if (!Array.isArray(arr)) {
273
352
  return undefined;
274
353
  }
275
- return array.reduce<number[]>((allNums, el) => {
276
- if (typeof el.eq === 'string') {
277
- const num = Number.parseFloat(el.eq);
354
+ return arr.reduce<number[]>((allNums, _, i) => {
355
+ const el = found.getArrayElement(i);
356
+ const val = el?.getEq();
357
+ if (typeof val === 'number') {
358
+ return allNums.concat(val);
359
+ }
360
+ if (typeof val === 'string') {
361
+ const num = Number.parseFloat(val);
278
362
  if (!Number.isNaN(num)) {
279
363
  return allNums.concat(num);
280
364
  }
@@ -283,16 +367,197 @@ export class Tag implements TagInterface {
283
367
  }, []);
284
368
  }
285
369
 
286
- // Has the sometimes desireable side effect of initalizing properties
287
- getProperties(): TagDict {
370
+ // Has the sometimes desirable side effect of initializing properties
371
+ getProperties(): Record<string, Tag> {
288
372
  if (this.properties === undefined) {
289
373
  this.properties = {};
290
374
  }
291
375
  return this.properties;
292
376
  }
293
377
 
294
- clone(): Tag {
295
- return new Tag(structuredClone(this));
378
+ clone(newParent?: Tag): Tag {
379
+ const cloned = new Tag({}, newParent);
380
+ cloned.prefix = this.prefix;
381
+ cloned.deleted = this.deleted;
382
+
383
+ if (this.eq !== undefined) {
384
+ if (Array.isArray(this.eq)) {
385
+ cloned.eq = this.eq.map(el => el.clone(cloned));
386
+ } else {
387
+ // Scalar value - copy directly (Date needs special handling)
388
+ cloned.eq = this.eq instanceof Date ? new Date(this.eq) : this.eq;
389
+ }
390
+ }
391
+
392
+ if (this.properties) {
393
+ cloned.properties = {};
394
+ for (const [key, val] of Object.entries(this.properties)) {
395
+ cloned.properties[key] = val.clone(cloned);
396
+ }
397
+ }
398
+
399
+ return cloned;
400
+ }
401
+
402
+ private static scalarToObject(
403
+ val: TagScalar
404
+ ): string | number | boolean | Date {
405
+ return val;
406
+ }
407
+
408
+ private static tagToObject(tag: TagInterface): unknown {
409
+ const hasProps =
410
+ tag.properties !== undefined && Object.keys(tag.properties).length > 0;
411
+ const hasValue = tag.eq !== undefined;
412
+
413
+ // Bare tag (no value, no properties)
414
+ if (!hasValue && !hasProps) {
415
+ return true;
416
+ }
417
+
418
+ // Properties only
419
+ if (!hasValue && hasProps) {
420
+ const result: Record<string, unknown> = {};
421
+ for (const [key, val] of Object.entries(tag.properties!)) {
422
+ if (!val.deleted) {
423
+ result[key] = Tag.tagToObject(val);
424
+ }
425
+ }
426
+ return result;
427
+ }
428
+
429
+ // Value only
430
+ if (hasValue && !hasProps) {
431
+ if (Array.isArray(tag.eq)) {
432
+ return tag.eq.map(el => Tag.tagToObject(el));
433
+ }
434
+ return Tag.scalarToObject(tag.eq!);
435
+ }
436
+
437
+ // Both value and properties
438
+ const result: Record<string, unknown> = {};
439
+ if (Array.isArray(tag.eq)) {
440
+ result['='] = tag.eq.map(el => Tag.tagToObject(el));
441
+ } else {
442
+ result['='] = Tag.scalarToObject(tag.eq!);
443
+ }
444
+ for (const [key, val] of Object.entries(tag.properties!)) {
445
+ if (!val.deleted) {
446
+ result[key] = Tag.tagToObject(val);
447
+ }
448
+ }
449
+ return result;
450
+ }
451
+
452
+ /**
453
+ * Convert to a plain JS object. References are resolved to actual
454
+ * object pointers (which may be circular in JS - that's fine).
455
+ * @param resolving - RefTags currently being resolved (for cycle detection)
456
+ */
457
+ toObject(resolving: Set<RefTag> = new Set()): Record<string, unknown> {
458
+ const result: Record<string, unknown> = {};
459
+ if (this.properties) {
460
+ for (const [key, prop] of Object.entries(this.properties)) {
461
+ if (!prop.deleted) {
462
+ result[key] = prop.toObjectValue(resolving);
463
+ }
464
+ }
465
+ }
466
+ return result;
467
+ }
468
+
469
+ /**
470
+ * Convert this tag's value to a plain JS object.
471
+ * Override in RefTag to resolve references.
472
+ * @param resolving - RefTags currently being resolved (for cycle detection)
473
+ */
474
+ toObjectValue(resolving: Set<RefTag>): unknown {
475
+ const hasProps =
476
+ this.properties !== undefined && Object.keys(this.properties).length > 0;
477
+ const hasValue = this.eq !== undefined;
478
+
479
+ // Bare tag (no value, no properties)
480
+ if (!hasValue && !hasProps) {
481
+ return true;
482
+ }
483
+
484
+ // Properties only
485
+ if (!hasValue && hasProps) {
486
+ return this.toObject(resolving);
487
+ }
488
+
489
+ // Value only
490
+ if (hasValue && !hasProps) {
491
+ if (Array.isArray(this.eq)) {
492
+ return this.eq.map(el => el.toObjectValue(resolving));
493
+ }
494
+ return this.eq;
495
+ }
496
+
497
+ // Both value and properties
498
+ const result: Record<string, unknown> = this.toObject(resolving);
499
+ if (Array.isArray(this.eq)) {
500
+ result['='] = this.eq.map(el => el.toObjectValue(resolving));
501
+ } else {
502
+ result['='] = this.eq;
503
+ }
504
+ return result;
505
+ }
506
+
507
+ /**
508
+ * Custom JSON serialization that excludes _parent to avoid circular references.
509
+ * This is called automatically by JSON.stringify().
510
+ */
511
+ toJSON(): TagJSON {
512
+ const result: TagJSONInterface = {};
513
+ if (this.eq !== undefined) {
514
+ if (Array.isArray(this.eq)) {
515
+ result.eq = this.eq.map(el => el.toJSON());
516
+ } else {
517
+ result.eq = this.eq;
518
+ }
519
+ }
520
+ if (this.properties !== undefined) {
521
+ result.properties = {};
522
+ for (const [key, val] of Object.entries(this.properties)) {
523
+ result.properties[key] = val.toJSON();
524
+ }
525
+ }
526
+ if (this.deleted) {
527
+ result.deleted = true;
528
+ }
529
+ if (this.prefix) {
530
+ result.prefix = this.prefix;
531
+ }
532
+ return result;
533
+ }
534
+
535
+ /**
536
+ * Validate all references in this tag tree.
537
+ * Returns an array of error messages for unresolved references.
538
+ */
539
+ validateReferences(): string[] {
540
+ const errors: string[] = [];
541
+ this.collectReferenceErrors(errors, []);
542
+ return errors;
543
+ }
544
+
545
+ /**
546
+ * Recursively collect reference errors.
547
+ */
548
+ collectReferenceErrors(errors: string[], path: string[]): void {
549
+ if (this.properties) {
550
+ for (const [key, prop] of Object.entries(this.properties)) {
551
+ if (!prop.deleted) {
552
+ prop.collectReferenceErrors(errors, [...path, key]);
553
+ }
554
+ }
555
+ }
556
+ if (Array.isArray(this.eq)) {
557
+ this.eq.forEach((el, i) => {
558
+ el.collectReferenceErrors(errors, [...path, `[${i}]`]);
559
+ });
560
+ }
296
561
  }
297
562
 
298
563
  private static escapeString(str: string) {
@@ -313,6 +578,19 @@ export class Tag implements TagInterface {
313
578
  return `"${Tag.escapeString(str)}"`;
314
579
  }
315
580
 
581
+ private static serializeScalar(val: TagScalar): string {
582
+ if (typeof val === 'boolean') {
583
+ return val ? '@true' : '@false';
584
+ }
585
+ if (val instanceof Date) {
586
+ return `@${val.toISOString()}`;
587
+ }
588
+ if (typeof val === 'number') {
589
+ return String(val);
590
+ }
591
+ return Tag.quoteAndEscape(val);
592
+ }
593
+
316
594
  toString(): string {
317
595
  let annotation = this.prefix ?? '# ';
318
596
  function addChildren(tag: TagInterface) {
@@ -335,7 +613,7 @@ export class Tag implements TagInterface {
335
613
  }
336
614
  annotation += ']';
337
615
  } else {
338
- annotation += Tag.quoteAndEscape(`${child.eq}`);
616
+ annotation += Tag.serializeScalar(child.eq);
339
617
  }
340
618
  }
341
619
  if (child.properties) {
@@ -370,25 +648,19 @@ export class Tag implements TagInterface {
370
648
  }
371
649
 
372
650
  find(path: Path): Tag | undefined {
373
- let currentTag: Tag = Tag.tagFrom(this);
651
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
652
+ let currentTag: Tag = this;
374
653
  for (const segment of path) {
654
+ let next: Tag | undefined;
375
655
  if (typeof segment === 'number') {
376
- if (
377
- currentTag.eq === undefined ||
378
- !Array.isArray(currentTag.eq) ||
379
- currentTag.eq.length <= segment
380
- ) {
381
- return;
382
- }
383
- currentTag = Tag.tagFrom(currentTag.eq[segment]);
656
+ next = currentTag.getArrayElement(segment);
384
657
  } else {
385
- const properties = currentTag.properties ?? {};
386
- if (segment in properties) {
387
- currentTag = Tag.tagFrom(properties[segment]);
388
- } else {
389
- return;
390
- }
658
+ next = currentTag.getProperty(segment);
391
659
  }
660
+ if (next === undefined) {
661
+ return undefined;
662
+ }
663
+ currentTag = next;
392
664
  }
393
665
  return currentTag.deleted ? undefined : currentTag;
394
666
  }
@@ -398,16 +670,23 @@ export class Tag implements TagInterface {
398
670
  }
399
671
 
400
672
  set(path: Path, value: TagSetValue = null): Tag {
401
- const copy = Tag.tagFrom(this);
402
- let currentTag: TagInterface = copy;
673
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
674
+ let currentTag: Tag = this;
675
+ let parentTag: Tag | undefined;
676
+ let lastSegment: PathSegment | undefined;
677
+
403
678
  for (const segment of path) {
679
+ parentTag = currentTag;
680
+ lastSegment = segment;
404
681
  if (typeof segment === 'number') {
405
682
  if (currentTag.eq === undefined || !Array.isArray(currentTag.eq)) {
406
- currentTag.eq = Array.from({length: segment + 1}).map(_ => ({}));
683
+ currentTag.eq = Array.from({length: segment + 1}).map(
684
+ () => new Tag({}, currentTag)
685
+ );
407
686
  } else if (currentTag.eq.length <= segment) {
408
687
  const values = currentTag.eq;
409
688
  const newVal = Array.from({length: segment + 1}).map((_, i) =>
410
- i < values.length ? values[i] : {}
689
+ i < values.length ? values[i] : new Tag({}, currentTag)
411
690
  );
412
691
  currentTag.eq = newVal;
413
692
  }
@@ -415,7 +694,7 @@ export class Tag implements TagInterface {
415
694
  } else {
416
695
  const properties = currentTag.properties;
417
696
  if (properties === undefined) {
418
- currentTag.properties = {[segment]: {}};
697
+ currentTag.properties = {[segment]: new Tag({}, currentTag)};
419
698
  currentTag = currentTag.properties[segment];
420
699
  } else if (segment in properties) {
421
700
  currentTag = properties[segment];
@@ -423,23 +702,37 @@ export class Tag implements TagInterface {
423
702
  currentTag.deleted = false;
424
703
  }
425
704
  } else {
426
- properties[segment] = {};
705
+ properties[segment] = new Tag({}, currentTag);
427
706
  currentTag = properties[segment];
428
707
  }
429
708
  }
430
709
  }
710
+
431
711
  if (value === null) {
432
712
  currentTag.eq = undefined;
433
- } else if (typeof value === 'string') {
713
+ } else if (value instanceof Tag) {
714
+ // Clone the tag with correct parent and replace in parent's slot
715
+ const cloned = value.clone(parentTag);
716
+ if (parentTag && lastSegment !== undefined) {
717
+ if (typeof lastSegment === 'number' && Array.isArray(parentTag.eq)) {
718
+ parentTag.eq[lastSegment] = cloned;
719
+ } else if (typeof lastSegment === 'string' && parentTag.properties) {
720
+ parentTag.properties[lastSegment] = cloned;
721
+ }
722
+ }
723
+ } else if (
724
+ typeof value === 'string' ||
725
+ typeof value === 'number' ||
726
+ typeof value === 'boolean' ||
727
+ value instanceof Date
728
+ ) {
434
729
  currentTag.eq = value;
435
- } else if (typeof value === 'number') {
436
- currentTag.eq = value.toString(); // TODO big numbers?
437
730
  } else if (Array.isArray(value)) {
438
731
  currentTag.eq = value.map((v: string | number) => {
439
- return {eq: typeof v === 'string' ? v : v.toString()};
732
+ return new Tag({eq: v}, currentTag);
440
733
  });
441
734
  }
442
- return copy;
735
+ return this;
443
736
  }
444
737
 
445
738
  delete(...path: Path): Tag {
@@ -451,18 +744,20 @@ export class Tag implements TagInterface {
451
744
  }
452
745
 
453
746
  private remove(path: Path, hard = false): Tag {
454
- const origCopy = Tag.tagFrom(this);
455
- let currentTag: TagInterface = origCopy;
747
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
748
+ let currentTag: Tag = this;
456
749
  for (const segment of path.slice(0, path.length - 1)) {
457
750
  if (typeof segment === 'number') {
458
751
  if (currentTag.eq === undefined || !Array.isArray(currentTag.eq)) {
459
- if (!hard) return origCopy;
460
- currentTag.eq = Array.from({length: segment}).map(_ => ({}));
752
+ if (!hard) return this;
753
+ currentTag.eq = Array.from({length: segment}).map(
754
+ () => new Tag({}, currentTag)
755
+ );
461
756
  } else if (currentTag.eq.length <= segment) {
462
- if (!hard) return origCopy;
757
+ if (!hard) return this;
463
758
  const values = currentTag.eq;
464
759
  const newVal = Array.from({length: segment}).map((_, i) =>
465
- i < values.length ? values[i] : {}
760
+ i < values.length ? values[i] : new Tag({}, currentTag)
466
761
  );
467
762
  currentTag.eq = newVal;
468
763
  }
@@ -470,14 +765,14 @@ export class Tag implements TagInterface {
470
765
  } else {
471
766
  const properties = currentTag.properties;
472
767
  if (properties === undefined) {
473
- if (!hard) return origCopy;
474
- currentTag.properties = {[segment]: {}};
768
+ if (!hard) return this;
769
+ currentTag.properties = {[segment]: new Tag({}, currentTag)};
475
770
  currentTag = currentTag.properties[segment];
476
771
  } else if (segment in properties) {
477
772
  currentTag = properties[segment];
478
773
  } else {
479
- if (!hard) return origCopy;
480
- properties[segment] = {};
774
+ if (!hard) return this;
775
+ properties[segment] = new Tag({}, currentTag);
481
776
  currentTag = properties[segment];
482
777
  }
483
778
  }
@@ -488,290 +783,194 @@ export class Tag implements TagInterface {
488
783
  delete currentTag.properties[segment];
489
784
  } else if (hard) {
490
785
  currentTag.properties ??= {};
491
- currentTag.properties[segment] = {deleted: true};
786
+ currentTag.properties[segment] = new Tag({deleted: true}, currentTag);
492
787
  }
493
788
  } else {
494
789
  if (Array.isArray(currentTag.eq)) {
495
790
  currentTag.eq.splice(segment, 1);
496
791
  }
497
792
  }
498
- return origCopy;
793
+ return this;
499
794
  }
500
795
  }
501
796
 
502
- class TagErrorListener implements ANTLRErrorListener<Token> {
503
- log: TagError[] = [];
504
- constructor(readonly line: number) {}
505
-
506
- syntaxError(
507
- recognizer: unknown,
508
- offendingSymbol: Token | undefined,
509
- line: number,
510
- charPositionInLine: number,
511
- msg: string,
512
- _e: unknown
513
- ): void {
514
- const logMsg: TagError = {
515
- code: 'tag-parse-syntax-error',
516
- message: msg,
517
- line: this.line,
518
- offset: charPositionInLine,
519
- };
520
- this.log.push(logMsg);
521
- }
522
-
523
- semanticError(cx: ParserRuleContext, code: string, msg: string): void {
524
- const logMsg: TagError = {
525
- code,
526
- message: msg,
527
- line: this.line,
528
- offset: cx.start.charPositionInLine,
529
- };
530
- this.log.push(logMsg);
531
- }
532
- }
797
+ /**
798
+ * A tag that references another location in the tag tree.
799
+ * When accessed, it dereferences to the target tag.
800
+ *
801
+ * Reference syntax:
802
+ * $path.to.thing - absolute from root
803
+ * $^thing - up one level, then 'thing'
804
+ * $^^thing - up two levels, then 'thing'
805
+ * $items[0].name - with array indexing
806
+ */
807
+ export class RefTag extends Tag {
808
+ readonly ups: number;
809
+ readonly refPath: Path;
533
810
 
534
- function getBuildOn(ctx: ParserRuleContext): Tag {
535
- const buildOn = ctx['buildOn'];
536
- if (buildOn instanceof Tag) {
537
- return buildOn;
811
+ constructor(ups: number, refPath: Path, parent?: Tag) {
812
+ super({}, parent);
813
+ this.ups = ups;
814
+ this.refPath = refPath;
538
815
  }
539
- return new Tag();
540
- }
541
816
 
542
- /**
543
- * When chasing a path reference, the two interesting gestures are to
544
- * find the path-ed tag so it can be extended, or to find the path tag
545
- * so it can be deleted. This returns the parent and the final tag
546
- * so that the caller can delete the tag with delete parent.tagName
547
- * or assign to it with parent[tagName] = new_value
548
- */
549
- function buildAccessPath(buildOn: Tag, path: string[]): [string, TagDict] {
550
- let parentPropertyObject = buildOn.getProperties();
551
- for (const p of path.slice(0, path.length - 1)) {
552
- let next: Tag;
553
- if (parentPropertyObject[p] === undefined) {
554
- next = new Tag({});
555
- parentPropertyObject[p] = next;
817
+ /**
818
+ * Resolve this reference to the target tag.
819
+ * Returns undefined if the reference cannot be resolved.
820
+ */
821
+ resolve(): Tag | undefined {
822
+ // Start from the appropriate point based on ups
823
+ let current: Tag | undefined;
824
+ if (this.ups === 0) {
825
+ // Absolute reference from root
826
+ current = this.root;
556
827
  } else {
557
- // The access that we are performing requires that `.properties` be the
558
- // same JS object (not equal, but identical), and `Tag.tagFrom` only copies
559
- // the exact object in if it is actually present.
560
- parentPropertyObject[p].properties ??= {};
561
- next = Tag.tagFrom(parentPropertyObject[p]);
828
+ // Relative reference - go up 'ups' levels from parent
829
+ // $^ means go up 1 level from the containing scope
830
+ current = this.parent;
831
+ for (let i = 0; i < this.ups && current !== undefined; i++) {
832
+ current = current.parent;
833
+ }
834
+ }
835
+
836
+ if (current === undefined) {
837
+ return undefined;
562
838
  }
563
- parentPropertyObject = next.getProperties();
839
+
840
+ // Follow the path
841
+ return current.find(this.refPath);
564
842
  }
565
- return [path[path.length - 1], parentPropertyObject];
566
- }
567
843
 
568
- function getString(ctx: StringContext) {
569
- if (ctx.SQ_STRING() || ctx.DQ_STRING()) {
570
- return parseString(ctx.text, ctx.text[0]);
844
+ /**
845
+ * Quote an identifier if it contains special characters.
846
+ */
847
+ private quoteSegment(seg: string): string {
848
+ // Match bareString from peggy grammar: [0-9A-Za-z_\u00C0-\u024F\u1E00-\u1EFF]+
849
+ if (/^[0-9A-Za-z_\u00C0-\u024F\u1E00-\u1EFF]+$/.test(seg)) {
850
+ return seg;
851
+ }
852
+ // Otherwise, backquote it (escaping backslashes and backquotes)
853
+ return '`' + seg.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`';
571
854
  }
572
- return ctx.text;
573
- }
574
855
 
575
- function parseTagLine(
576
- source: string,
577
- extending: Tag | undefined,
578
- outerScope: Tag[],
579
- onLine: number
580
- ): TagParse {
581
- if (source[0] === '#') {
582
- const skipTo = source.indexOf(' ');
583
- if (skipTo > 0) {
584
- source = source.slice(skipTo);
585
- } else {
586
- source = '';
587
- }
588
- }
589
- const inputStream = CharStreams.fromString(source);
590
- const lexer = new MalloyTagLexer(inputStream);
591
- const tokenStream = new CommonTokenStream(lexer);
592
- const pLog = new TagErrorListener(onLine);
593
- const taglineParser = new MalloyTagParser(tokenStream);
594
- taglineParser.removeErrorListeners();
595
- taglineParser.addErrorListener(pLog);
596
- const tagTree = taglineParser.tagLine();
597
- const treeWalker = new TagLineParser(outerScope, pLog);
598
- const tag = treeWalker.tagLineToTag(tagTree, extending);
599
- return {tag, log: pLog.log};
600
- }
856
+ /**
857
+ * Convert this reference to its string representation.
858
+ */
859
+ toRefString(): string {
860
+ const prefix = '$' + '^'.repeat(this.ups);
861
+ const pathStr = this.refPath
862
+ .map((seg, i) => {
863
+ if (typeof seg === 'number') {
864
+ return `[${seg}]`;
865
+ }
866
+ const quoted = this.quoteSegment(seg);
867
+ return i === 0 ? quoted : `.${quoted}`;
868
+ })
869
+ .join('');
870
+ return prefix + pathStr;
871
+ }
601
872
 
602
- class TagLineParser
603
- extends AbstractParseTreeVisitor<Tag>
604
- implements MalloyTagVisitor<Tag>
605
- {
606
- scopes: Tag[] = [];
607
- msgLog: TagErrorListener;
608
- constructor(outerScopes: Tag[] = [], msgLog: TagErrorListener) {
609
- super();
610
- this.msgLog = msgLog;
611
- this.scopes.unshift(...outerScopes);
873
+ // Override virtual accessors to resolve the reference
874
+ override getEq(): TagValue | undefined {
875
+ return this.resolve()?.getEq();
612
876
  }
613
877
 
614
- defaultResult() {
615
- return new Tag();
878
+ override getProperty(name: string): Tag | undefined {
879
+ return this.resolve()?.getProperty(name);
616
880
  }
617
881
 
618
- visitString(ctx: StringContext): Tag {
619
- return new Tag({eq: getString(ctx)});
882
+ override getArrayElement(index: number): Tag | undefined {
883
+ return this.resolve()?.getArrayElement(index);
620
884
  }
621
885
 
622
- protected getPropName(ctx: PropNameContext): string[] {
623
- return ctx
624
- .identifier()
625
- .map(cx =>
626
- cx.BARE_STRING() ? cx.text : parseString(cx.text, cx.text[0])
627
- );
886
+ override hasProperties(): boolean {
887
+ return this.resolve()?.hasProperties() ?? false;
628
888
  }
629
889
 
630
- getTags(tags: TagSpecContext[], tagLine: Tag): Tag {
631
- for (const tagSpec of tags) {
632
- // Stash the current state of this tag in the context and then visit it
633
- // visit functions should alter the tagLine
634
- tagSpec['buildOn'] = tagLine;
635
- this.visit(tagSpec);
890
+ /**
891
+ * For toObject, resolve the reference and return the target's object value.
892
+ * This creates actual object pointers (circular references are allowed).
893
+ * Detects cycles in the reference chain to prevent infinite recursion.
894
+ */
895
+ override toObjectValue(resolving: Set<RefTag>): unknown {
896
+ // Check for cycle in reference chain
897
+ if (resolving.has(this)) {
898
+ // We're in a cycle - return undefined to break it
899
+ // (The cycle will be completed when the outer resolution finishes)
900
+ return undefined;
636
901
  }
637
- return tagLine;
638
- }
639
902
 
640
- tagLineToTag(ctx: TagLineContext, extending: Tag | undefined): Tag {
641
- extending = extending?.clone() || new Tag({});
642
- this.scopes.unshift(extending);
643
- this.getTags(ctx.tagSpec(), extending);
644
- return extending;
645
- }
903
+ const resolved = this.resolve();
904
+ if (resolved === undefined) {
905
+ return undefined;
906
+ }
646
907
 
647
- visitTagLine(_ctx: TagLineContext): Tag {
648
- throw new Error('INTERNAL: ERROR: Call tagLineToTag, not vistTagLine');
649
- return this.defaultResult();
650
- }
908
+ // Track that we're resolving this RefTag
909
+ resolving.add(this);
910
+ const result = resolved.toObjectValue(resolving);
911
+ resolving.delete(this);
651
912
 
652
- visitProperties(ctx: PropertiesContext): Tag {
653
- return this.getTags(ctx.tagSpec(), getBuildOn(ctx));
913
+ return result;
654
914
  }
655
915
 
656
- visitArrayValue(ctx: ArrayValueContext): Tag {
657
- return new Tag({eq: this.getArray(ctx)});
916
+ /**
917
+ * For JSON serialization, return a marker object instead of resolving.
918
+ */
919
+ override toJSON(): TagJSON {
920
+ return {linkTo: this.toRefString()};
658
921
  }
659
922
 
660
- getArray(ctx: ArrayValueContext): Tag[] {
661
- return ctx.arrayElement().map(v => this.visit(v));
923
+ /**
924
+ * Check if this reference resolves, add error if not.
925
+ */
926
+ override collectReferenceErrors(errors: string[], path: string[]): void {
927
+ if (this.resolve() === undefined) {
928
+ const location = path.length > 0 ? path.join('.') : 'root';
929
+ errors.push(`Unresolved reference at ${location}: ${this.toRefString()}`);
930
+ }
662
931
  }
663
932
 
664
- visitArrayElement(ctx: ArrayElementContext): Tag {
665
- const propCx = ctx.properties();
666
- const properties = propCx ? this.visitProperties(propCx) : undefined;
667
- const strCx = ctx.string();
668
- let value: TagValue | undefined = strCx ? getString(strCx) : undefined;
669
-
670
- const arrayCx = ctx.arrayValue();
671
- if (arrayCx) {
672
- value = this.getArray(arrayCx);
673
- }
933
+ /**
934
+ * Clone this RefTag, preserving the reference information.
935
+ */
936
+ override clone(newParent?: Tag): RefTag {
937
+ return new RefTag(this.ups, [...this.refPath], newParent);
938
+ }
939
+ }
674
940
 
675
- if (properties) {
676
- if (value !== undefined) {
677
- properties.eq = value;
678
- }
679
- return properties;
680
- }
941
+ /**
942
+ * Convert a Tag to a plain TagInterface without internal fields like _parent.
943
+ * Useful for test comparisons.
944
+ */
945
+ export function interfaceFromTag(tag: TagInterface): TagInterface {
946
+ const result: TagInterface = {};
681
947
 
682
- const refCx = ctx.reference();
683
- if (refCx) {
684
- return this.visitReference(refCx);
948
+ if (tag.eq !== undefined) {
949
+ if (Array.isArray(tag.eq)) {
950
+ result.eq = tag.eq.map(el => interfaceFromTag(el));
951
+ } else {
952
+ result.eq = tag.eq;
685
953
  }
686
- return new Tag({eq: value});
687
954
  }
688
955
 
689
- visitReference(ctx: ReferenceContext): Tag {
690
- const path = this.getPropName(ctx.propName());
691
- for (const scope of this.scopes) {
692
- // first scope which has the first component gets to resolve the whole path
693
- if (scope.has(path[0])) {
694
- const refTo = scope.tag(...path);
695
- if (refTo) {
696
- return refTo.clone();
697
- }
698
- break;
699
- }
700
- }
701
- this.msgLog.semanticError(
702
- ctx,
703
- 'tag-property-not-found',
704
- `Reference to undefined property ${path.join('.')}`
705
- );
706
- return this.defaultResult();
707
- }
708
-
709
- visitTagEq(ctx: TagEqContext): Tag {
710
- const buildOn = getBuildOn(ctx);
711
- const name = this.getPropName(ctx.propName());
712
- const [writeKey, writeInto] = buildAccessPath(buildOn, name);
713
- const eq = this.visit(ctx.eqValue());
714
- const propCx = ctx.properties();
715
- if (propCx) {
716
- // a.b.c { -y } means i want to do -y on
717
- if (propCx.DOTTY() === undefined) {
718
- const properties = this.visitProperties(propCx).dict;
719
- // Add new properties old value
720
- writeInto[writeKey] = {...eq, properties};
721
- } else {
722
- // preserve old properties, add new value
723
- writeInto[writeKey] = {...writeInto[writeKey], ...eq};
724
- }
725
- } else {
726
- writeInto[writeKey] = eq;
727
- }
728
- return buildOn;
956
+ if (tag.properties !== undefined) {
957
+ result.properties = interfaceFromDict(tag.properties);
729
958
  }
730
959
 
731
- visitTagReplaceProperties(ctx: TagReplacePropertiesContext): Tag {
732
- const buildOn = getBuildOn(ctx);
733
- const name = this.getPropName(ctx.propName());
734
- const [writeKey, writeInto] = buildAccessPath(buildOn, name);
735
- const propCx = ctx.properties();
736
- const props = this.visitProperties(propCx);
737
- if (ctx.DOTTY() === undefined) {
738
- // No dots, thropw away the value
739
- writeInto[writeKey] = {properties: props.dict};
740
- } else {
741
- /// DOTS, just update the properties
742
- writeInto[writeKey].properties = props.dict;
743
- }
744
- return buildOn;
745
- }
746
-
747
- visitTagUpdateProperties(ctx: TagUpdatePropertiesContext): Tag {
748
- const buildOn = getBuildOn(ctx);
749
- const name = this.getPropName(ctx.propName());
750
- const [writeKey, writeInto] = buildAccessPath(buildOn, name);
751
- const propCx = ctx.properties();
752
- propCx['buildOn'] = Tag.tagFrom(writeInto[writeKey]);
753
- const props = this.visitProperties(propCx);
754
- const thisObj = writeInto[writeKey] || new Tag({});
755
- const properties = {...thisObj.properties, ...props.dict};
756
- writeInto[writeKey] = {...thisObj, properties};
757
- return buildOn;
758
- }
759
-
760
- visitTagDef(ctx: TagDefContext): Tag {
761
- const buildOn = getBuildOn(ctx);
762
- const path = this.getPropName(ctx.propName());
763
- const [writeKey, writeInto] = buildAccessPath(buildOn, path);
764
- if (ctx.MINUS()) {
765
- writeInto[writeKey] = {deleted: true};
766
- } else {
767
- writeInto[writeKey] = new Tag({});
768
- }
769
- return buildOn;
960
+ if (tag.deleted) {
961
+ result.deleted = true;
770
962
  }
771
963
 
772
- visitTagEmpty(ctx: TagEmptyContext): Tag {
773
- const tagList = ctx['buildOn'] as Tag;
774
- tagList.properties = {};
775
- return tagList;
964
+ return result;
965
+ }
966
+
967
+ /**
968
+ * Convert a TagDict to a plain TagDict without internal fields.
969
+ */
970
+ export function interfaceFromDict(dict: TagDict): TagDict {
971
+ const result: TagDict = {};
972
+ for (const [key, val] of Object.entries(dict)) {
973
+ result[key] = interfaceFromTag(val);
776
974
  }
975
+ return result;
777
976
  }