@malloydata/malloy-tag 0.0.335 → 0.0.337

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.spec.ts CHANGED
@@ -22,7 +22,8 @@
22
22
  */
23
23
 
24
24
  import type {TagDict} from './tags';
25
- import {Tag} from './tags';
25
+ import {Tag, RefTag, interfaceFromDict} from './tags';
26
+ import {parseTag} from './peggy';
26
27
 
27
28
  declare global {
28
29
  // eslint-disable-next-line @typescript-eslint/no-namespace
@@ -36,7 +37,7 @@ declare global {
36
37
  expect.extend({
37
38
  tagsAre(src: string | Tag, result: Tag) {
38
39
  if (typeof src === 'string') {
39
- const {tag, log} = Tag.fromTagLine(src, 0, undefined);
40
+ const {tag, log} = parseTag(src);
40
41
  const errs = log.map(e => e.message);
41
42
  if (log.length > 0) {
42
43
  return {
@@ -46,7 +47,7 @@ expect.extend({
46
47
  }
47
48
  src = tag;
48
49
  }
49
- const got = src.properties;
50
+ const got = src.properties ? interfaceFromDict(src.properties) : undefined;
50
51
  if (this.equals(got, result)) {
51
52
  return {
52
53
  pass: true,
@@ -111,26 +112,19 @@ describe('tagParse to Tag', () => {
111
112
 
112
113
  ['x={y z} -x.y', {x: {properties: {z: {}, y: {deleted: true}}}}],
113
114
  ['x={y z} x {-y}', {x: {properties: {z: {}, y: {deleted: true}}}}],
114
- ['x=1 x {xx=11}', {x: {eq: '1', properties: {xx: {eq: '11'}}}}],
115
- ['x.y=xx x=1 {...}', {x: {eq: '1', properties: {y: {eq: 'xx'}}}}],
116
- ['a {b c} a=1', {a: {eq: '1'}}],
117
- ['a=1 a=...{b}', {a: {eq: '1', properties: {b: {}}}}],
118
- [
119
- 'a=red { shade=dark } color=$(a) shade=$(a.shade)',
120
- {
121
- a: {eq: 'red', properties: {shade: {eq: 'dark'}}},
122
- color: {eq: 'red', properties: {shade: {eq: 'dark'}}},
123
- shade: {eq: 'dark'},
124
- },
125
- ],
126
- ['x=.01', {x: {eq: '.01'}}],
127
- ['x=-7', {x: {eq: '-7'}}],
128
- ['x=7', {x: {eq: '7'}}],
129
- ['x=7.0', {x: {eq: '7.0'}}],
130
- ['x=.7', {x: {eq: '.7'}}],
131
- ['x=.7e2', {x: {eq: '.7e2'}}],
132
- ['x=7E2', {x: {eq: '7E2'}}],
115
+ ['x=1 x {xx=11}', {x: {eq: 1, properties: {xx: {eq: 11}}}}],
116
+ ['x.y=xx x=1 {...}', {x: {eq: 1, properties: {y: {eq: 'xx'}}}}],
117
+ ['a {b c} a=1', {a: {eq: 1}}],
118
+ ['a=1 a=...{b}', {a: {eq: 1, properties: {b: {}}}}],
119
+ ['x=.01', {x: {eq: 0.01}}],
120
+ ['x=-7', {x: {eq: -7}}],
121
+ ['x=7', {x: {eq: 7}}],
122
+ ['x=7.0', {x: {eq: 7.0}}],
123
+ ['x=.7', {x: {eq: 0.7}}],
124
+ ['x=.7e2', {x: {eq: 70}}],
125
+ ['x=7E2', {x: {eq: 700}}],
133
126
  ['`spacey name`=Zaphod', {'spacey name': {eq: 'Zaphod'}}],
127
+ ["name='single quoted'", {name: {eq: 'single quoted'}}],
134
128
  [
135
129
  'image { alt=hello { field=department } }',
136
130
  {
@@ -152,6 +146,57 @@ describe('tagParse to Tag', () => {
152
146
  },
153
147
  ],
154
148
  ['can remove.properties -...', {}],
149
+ // Colon syntax REPLACES properties (deletes old props)
150
+ ['name: { prop }', {name: {properties: {prop: {}}}}],
151
+ ['name: { a=1 b=2 }', {name: {properties: {a: {eq: 1}, b: {eq: 2}}}}],
152
+ ['name { old } name: { new }', {name: {properties: {new: {}}}}],
153
+ // Space syntax MERGES properties (keeps old props)
154
+ ['name { old } name { new }', {name: {properties: {old: {}, new: {}}}}],
155
+ // Colon and space syntax with dotted paths
156
+ ['a.b: { c }', {a: {properties: {b: {properties: {c: {}}}}}}],
157
+ [
158
+ 'a.b { c } a.b { d }',
159
+ {a: {properties: {b: {properties: {c: {}, d: {}}}}}},
160
+ ],
161
+ ['a.b { c } a.b: { d }', {a: {properties: {b: {properties: {d: {}}}}}}],
162
+ // Colon syntax deletes existing value
163
+ ['name=val name: { prop }', {name: {properties: {prop: {}}}}],
164
+ // Space syntax preserves existing value
165
+ ['name=val name { prop }', {name: {eq: 'val', properties: {prop: {}}}}],
166
+ // Multi-line input
167
+ [
168
+ 'person {\n name="ted"\n age=42\n}',
169
+ {person: {properties: {name: {eq: 'ted'}, age: {eq: 42}}}},
170
+ ],
171
+ // Triple-quoted strings (multi-line values)
172
+ ['desc="""hello"""', {desc: {eq: 'hello'}}],
173
+ ['desc="""line one\nline two"""', {desc: {eq: 'line one\nline two'}}],
174
+ ['desc="""has " quote"""', {desc: {eq: 'has " quote'}}],
175
+ ['desc="""has "" two quotes"""', {desc: {eq: 'has "" two quotes'}}],
176
+ // Boolean values
177
+ ['enabled=@true', {enabled: {eq: true}}],
178
+ ['disabled=@false', {disabled: {eq: false}}],
179
+ // Date values
180
+ ['created=@2024-01-15', {created: {eq: new Date('2024-01-15')}}],
181
+ [
182
+ 'updated=@2024-01-15T10:30:00Z',
183
+ {updated: {eq: new Date('2024-01-15T10:30:00Z')}},
184
+ ],
185
+ // Mixed types
186
+ [
187
+ 'config { enabled=@true count=42 name=test }',
188
+ {
189
+ config: {
190
+ properties: {
191
+ enabled: {eq: true},
192
+ count: {eq: 42},
193
+ name: {eq: 'test'},
194
+ },
195
+ },
196
+ },
197
+ ],
198
+ // Arrays with typed values
199
+ ['flags=[@true, @false]', {flags: {eq: [{eq: true}, {eq: false}]}}],
155
200
  ];
156
201
  test.each(tagTests)('tag %s', (expression: string, expected: TagDict) => {
157
202
  expect(expression).tagsAre(expected);
@@ -165,7 +210,7 @@ describe('tagParse to Tag', () => {
165
210
  describe('Tag access', () => {
166
211
  test('just text', () => {
167
212
  const strToParse = 'a=b';
168
- const getTags = Tag.fromTagLine(strToParse, undefined);
213
+ const getTags = parseTag(strToParse);
169
214
  expect(getTags.log).toEqual([]);
170
215
  const a = getTags.tag.tag('a');
171
216
  expect(a).toBeDefined();
@@ -173,7 +218,7 @@ describe('Tag access', () => {
173
218
  });
174
219
  test('tag path', () => {
175
220
  const strToParse = 'a.b.c.d.e=f';
176
- const tagParse = Tag.fromTagLine(strToParse, undefined);
221
+ const tagParse = parseTag(strToParse);
177
222
  expect(tagParse.log).toEqual([]);
178
223
  const abcde = tagParse.tag.tag('a', 'b', 'c', 'd', 'e');
179
224
  expect(abcde).toBeDefined();
@@ -181,7 +226,7 @@ describe('Tag access', () => {
181
226
  });
182
227
  test('just array', () => {
183
228
  const strToParse = 'a=[b]';
184
- const getTags = Tag.fromTagLine(strToParse, undefined);
229
+ const getTags = parseTag(strToParse);
185
230
  expect(getTags.log).toEqual([]);
186
231
  const a = getTags.tag.tag('a');
187
232
  const aval = a?.array();
@@ -193,7 +238,7 @@ describe('Tag access', () => {
193
238
  });
194
239
  test('tag path into array', () => {
195
240
  const strToParse = 'a.b.c = [{d=e}]';
196
- const tagParse = Tag.fromTagLine(strToParse, undefined);
241
+ const tagParse = parseTag(strToParse);
197
242
  expect(tagParse.log).toEqual([]);
198
243
  const abcde = tagParse.tag.tag('a', 'b', 'c', 0, 'd');
199
244
  expect(abcde).toBeDefined();
@@ -201,7 +246,7 @@ describe('Tag access', () => {
201
246
  });
202
247
  test('array as text', () => {
203
248
  const strToParse = 'a=[b]';
204
- const getTags = Tag.fromTagLine(strToParse, undefined);
249
+ const getTags = parseTag(strToParse);
205
250
  expect(getTags.log).toEqual([]);
206
251
  const a = getTags.tag.tag('a');
207
252
  expect(a).toBeDefined();
@@ -209,7 +254,7 @@ describe('Tag access', () => {
209
254
  });
210
255
  test('text as array', () => {
211
256
  const strToParse = 'a=b';
212
- const getTags = Tag.fromTagLine(strToParse, undefined);
257
+ const getTags = parseTag(strToParse);
213
258
  expect(getTags.log).toEqual([]);
214
259
  const a = getTags.tag.tag('a');
215
260
  expect(a).toBeDefined();
@@ -217,7 +262,7 @@ describe('Tag access', () => {
217
262
  });
218
263
  test('just numeric', () => {
219
264
  const strToParse = 'a=7';
220
- const getTags = Tag.fromTagLine(strToParse, undefined);
265
+ const getTags = parseTag(strToParse);
221
266
  expect(getTags.log).toEqual([]);
222
267
  const a = getTags.tag.tag('a');
223
268
  expect(a).toBeDefined();
@@ -227,7 +272,7 @@ describe('Tag access', () => {
227
272
  });
228
273
  test('text as numeric', () => {
229
274
  const strToParse = 'a=seven';
230
- const getTags = Tag.fromTagLine(strToParse, undefined);
275
+ const getTags = parseTag(strToParse);
231
276
  expect(getTags.log).toEqual([]);
232
277
  const a = getTags.tag.tag('a');
233
278
  expect(a).toBeDefined();
@@ -236,7 +281,7 @@ describe('Tag access', () => {
236
281
  });
237
282
  test('array as numeric', () => {
238
283
  const strToParse = 'a=[seven]';
239
- const getTags = Tag.fromTagLine(strToParse, undefined);
284
+ const getTags = parseTag(strToParse);
240
285
  expect(getTags.log).toEqual([]);
241
286
  const a = getTags.tag.tag('a');
242
287
  expect(a).toBeDefined();
@@ -245,7 +290,7 @@ describe('Tag access', () => {
245
290
  });
246
291
  test('full text array', () => {
247
292
  const strToParse = 'a=[b,c]';
248
- const getTags = Tag.fromTagLine(strToParse, undefined);
293
+ const getTags = parseTag(strToParse);
249
294
  expect(getTags.log).toEqual([]);
250
295
  const a = getTags.tag.tag('a');
251
296
  expect(a).toBeDefined();
@@ -254,7 +299,7 @@ describe('Tag access', () => {
254
299
  });
255
300
  test('filtered text array', () => {
256
301
  const strToParse = 'a=[b,c,{d}]';
257
- const getTags = Tag.fromTagLine(strToParse, undefined);
302
+ const getTags = parseTag(strToParse);
258
303
  expect(getTags.log).toEqual([]);
259
304
  const a = getTags.tag.tag('a');
260
305
  expect(a).toBeDefined();
@@ -263,7 +308,7 @@ describe('Tag access', () => {
263
308
  });
264
309
  test('full numeric array', () => {
265
310
  const strToParse = 'a=[1,2]';
266
- const getTags = Tag.fromTagLine(strToParse, undefined);
311
+ const getTags = parseTag(strToParse);
267
312
  expect(getTags.log).toEqual([]);
268
313
  const a = getTags.tag.tag('a');
269
314
  expect(a).toBeDefined();
@@ -272,7 +317,7 @@ describe('Tag access', () => {
272
317
  });
273
318
  test('filtered numeric array', () => {
274
319
  const strToParse = 'a=[1,2,three]';
275
- const getTags = Tag.fromTagLine(strToParse);
320
+ const getTags = parseTag(strToParse);
276
321
  expect(getTags.log).toEqual([]);
277
322
  const a = getTags.tag.tag('a');
278
323
  expect(a).toBeDefined();
@@ -281,15 +326,51 @@ describe('Tag access', () => {
281
326
  });
282
327
  test('has', () => {
283
328
  const strToParse = 'a b.d';
284
- const getTags = Tag.fromTagLine(strToParse);
329
+ const getTags = parseTag(strToParse);
285
330
  expect(getTags.log).toEqual([]);
286
331
  expect(getTags.tag.has('a')).toBeTruthy();
287
332
  expect(getTags.tag.has('b', 'd')).toBeTruthy();
288
333
  expect(getTags.tag.has('c')).toBeFalsy();
289
334
  });
335
+ test('boolean accessor', () => {
336
+ const {tag} = parseTag('enabled=@true disabled=@false');
337
+ expect(tag.boolean('enabled')).toBe(true);
338
+ expect(tag.boolean('disabled')).toBe(false);
339
+ expect(tag.boolean('missing')).toBeUndefined();
340
+ });
341
+ test('isTrue and isFalse', () => {
342
+ const {tag} = parseTag('enabled=@true disabled=@false name=test');
343
+ expect(tag.isTrue('enabled')).toBe(true);
344
+ expect(tag.isFalse('enabled')).toBe(false);
345
+ expect(tag.isTrue('disabled')).toBe(false);
346
+ expect(tag.isFalse('disabled')).toBe(true);
347
+ // Non-boolean values
348
+ expect(tag.isTrue('name')).toBe(false);
349
+ expect(tag.isFalse('name')).toBe(false);
350
+ // Missing values
351
+ expect(tag.isTrue('missing')).toBe(false);
352
+ expect(tag.isFalse('missing')).toBe(false);
353
+ });
354
+ test('date accessor', () => {
355
+ const {tag} = parseTag('created=@2024-01-15 updated=@2024-01-15T10:30:00Z');
356
+ const created = tag.date('created');
357
+ expect(created).toBeInstanceOf(Date);
358
+ expect(created?.toISOString()).toBe('2024-01-15T00:00:00.000Z');
359
+ const updated = tag.date('updated');
360
+ expect(updated).toBeInstanceOf(Date);
361
+ expect(updated?.toISOString()).toBe('2024-01-15T10:30:00.000Z');
362
+ expect(tag.date('missing')).toBeUndefined();
363
+ });
364
+ test('text returns string for all scalar types', () => {
365
+ const {tag} = parseTag('n=42 b=@true d=@2024-01-15 s=hello');
366
+ expect(tag.text('n')).toBe('42');
367
+ expect(tag.text('b')).toBe('true');
368
+ expect(tag.text('d')).toBe('2024-01-15T00:00:00.000Z');
369
+ expect(tag.text('s')).toBe('hello');
370
+ });
290
371
  test('property access on existing tag (which does not yet have properties)', () => {
291
- const parsePlot = Tag.fromTagLine('# plot');
292
- const parsed = Tag.fromTagLine('# plot.x=2', 0, parsePlot.tag);
372
+ const parsePlot = parseTag('# plot');
373
+ const parsed = parseTag('# plot.x=2', parsePlot.tag);
293
374
  const allTags = parsed.tag;
294
375
  const plotTag = allTags.tag('plot');
295
376
  const xTag = plotTag!.tag('x');
@@ -302,7 +383,7 @@ describe('Tag access', () => {
302
383
  const base = Tag.withPrefix('# ');
303
384
  const ext = base.set(['a', 'b', 0], 3).set(['a', 'b', 1], 4);
304
385
  expect(ext).tagsAre({
305
- a: {properties: {b: {eq: [{eq: '3'}, {eq: '4'}]}}},
386
+ a: {properties: {b: {eq: [{eq: 3}, {eq: 4}]}}},
306
387
  });
307
388
  expect(ext.toString()).toBe('# a.b = [3, 4]\n');
308
389
  });
@@ -313,13 +394,13 @@ describe('Tag access', () => {
313
394
  .set(['c', 0], 'foo')
314
395
  .set(['c', 0, 'a'], 4);
315
396
  expect(ext).tagsAre({
316
- a: {properties: {b: {eq: [{properties: {a: {eq: '3'}}}]}}},
317
- c: {eq: [{eq: 'foo', properties: {a: {eq: '4'}}}]},
397
+ a: {properties: {b: {eq: [{properties: {a: {eq: 3}}}]}}},
398
+ c: {eq: [{eq: 'foo', properties: {a: {eq: 4}}}]},
318
399
  });
319
400
  expect(ext.toString()).toBe('# a.b = [{ a = 3 }] c = [foo { a = 4 }]\n');
320
401
  });
321
402
  test('soft remove', () => {
322
- const base = Tag.fromTagLine('# a.b.c = [{ d = 1 }]').tag;
403
+ const base = parseTag('# a.b.c = [{ d = 1 }]').tag;
323
404
  const ext = base.delete('a', 'b', 'c', 0, 'd').delete('a', 'b', 'c', 0);
324
405
  expect(ext).tagsAre({
325
406
  a: {properties: {b: {properties: {c: {eq: []}}}}},
@@ -327,7 +408,7 @@ describe('Tag access', () => {
327
408
  expect(ext.toString()).toBe('# a.b.c = []\n');
328
409
  });
329
410
  test('hard remove', () => {
330
- const base = Tag.fromTagLine('# hello').tag;
411
+ const base = parseTag('# hello').tag;
331
412
  const ext = base.unset('goodbye').unset('a', 'dieu');
332
413
  expect(ext).tagsAre({
333
414
  hello: {},
@@ -384,7 +465,7 @@ describe('Tag access', () => {
384
465
  const base = Tag.withPrefix('#(malloy) ');
385
466
  const ext = base.set(['value'], '\n');
386
467
  expect(ext.toString()).toBe('#(malloy) value = "\\n"\n');
387
- expect(base.text('value')).toBe('\n');
468
+ expect(ext.text('value')).toBe('\n');
388
469
  idempotent(ext);
389
470
  });
390
471
  test('value has a double quote', () => {
@@ -393,7 +474,7 @@ describe('Tag access', () => {
393
474
  expect(ext.toString()).toBe('#(malloy) value = "\\""\n');
394
475
  idempotent(ext);
395
476
  });
396
- test.skip('value is empty string', () => {
477
+ test('value is empty string', () => {
397
478
  const base = Tag.withPrefix('#(malloy) ');
398
479
  const ext = base.set(['value'], '');
399
480
  expect(ext.toString()).toBe('#(malloy) value = ""\n');
@@ -412,11 +493,475 @@ describe('Tag access', () => {
412
493
  idempotent(ext);
413
494
  });
414
495
  });
496
+ describe('parsing escape sequences in strings', () => {
497
+ test('\\n becomes newline', () => {
498
+ const {tag} = parseTag('x="hello\\nworld"');
499
+ expect(tag.text('x')).toBe('hello\nworld');
500
+ });
501
+ test('\\t becomes tab', () => {
502
+ const {tag} = parseTag('x="hello\\tworld"');
503
+ expect(tag.text('x')).toBe('hello\tworld');
504
+ });
505
+ test('\\r becomes carriage return', () => {
506
+ const {tag} = parseTag('x="hello\\rworld"');
507
+ expect(tag.text('x')).toBe('hello\rworld');
508
+ });
509
+ test('\\b becomes backspace', () => {
510
+ const {tag} = parseTag('x="hello\\bworld"');
511
+ expect(tag.text('x')).toBe('hello\bworld');
512
+ });
513
+ test('\\f becomes form feed', () => {
514
+ const {tag} = parseTag('x="hello\\fworld"');
515
+ expect(tag.text('x')).toBe('hello\fworld');
516
+ });
517
+ test('\\uXXXX becomes unicode character', () => {
518
+ const {tag} = parseTag('x="hello\\u0026world"');
519
+ expect(tag.text('x')).toBe('hello&world');
520
+ });
521
+ test('\\uXXXX with uppercase hex', () => {
522
+ const {tag} = parseTag('x="\\u003F"');
523
+ expect(tag.text('x')).toBe('?');
524
+ });
525
+ test('\\\\ becomes backslash', () => {
526
+ const {tag} = parseTag('x="hello\\\\world"');
527
+ expect(tag.text('x')).toBe('hello\\world');
528
+ });
529
+ test('\\" becomes double quote', () => {
530
+ const {tag} = parseTag('x="hello\\"world"');
531
+ expect(tag.text('x')).toBe('hello"world');
532
+ });
533
+ test("\\' in single quoted string", () => {
534
+ const {tag} = parseTag("x='hello\\'world'");
535
+ expect(tag.text('x')).toBe("hello'world");
536
+ });
537
+ test('\\` in backtick identifier', () => {
538
+ const {tag} = parseTag('`hello\\`world`=value');
539
+ expect(tag.text('hello`world')).toBe('value');
540
+ });
541
+ });
542
+ });
543
+
544
+ describe('Tag prefix handling', () => {
545
+ test('# prefix skips to first space', () => {
546
+ const {tag, log} = parseTag('# name=value');
547
+ expect(log).toEqual([]);
548
+ expect(tag.text('name')).toEqual('value');
549
+ });
550
+
551
+ test('#(docs) prefix skips to first space', () => {
552
+ const {tag, log} = parseTag('#(docs) name=value');
553
+ expect(log).toEqual([]);
554
+ expect(tag.text('name')).toEqual('value');
555
+ });
556
+
557
+ test('# with no space returns empty tag', () => {
558
+ const {tag, log} = parseTag('#noSpace');
559
+ expect(log).toEqual([]);
560
+ expect(tag.properties).toBeUndefined();
561
+ });
562
+
563
+ test('everything after # on same line is ignored (comment behavior)', () => {
564
+ // When parsing a single tag line, # at start means "skip prefix"
565
+ // The rest of the line after the space is the tag content
566
+ const {tag, log} = parseTag('# name=value # this is not a comment');
567
+ expect(log).toEqual([]);
568
+ // The "# this is not a comment" is parsed as tag content, not ignored
569
+ // because single-line parsing doesn't have comment support
570
+ expect(tag.has('name')).toBe(true);
571
+ });
572
+ });
573
+
574
+ describe('Empty and whitespace input', () => {
575
+ test('empty string produces empty tag', () => {
576
+ const {tag, log} = parseTag('');
577
+ expect(log).toEqual([]);
578
+ expect(tag.properties).toBeUndefined();
579
+ });
580
+
581
+ test('whitespace only produces empty tag', () => {
582
+ const {tag, log} = parseTag(' ');
583
+ expect(log).toEqual([]);
584
+ expect(tag.properties).toBeUndefined();
585
+ });
586
+
587
+ test('whitespace with comment produces empty tag', () => {
588
+ const {tag, log} = parseTag(' # this is a comment');
589
+ expect(log).toEqual([]);
590
+ expect(tag.properties).toBeUndefined();
591
+ });
592
+ });
593
+
594
+ describe('Error handling', () => {
595
+ test('syntax error has 0-based line and offset', () => {
596
+ const {log} = parseTag('a = [');
597
+ expect(log.length).toBe(1);
598
+ expect(log[0].code).toBe('tag-parse-syntax-error');
599
+ expect(log[0].line).toBe(0);
600
+ // Error at position 5 (after "a = [")
601
+ expect(log[0].offset).toBeGreaterThan(0);
602
+ });
603
+
604
+ test('error offset accounts for input position', () => {
605
+ const {log} = parseTag('valid another_valid oops=');
606
+ expect(log.length).toBe(1);
607
+ expect(log[0].line).toBe(0);
608
+ // Error should be near end of line
609
+ expect(log[0].offset).toBeGreaterThan(20);
610
+ });
611
+
612
+ test('error offset after prefix stripping', () => {
613
+ // "# " is stripped, so input becomes " a = ["
614
+ const {log} = parseTag('# a = [');
615
+ expect(log.length).toBe(1);
616
+ expect(log[0].line).toBe(0);
617
+ // Offset is relative to stripped input (after "#")
618
+ expect(log[0].offset).toBeGreaterThan(0);
619
+ });
620
+
621
+ test('longer prefix is stripped correctly', () => {
622
+ // "#(docs) " is stripped
623
+ const {log} = parseTag('#(docs) a = [');
624
+ expect(log.length).toBe(1);
625
+ expect(log[0].line).toBe(0);
626
+ // Offset is relative to stripped input (after "#(docs)")
627
+ expect(log[0].offset).toBeGreaterThan(0);
628
+ });
629
+
630
+ test('error on second line reports correct line number', () => {
631
+ // Error is on line 1 (0-based), the unclosed bracket
632
+ const {log} = parseTag('valid=1\ninvalid=[');
633
+ expect(log.length).toBe(1);
634
+ expect(log[0].line).toBe(1);
635
+ expect(log[0].offset).toBeGreaterThan(0);
636
+ });
637
+
638
+ test('unclosed string with newline produces error', () => {
639
+ // Regular strings cannot contain raw newlines - must close on same line
640
+ const {log} = parseTag('desc="forgot to close\n');
641
+ expect(log.length).toBe(1);
642
+ expect(log[0].line).toBe(0);
643
+ });
415
644
  });
416
645
 
417
646
  function idempotent(tag: Tag) {
418
647
  const str = tag.toString();
419
- const clone = Tag.fromTagLine(str).tag;
648
+ const clone = parseTag(str).tag;
420
649
  clone.prefix = tag.prefix;
421
650
  expect(clone.toString()).toBe(str);
422
651
  }
652
+
653
+ describe('toObject', () => {
654
+ test('bare tag becomes true', () => {
655
+ const {tag} = parseTag('hidden');
656
+ expect(tag.toObject()).toEqual({hidden: true});
657
+ });
658
+
659
+ test('string value becomes string', () => {
660
+ const {tag} = parseTag('color=blue');
661
+ expect(tag.toObject()).toEqual({color: 'blue'});
662
+ });
663
+
664
+ test('numeric value becomes number', () => {
665
+ const {tag} = parseTag('size=10');
666
+ expect(tag.toObject()).toEqual({size: 10});
667
+ });
668
+
669
+ test('float value becomes number', () => {
670
+ const {tag} = parseTag('ratio=3.14');
671
+ expect(tag.toObject()).toEqual({ratio: 3.14});
672
+ });
673
+
674
+ test('properties only becomes nested object', () => {
675
+ const {tag} = parseTag('box { width=100 height=200 }');
676
+ expect(tag.toObject()).toEqual({box: {width: 100, height: 200}});
677
+ });
678
+
679
+ test('value and properties uses = key', () => {
680
+ const {tag} = parseTag('link="http://example.com" { target=_blank }');
681
+ expect(tag.toObject()).toEqual({
682
+ link: {'=': 'http://example.com', 'target': '_blank'},
683
+ });
684
+ });
685
+
686
+ test('array of simple values', () => {
687
+ const {tag} = parseTag('items=[a, b, c]');
688
+ expect(tag.toObject()).toEqual({items: ['a', 'b', 'c']});
689
+ });
690
+
691
+ test('array of numeric values', () => {
692
+ const {tag} = parseTag('nums=[1, 2, 3]');
693
+ expect(tag.toObject()).toEqual({nums: [1, 2, 3]});
694
+ });
695
+
696
+ test('array with properties on elements', () => {
697
+ const {tag} = parseTag('items=[a { x=1 }, b { y=2 }]');
698
+ expect(tag.toObject()).toEqual({
699
+ items: [
700
+ {'=': 'a', 'x': 1},
701
+ {'=': 'b', 'y': 2},
702
+ ],
703
+ });
704
+ });
705
+
706
+ test('complex nested structure', () => {
707
+ const {tag} = parseTag('# hidden color=blue size=10 box { width=100 }');
708
+ expect(tag.toObject()).toEqual({
709
+ hidden: true,
710
+ color: 'blue',
711
+ size: 10,
712
+ box: {width: 100},
713
+ });
714
+ });
715
+
716
+ test('deleted properties are excluded', () => {
717
+ const {tag} = parseTag('a b -a');
718
+ expect(tag.toObject()).toEqual({b: true});
719
+ });
720
+
721
+ test('empty tag returns empty object', () => {
722
+ const {tag} = parseTag('');
723
+ expect(tag.toObject()).toEqual({});
724
+ });
725
+
726
+ test('deeply nested properties', () => {
727
+ const {tag} = parseTag('a.b.c=1');
728
+ expect(tag.toObject()).toEqual({a: {b: {c: 1}}});
729
+ });
730
+
731
+ test('array of objects (dictionaries)', () => {
732
+ const {tag} = parseTag('items=[{name=alice age=30}, {name=bob age=25}]');
733
+ expect(tag.toObject()).toEqual({
734
+ items: [
735
+ {name: 'alice', age: 30},
736
+ {name: 'bob', age: 25},
737
+ ],
738
+ });
739
+ });
740
+ });
741
+
742
+ describe('Tag parent tracking', () => {
743
+ test('root tag has no parent', () => {
744
+ const {tag} = parseTag('a=1');
745
+ expect(tag.parent).toBeUndefined();
746
+ expect(tag.root).toBe(tag);
747
+ });
748
+
749
+ test('child tag has parent set', () => {
750
+ const {tag} = parseTag('a { b=1 }');
751
+ const a = tag.tag('a');
752
+ expect(a?.parent).toBe(tag);
753
+ });
754
+
755
+ test('nested child has correct parent chain', () => {
756
+ const {tag} = parseTag('a { b { c=1 } }');
757
+ const a = tag.tag('a');
758
+ const b = a?.tag('b');
759
+ const c = b?.tag('c');
760
+
761
+ expect(a?.parent).toBe(tag);
762
+ expect(b?.parent).toBe(a);
763
+ expect(c?.parent).toBe(b);
764
+ });
765
+
766
+ test('root traverses to top of tree', () => {
767
+ const {tag} = parseTag('a { b { c=1 } }');
768
+ const c = tag.tag('a', 'b', 'c');
769
+
770
+ expect(c?.root).toBe(tag);
771
+ });
772
+
773
+ test('array elements have parent set to containing tag', () => {
774
+ const {tag} = parseTag('items=[a, b, c]');
775
+ const items = tag.tag('items');
776
+ const arr = items?.array();
777
+
778
+ expect(arr?.[0].parent).toBe(items);
779
+ expect(arr?.[1].parent).toBe(items);
780
+ expect(arr?.[2].parent).toBe(items);
781
+ });
782
+
783
+ test('nested array elements have correct parents', () => {
784
+ const {tag} = parseTag('items=[{name=alice}, {name=bob}]');
785
+ const items = tag.tag('items');
786
+ const arr = items?.array();
787
+ const alice = arr?.[0];
788
+ const name = alice?.tag('name');
789
+
790
+ expect(alice?.parent).toBe(items);
791
+ expect(name?.parent).toBe(alice);
792
+ });
793
+
794
+ test('dict accessor returns tags with correct parent', () => {
795
+ const {tag} = parseTag('a=1 b=2 c=3');
796
+ const dict = tag.dict;
797
+
798
+ expect(dict['a'].parent).toBe(tag);
799
+ expect(dict['b'].parent).toBe(tag);
800
+ expect(dict['c'].parent).toBe(tag);
801
+ });
802
+
803
+ test('entries iterator returns tags with correct parent', () => {
804
+ const {tag} = parseTag('a=1 b=2');
805
+ for (const [, child] of tag.entries()) {
806
+ expect(child.parent).toBe(tag);
807
+ }
808
+ });
809
+ });
810
+
811
+ describe('References (RefTag)', () => {
812
+ test('absolute reference resolves to root property', () => {
813
+ const {tag} = parseTag('source=hello target=$source');
814
+ expect(tag.text('target')).toBe('hello');
815
+ });
816
+
817
+ test('absolute reference with path resolves correctly', () => {
818
+ const {tag} = parseTag(
819
+ 'config { db { host=localhost } } target=$config.db.host'
820
+ );
821
+ expect(tag.text('target')).toBe('localhost');
822
+ });
823
+
824
+ test('relative reference up one level', () => {
825
+ const {tag} = parseTag('outer { value=42 inner { ref=$^value } }');
826
+ expect(tag.numeric('outer', 'inner', 'ref')).toBe(42);
827
+ });
828
+
829
+ test('relative reference up two levels', () => {
830
+ const {tag} = parseTag('root=hello outer { inner { ref=$^^root } }');
831
+ expect(tag.text('outer', 'inner', 'ref')).toBe('hello');
832
+ });
833
+
834
+ test('reference with array index', () => {
835
+ const {tag} = parseTag('items=[first, second, third] target=$items[1]');
836
+ expect(tag.text('target')).toBe('second');
837
+ });
838
+
839
+ test('reference in array', () => {
840
+ const {tag} = parseTag('source=value refs=[$source, $source]');
841
+ const arr = tag.textArray('refs');
842
+ expect(arr).toEqual(['value', 'value']);
843
+ });
844
+
845
+ test('unresolved reference returns undefined', () => {
846
+ const {tag} = parseTag('ref=$nonexistent');
847
+ expect(tag.text('ref')).toBeUndefined();
848
+ });
849
+
850
+ test('RefTag.toRefString() returns source representation', () => {
851
+ const {tag} = parseTag('ref=$path.to.thing');
852
+ const ref = tag.tag('ref');
853
+ expect(ref).toBeInstanceOf(RefTag);
854
+ expect((ref as RefTag).toRefString()).toBe('$path.to.thing');
855
+ });
856
+
857
+ test('RefTag.toRefString() with ups', () => {
858
+ const {tag} = parseTag('a { ref=$^^root.path }');
859
+ const ref = tag.tag('a', 'ref');
860
+ expect(ref).toBeInstanceOf(RefTag);
861
+ expect((ref as RefTag).toRefString()).toBe('$^^root.path');
862
+ });
863
+
864
+ test('RefTag.toRefString() with array index', () => {
865
+ const {tag} = parseTag('ref=$items[0].name');
866
+ const ref = tag.tag('ref');
867
+ expect(ref).toBeInstanceOf(RefTag);
868
+ expect((ref as RefTag).toRefString()).toBe('$items[0].name');
869
+ });
870
+
871
+ test('chained reference access', () => {
872
+ const {tag} = parseTag('data { name=alice age=30 } ref=$data');
873
+ expect(tag.text('ref', 'name')).toBe('alice');
874
+ expect(tag.numeric('ref', 'age')).toBe(30);
875
+ });
876
+
877
+ test('reference has correct parent', () => {
878
+ const {tag} = parseTag('outer { ref=$something }');
879
+ const outer = tag.tag('outer');
880
+ const ref = outer?.tag('ref');
881
+ expect(ref?.parent).toBe(outer);
882
+ });
883
+
884
+ describe('validateReferences', () => {
885
+ test('no errors for valid references', () => {
886
+ const {tag} = parseTag('source=hello target=$source');
887
+ expect(tag.validateReferences()).toEqual([]);
888
+ });
889
+
890
+ test('error for unresolved reference', () => {
891
+ const {tag} = parseTag('ref=$nonexistent');
892
+ const errors = tag.validateReferences();
893
+ expect(errors).toHaveLength(1);
894
+ expect(errors[0]).toContain('Unresolved reference');
895
+ expect(errors[0]).toContain('$nonexistent');
896
+ });
897
+
898
+ test('error for unresolved nested reference', () => {
899
+ const {tag} = parseTag('outer { inner { ref=$missing } }');
900
+ const errors = tag.validateReferences();
901
+ expect(errors).toHaveLength(1);
902
+ expect(errors[0]).toContain('outer.inner.ref');
903
+ });
904
+
905
+ test('error for reference that goes up too far', () => {
906
+ const {tag} = parseTag('ref=$^^^^^way.too.far');
907
+ const errors = tag.validateReferences();
908
+ expect(errors).toHaveLength(1);
909
+ });
910
+
911
+ test('multiple unresolved references', () => {
912
+ const {tag} = parseTag('a=$missing1 b=$missing2');
913
+ const errors = tag.validateReferences();
914
+ expect(errors).toHaveLength(2);
915
+ });
916
+ });
917
+
918
+ describe('toJSON for references', () => {
919
+ test('RefTag serializes to linkTo marker', () => {
920
+ const {tag} = parseTag('ref=$path.to.thing');
921
+ const ref = tag.tag('ref');
922
+ expect(ref?.toJSON()).toEqual({linkTo: '$path.to.thing'});
923
+ });
924
+
925
+ test('RefTag with ups serializes correctly', () => {
926
+ const {tag} = parseTag('a { ref=$^^root }');
927
+ const ref = tag.tag('a', 'ref');
928
+ expect(ref?.toJSON()).toEqual({linkTo: '$^^root'});
929
+ });
930
+ });
931
+
932
+ describe('toObject with references', () => {
933
+ test('reference resolves to actual value', () => {
934
+ const {tag} = parseTag('source=hello target=$source');
935
+ const obj = tag.toObject();
936
+ expect(obj['target']).toBe('hello');
937
+ });
938
+
939
+ test('reference to object resolves correctly', () => {
940
+ const {tag} = parseTag('data { name=alice } ref=$data');
941
+ const obj = tag.toObject();
942
+ expect(obj['ref']).toEqual({name: 'alice'});
943
+ });
944
+
945
+ test('unresolved reference becomes undefined', () => {
946
+ const {tag} = parseTag('ref=$nonexistent');
947
+ const obj = tag.toObject();
948
+ expect(obj['ref']).toBeUndefined();
949
+ });
950
+ });
951
+
952
+ describe('cloning with references', () => {
953
+ test('reference survives when extending tag is cloned', () => {
954
+ // Parse two lines - first creates reference, second extends it
955
+ const {tag} = parseTag(['source=hello target=$source', 'extra=data']);
956
+ // The reference should still work after the second parse cloned the first result
957
+ expect(tag.text('target')).toBe('hello');
958
+ });
959
+
960
+ test('clone preserves RefTag', () => {
961
+ const {tag} = parseTag('source=hello target=$source');
962
+ const cloned = tag.clone();
963
+ // After cloning, the reference should still resolve
964
+ expect(cloned.text('target')).toBe('hello');
965
+ });
966
+ });
967
+ });