origamic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts ADDED
@@ -0,0 +1,1359 @@
1
+ import type {
2
+ CodeSlice,
3
+ Error,
4
+ JsonArray,
5
+ JsonBoolean,
6
+ JsonEllipsis,
7
+ JsonNull,
8
+ JsonNumber,
9
+ JsonObject,
10
+ JsonObjectEntry,
11
+ JsonString,
12
+ JsonValue,
13
+ Line,
14
+ ResponseExample,
15
+ ResponseSchema,
16
+ ResponseSchemaDeclaration,
17
+ Result,
18
+ Template,
19
+ TemplateFile,
20
+ TemplatePiece,
21
+ } from "./types.js";
22
+
23
+ interface HeaderParseResult {
24
+ readonly name: CodeSlice;
25
+ readonly variantName?: CodeSlice;
26
+ }
27
+
28
+ interface BodyBuildResult {
29
+ readonly text: string;
30
+ readonly sourceLineSegments: readonly SourceLineSegment[];
31
+ readonly endSourcePos: number;
32
+ readonly nextLineIndex: number;
33
+ readonly errors: readonly Error[];
34
+ }
35
+
36
+ interface SourceLineSegment {
37
+ readonly bodyStart: number;
38
+ readonly bodyEnd: number;
39
+ readonly sourceLineStart: number;
40
+ readonly dedentLen: number;
41
+ readonly sourceLineLength: number;
42
+ }
43
+
44
+ interface ParseState {
45
+ readonly content: string;
46
+ readonly lines: readonly Line[];
47
+ readonly errors: Error[];
48
+ }
49
+
50
+ export function parseTemplateFile(content: string): Result<TemplateFile> {
51
+ const lines = splitIntoLines(content);
52
+ const state: ParseState = {
53
+ content,
54
+ lines,
55
+ errors: [],
56
+ };
57
+
58
+ const templates: Template[] = [];
59
+ let lineIndex = 0;
60
+
61
+ while (lineIndex < lines.length) {
62
+ while (
63
+ lineIndex < lines.length &&
64
+ (isEmptyLine(lines[lineIndex]!) ||
65
+ isTopLevelCommentLine(lines[lineIndex]!))
66
+ ) {
67
+ lineIndex += 1;
68
+ }
69
+ if (lineIndex >= lines.length) {
70
+ break;
71
+ }
72
+
73
+ const headerLine = lines[lineIndex]!;
74
+ const header = parseTemplateHeaderLine(state, headerLine);
75
+ if (!header) {
76
+ lineIndex += 1;
77
+ continue;
78
+ }
79
+
80
+ lineIndex += 1;
81
+ const body = buildTemplateBody(state, lineIndex);
82
+ lineIndex = body.nextLineIndex;
83
+ state.errors.push(...body.errors);
84
+
85
+ const piecesParse = parseTemplatePieces(state, body);
86
+ templates.push({
87
+ name: header.name,
88
+ variantName: header.variantName,
89
+ pieces: piecesParse.pieces,
90
+ parameters: piecesParse.parameters,
91
+ });
92
+ }
93
+
94
+ return {
95
+ value: {
96
+ templates,
97
+ },
98
+ errors: state.errors,
99
+ };
100
+ }
101
+
102
+ function parseTemplateHeaderLine(
103
+ state: ParseState,
104
+ line: Line,
105
+ ): HeaderParseResult | null {
106
+ if (line.indent !== "") {
107
+ state.errors.push({
108
+ message: "Template header line must not be indented",
109
+ codeSlice: lineSlice(line),
110
+ });
111
+ return null;
112
+ }
113
+
114
+ const text = line.text;
115
+ let cursor = 0;
116
+
117
+ const parseIdentifier = (): CodeSlice | null => {
118
+ if (cursor >= text.length || !isIdentifierStart(text[cursor]!)) {
119
+ return null;
120
+ }
121
+ const start = cursor;
122
+ cursor += 1;
123
+ while (cursor < text.length && isIdentifierPart(text[cursor]!)) {
124
+ cursor += 1;
125
+ }
126
+ return {
127
+ text: text.slice(start, cursor),
128
+ startPos: line.startPos + start,
129
+ endPos: line.startPos + cursor,
130
+ line,
131
+ };
132
+ };
133
+
134
+ const skipSpaces = (): void => {
135
+ while (cursor < text.length && isSpace(text[cursor]!)) {
136
+ cursor += 1;
137
+ }
138
+ };
139
+
140
+ const name = parseIdentifier();
141
+ if (!name) {
142
+ state.errors.push({
143
+ message: "Expected template identifier",
144
+ codeSlice: lineSlice(line),
145
+ });
146
+ return null;
147
+ }
148
+
149
+ skipSpaces();
150
+ if (cursor >= text.length) {
151
+ state.errors.push({
152
+ expected: ":",
153
+ codeSlice: name,
154
+ });
155
+ return null;
156
+ }
157
+
158
+ if (text[cursor] === ":") {
159
+ cursor += 1;
160
+ if (cursor !== text.length) {
161
+ state.errors.push({
162
+ message: "Expected end of line after ':'",
163
+ codeSlice: lineSliceFromColumn(line, cursor),
164
+ });
165
+ return null;
166
+ }
167
+ return { name };
168
+ }
169
+
170
+ if (text[cursor] !== "/") {
171
+ state.errors.push({
172
+ expected: "'/' or ':'",
173
+ codeSlice: lineSliceFromColumn(line, cursor),
174
+ });
175
+ return null;
176
+ }
177
+
178
+ cursor += 1;
179
+ skipSpaces();
180
+ const variantName = parseIdentifier();
181
+ if (!variantName) {
182
+ state.errors.push({
183
+ message: "Expected variant identifier after '/'",
184
+ codeSlice: lineSliceFromColumn(line, cursor),
185
+ });
186
+ return null;
187
+ }
188
+
189
+ skipSpaces();
190
+ if (cursor >= text.length || text[cursor] !== ":") {
191
+ state.errors.push({
192
+ expected: ":",
193
+ codeSlice: variantName,
194
+ });
195
+ return null;
196
+ }
197
+
198
+ cursor += 1;
199
+ if (cursor !== text.length) {
200
+ state.errors.push({
201
+ message: "Expected end of line after ':'",
202
+ codeSlice: lineSliceFromColumn(line, cursor),
203
+ });
204
+ return null;
205
+ }
206
+
207
+ return { name, variantName };
208
+ }
209
+
210
+ function buildTemplateBody(
211
+ state: ParseState,
212
+ startLineIndex: number,
213
+ ): BodyBuildResult {
214
+ const errors: Error[] = [];
215
+ const bodyLines: Line[] = [];
216
+ let index = startLineIndex;
217
+
218
+ while (index < state.lines.length) {
219
+ const line = state.lines[index]!;
220
+ if (!isEmptyLine(line) && line.indent === "") {
221
+ break;
222
+ }
223
+ bodyLines.push(line);
224
+ index += 1;
225
+ }
226
+
227
+ let requiredIndent: string | null = null;
228
+ for (const line of bodyLines) {
229
+ if (isEmptyLine(line)) {
230
+ continue;
231
+ }
232
+ requiredIndent = line.indent;
233
+ break;
234
+ }
235
+
236
+ const sourceLineSegments: SourceLineSegment[] = [];
237
+ const dedentedLines: string[] = [];
238
+
239
+ for (const line of bodyLines) {
240
+ if (isEmptyLine(line)) {
241
+ dedentedLines.push("");
242
+ continue;
243
+ }
244
+
245
+ const expectedIndent = requiredIndent ?? "";
246
+ if (!line.text.startsWith(expectedIndent)) {
247
+ errors.push({
248
+ message: `Expected line to start with template indent '${expectedIndent.replace(/\t/g, "\\t")}'`,
249
+ codeSlice: lineSlice(line),
250
+ });
251
+ }
252
+
253
+ const dedentLen = line.text.startsWith(expectedIndent)
254
+ ? expectedIndent.length
255
+ : Math.min(line.indent.length, expectedIndent.length);
256
+ dedentedLines.push(line.text.slice(dedentLen));
257
+ }
258
+
259
+ let bodyCursor = 0;
260
+ for (let i = 0; i < bodyLines.length; i += 1) {
261
+ const line = bodyLines[i]!;
262
+ const dedented = dedentedLines[i]!;
263
+ const dedentLen = line.text.length - dedented.length;
264
+ sourceLineSegments.push({
265
+ bodyStart: bodyCursor,
266
+ bodyEnd: bodyCursor + dedented.length,
267
+ sourceLineStart: line.startPos,
268
+ dedentLen,
269
+ sourceLineLength: line.text.length,
270
+ });
271
+ bodyCursor += dedented.length;
272
+ if (i < bodyLines.length - 1) {
273
+ bodyCursor += 1;
274
+ }
275
+ }
276
+
277
+ const text = dedentedLines.join("\n");
278
+ const endSourcePos =
279
+ bodyLines.length === 0
280
+ ? (state.lines[Math.max(0, startLineIndex - 1)]?.startPos ?? 0)
281
+ : bodyLines[bodyLines.length - 1]!.startPos +
282
+ bodyLines[bodyLines.length - 1]!.text.length;
283
+
284
+ return {
285
+ text,
286
+ sourceLineSegments,
287
+ endSourcePos,
288
+ nextLineIndex: index,
289
+ errors,
290
+ };
291
+ }
292
+
293
+ function parseTemplatePieces(
294
+ state: ParseState,
295
+ body: BodyBuildResult,
296
+ ): {
297
+ readonly pieces: readonly TemplatePiece[];
298
+ readonly parameters: readonly string[];
299
+ } {
300
+ const pieces: TemplatePiece[] = [];
301
+ const parameters = new Set<string>();
302
+ let schemaCount = 0;
303
+
304
+ const text = body.text;
305
+ let cursor = 0;
306
+ let textBuffer = "";
307
+
308
+ const flushText = (): void => {
309
+ if (textBuffer.length === 0) {
310
+ return;
311
+ }
312
+ pieces.push({
313
+ kind: "text",
314
+ text: textBuffer,
315
+ });
316
+ textBuffer = "";
317
+ };
318
+
319
+ while (cursor < text.length) {
320
+ const ch = text[cursor]!;
321
+ if (ch !== "$") {
322
+ textBuffer += ch;
323
+ cursor += 1;
324
+ continue;
325
+ }
326
+
327
+ const next = text[cursor + 1];
328
+ if (next === "$") {
329
+ textBuffer += "$";
330
+ cursor += 2;
331
+ continue;
332
+ }
333
+
334
+ const tag = parseTagAt(text, cursor);
335
+ if (tag && !tag.closing) {
336
+ flushText();
337
+ if (tag.name === "schema") {
338
+ schemaCount += 1;
339
+ if (tag.selfClosing) {
340
+ const startPos = mapBodyPosToSource(body, cursor);
341
+ const endPos = mapBodyPosToSource(body, cursor + tag.length);
342
+ const line = findLineByPos(state.lines, startPos);
343
+ pieces.push({
344
+ kind: "schema",
345
+ text: "",
346
+ schema: {
347
+ codeSlice: {
348
+ text: "",
349
+ startPos,
350
+ endPos,
351
+ line,
352
+ },
353
+ declarations: [],
354
+ },
355
+ });
356
+ cursor += tag.length;
357
+ continue;
358
+ }
359
+
360
+ const close = findClosingTag(text, cursor + tag.length, tag.name);
361
+ if (!close) {
362
+ state.errors.push({
363
+ message: `Missing '$</${tag.name}>'`,
364
+ codeSlice: bodySlice(state, body, cursor, text.length),
365
+ });
366
+ const sectionRaw = text.slice(cursor + tag.length);
367
+ pieces.push({
368
+ kind: "schema",
369
+ text: tag.hide ? "" : sectionRaw,
370
+ schema: null,
371
+ });
372
+ cursor = text.length;
373
+ continue;
374
+ }
375
+
376
+ const raw = text.slice(cursor + tag.length, close.start);
377
+ const schema = parseResponseSchema(
378
+ state,
379
+ body,
380
+ cursor + tag.length,
381
+ close.start,
382
+ raw,
383
+ );
384
+ pieces.push({
385
+ kind: "schema",
386
+ text: tag.hide ? "" : raw,
387
+ schema,
388
+ });
389
+ cursor = close.start + close.length;
390
+ continue;
391
+ }
392
+
393
+ if (tag.name === "example") {
394
+ flushText();
395
+ const close = findClosingTag(text, cursor + tag.length, "example");
396
+ if (!close) {
397
+ state.errors.push({
398
+ message: "Missing '$</example>'",
399
+ codeSlice: bodySlice(state, body, cursor, text.length),
400
+ });
401
+ const raw = text.slice(cursor + tag.length);
402
+ pieces.push({
403
+ kind: "example",
404
+ text: raw,
405
+ example: parseResponseExample(
406
+ state,
407
+ body,
408
+ cursor + tag.length,
409
+ text.length,
410
+ raw,
411
+ ),
412
+ });
413
+ cursor = text.length;
414
+ continue;
415
+ }
416
+
417
+ const raw = text.slice(cursor + tag.length, close.start);
418
+ pieces.push({
419
+ kind: "example",
420
+ text: raw,
421
+ example: parseResponseExample(
422
+ state,
423
+ body,
424
+ cursor + tag.length,
425
+ close.start,
426
+ raw,
427
+ ),
428
+ });
429
+ cursor = close.start + close.length;
430
+ continue;
431
+ }
432
+ }
433
+
434
+ if (next === "{") {
435
+ const endBrace = text.indexOf("}", cursor + 2);
436
+ if (endBrace < 0) {
437
+ state.errors.push({
438
+ expected: "}",
439
+ codeSlice: bodySlice(
440
+ state,
441
+ body,
442
+ cursor,
443
+ Math.min(cursor + 2, text.length),
444
+ ),
445
+ });
446
+ textBuffer += "$";
447
+ cursor += 1;
448
+ continue;
449
+ }
450
+
451
+ const inner = text.slice(cursor + 2, endBrace);
452
+ const parsedExpr = parseExpressionWithModifier(inner);
453
+ if (!parsedExpr) {
454
+ state.errors.push({
455
+ message: "Invalid expression placeholder",
456
+ codeSlice: bodySlice(state, body, cursor, endBrace + 1),
457
+ });
458
+ textBuffer += text.slice(cursor, endBrace + 1);
459
+ cursor = endBrace + 1;
460
+ continue;
461
+ }
462
+
463
+ flushText();
464
+ parameters.add(parsedExpr.expression);
465
+ pieces.push({
466
+ kind: "expression",
467
+ expression: parsedExpr.expression,
468
+ hide: parsedExpr.hide,
469
+ extraIndent: currentLineExtraIndent(text, cursor),
470
+ });
471
+ cursor = endBrace + 1;
472
+ continue;
473
+ }
474
+
475
+ if (next && isIdentifierStart(next)) {
476
+ let end = cursor + 2;
477
+ while (end < text.length && isIdentifierPart(text[end]!)) {
478
+ end += 1;
479
+ }
480
+ flushText();
481
+ const expression = text.slice(cursor + 1, end);
482
+ parameters.add(expression);
483
+ pieces.push({
484
+ kind: "expression",
485
+ expression,
486
+ hide: false,
487
+ extraIndent: currentLineExtraIndent(text, cursor),
488
+ });
489
+ cursor = end;
490
+ continue;
491
+ }
492
+
493
+ state.errors.push({
494
+ message: "Invalid '$' sequence",
495
+ codeSlice: bodySlice(
496
+ state,
497
+ body,
498
+ cursor,
499
+ Math.min(cursor + 1, text.length),
500
+ ),
501
+ });
502
+ textBuffer += "$";
503
+ cursor += 1;
504
+ }
505
+
506
+ flushText();
507
+
508
+ if (schemaCount !== 1) {
509
+ state.errors.push({
510
+ message: "Template must contain exactly one schema section",
511
+ codeSlice: bodySlice(state, body, 0, text.length),
512
+ });
513
+ }
514
+
515
+ return {
516
+ pieces,
517
+ parameters: [...parameters],
518
+ };
519
+ }
520
+
521
+ function parseExpressionWithModifier(
522
+ source: string,
523
+ ): { readonly expression: string; readonly hide: boolean } | null {
524
+ const exprMatch = source.match(
525
+ /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*hide\s*)?$/,
526
+ );
527
+ if (!exprMatch) {
528
+ return null;
529
+ }
530
+ const hideMatch = source.match(/:\s*hide\s*$/);
531
+ return {
532
+ expression: exprMatch[1]!,
533
+ hide: Boolean(hideMatch),
534
+ };
535
+ }
536
+
537
+ function parseResponseSchema(
538
+ state: ParseState,
539
+ body: BodyBuildResult,
540
+ sectionStart: number,
541
+ sectionEnd: number,
542
+ sectionText: string,
543
+ ): ResponseSchema | null {
544
+ const startPos = mapBodyPosToSource(body, sectionStart);
545
+ const endPos = mapBodyPosToSource(body, sectionEnd);
546
+ const line = findLineByPos(state.lines, startPos);
547
+ const declarations: ResponseSchemaDeclaration[] = [];
548
+
549
+ const declarationRegex = /(interface|type)\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
550
+ let match: RegExpExecArray | null = declarationRegex.exec(sectionText);
551
+ while (match) {
552
+ const keyword = match[1]!;
553
+ const nameText = match[2]!;
554
+ const nameStartInSection = match.index + match[0].lastIndexOf(nameText);
555
+ const nameAbsPos = startPos + nameStartInSection;
556
+ const nameLine = findLineByPos(state.lines, nameAbsPos);
557
+ const nameSlice: CodeSlice = {
558
+ text: nameText,
559
+ startPos: nameAbsPos,
560
+ endPos: nameAbsPos + nameText.length,
561
+ line: nameLine,
562
+ };
563
+ const declSlice: CodeSlice = {
564
+ text: match[0],
565
+ startPos: startPos + match.index,
566
+ endPos: startPos + match.index + match[0].length,
567
+ line: nameLine,
568
+ };
569
+
570
+ declarations.push(
571
+ keyword === "interface"
572
+ ? {
573
+ kind: "interface",
574
+ name: nameSlice,
575
+ codeSlice: declSlice,
576
+ }
577
+ : {
578
+ kind: "type",
579
+ name: nameSlice,
580
+ codeSlice: declSlice,
581
+ },
582
+ );
583
+
584
+ match = declarationRegex.exec(sectionText);
585
+ }
586
+
587
+ return {
588
+ codeSlice: {
589
+ text: sectionText,
590
+ startPos,
591
+ endPos,
592
+ line,
593
+ },
594
+ declarations,
595
+ };
596
+ }
597
+
598
+ function parseResponseExample(
599
+ state: ParseState,
600
+ body: BodyBuildResult,
601
+ sectionStart: number,
602
+ sectionEnd: number,
603
+ sectionText: string,
604
+ ): ResponseExample {
605
+ const startPos = mapBodyPosToSource(body, sectionStart);
606
+ const endPos = mapBodyPosToSource(body, sectionEnd);
607
+ const line = findLineByPos(state.lines, startPos);
608
+ const jsonState: JsonParserState = {
609
+ text: sectionText,
610
+ cursor: 0,
611
+ line,
612
+ sourceStartPos: startPos,
613
+ errors: state.errors,
614
+ };
615
+
616
+ skipJsonSpaces(jsonState);
617
+ const value = parseJsonValue(jsonState);
618
+ skipJsonSpaces(jsonState);
619
+ if (value && jsonState.cursor < jsonState.text.length) {
620
+ state.errors.push({
621
+ message: "Unexpected trailing content after JSON value",
622
+ codeSlice: jsonSlice(jsonState, jsonState.cursor, jsonState.text.length),
623
+ });
624
+ }
625
+
626
+ return {
627
+ codeSlice: {
628
+ text: sectionText,
629
+ startPos,
630
+ endPos,
631
+ line,
632
+ },
633
+ value,
634
+ };
635
+ }
636
+
637
+ interface JsonParserState {
638
+ text: string;
639
+ cursor: number;
640
+ line: Line;
641
+ sourceStartPos: number;
642
+ errors: Error[];
643
+ }
644
+
645
+ function parseJsonValue(state: JsonParserState): JsonValue | null {
646
+ if (state.cursor >= state.text.length) {
647
+ state.errors.push({
648
+ message: "Expected JSON value",
649
+ codeSlice: jsonSlice(state, state.cursor, state.cursor),
650
+ });
651
+ return null;
652
+ }
653
+
654
+ const ch = state.text[state.cursor]!;
655
+ if (ch === "{") {
656
+ return parseJsonObject(state);
657
+ }
658
+ if (state.text.startsWith("...", state.cursor)) {
659
+ return parseJsonEllipsis(state);
660
+ }
661
+ if (ch === "[") {
662
+ return parseJsonArray(state);
663
+ }
664
+ if (ch === '"') {
665
+ return parseJsonString(state);
666
+ }
667
+ if (ch === "t" || ch === "f") {
668
+ return parseJsonBoolean(state);
669
+ }
670
+ if (ch === "n") {
671
+ return parseJsonNull(state);
672
+ }
673
+ if (ch === "-" || /[0-9]/.test(ch)) {
674
+ return parseJsonNumber(state);
675
+ }
676
+
677
+ state.errors.push({
678
+ message: "Invalid JSON value",
679
+ codeSlice: jsonSlice(
680
+ state,
681
+ state.cursor,
682
+ Math.min(state.cursor + 1, state.text.length),
683
+ ),
684
+ });
685
+ return null;
686
+ }
687
+
688
+ function parseJsonObject(state: JsonParserState): JsonObject | null {
689
+ const start = state.cursor;
690
+ state.cursor += 1;
691
+ skipJsonSpaces(state);
692
+ const entries: JsonObjectEntry[] = [];
693
+ if (state.text[state.cursor] === "}") {
694
+ state.cursor += 1;
695
+ return {
696
+ kind: "object",
697
+ codeSlice: jsonSlice(state, start, state.cursor),
698
+ entries,
699
+ };
700
+ }
701
+
702
+ let objectEllipsis: JsonEllipsis | undefined;
703
+
704
+ while (state.cursor < state.text.length) {
705
+ const ellipsis = parseJsonEllipsis(state);
706
+ if (ellipsis) {
707
+ skipJsonSpaces(state);
708
+ if (state.text[state.cursor] !== "}") {
709
+ state.errors.push({
710
+ expected: "}",
711
+ codeSlice: jsonSlice(
712
+ state,
713
+ state.cursor,
714
+ Math.min(state.cursor + 1, state.text.length),
715
+ ),
716
+ });
717
+ return null;
718
+ }
719
+ state.cursor += 1;
720
+ objectEllipsis = ellipsis;
721
+ return {
722
+ kind: "object",
723
+ codeSlice: jsonSlice(state, start, state.cursor),
724
+ entries,
725
+ ellipsis: objectEllipsis,
726
+ };
727
+ }
728
+
729
+ const key = parseJsonString(state);
730
+ if (!key) {
731
+ return null;
732
+ }
733
+ skipJsonSpaces(state);
734
+ if (state.text[state.cursor] !== ":") {
735
+ state.errors.push({
736
+ expected: ":",
737
+ codeSlice: jsonSlice(
738
+ state,
739
+ state.cursor,
740
+ Math.min(state.cursor + 1, state.text.length),
741
+ ),
742
+ });
743
+ return null;
744
+ }
745
+ state.cursor += 1;
746
+ skipJsonSpaces(state);
747
+ const value = parseJsonValue(state);
748
+ if (!value) {
749
+ return null;
750
+ }
751
+ entries.push({ key, value });
752
+ skipJsonSpaces(state);
753
+
754
+ if (state.text[state.cursor] === "}") {
755
+ state.cursor += 1;
756
+ return {
757
+ kind: "object",
758
+ codeSlice: jsonSlice(state, start, state.cursor),
759
+ entries,
760
+ ellipsis: objectEllipsis,
761
+ };
762
+ }
763
+ if (state.text[state.cursor] !== ",") {
764
+ state.errors.push({
765
+ expected: "',' or '}'",
766
+ codeSlice: jsonSlice(
767
+ state,
768
+ state.cursor,
769
+ Math.min(state.cursor + 1, state.text.length),
770
+ ),
771
+ });
772
+ return null;
773
+ }
774
+ state.cursor += 1;
775
+ skipJsonSpaces(state);
776
+ }
777
+
778
+ state.errors.push({
779
+ expected: "}",
780
+ codeSlice: jsonSlice(state, state.cursor, state.cursor),
781
+ });
782
+ return null;
783
+ }
784
+
785
+ function parseJsonEllipsis(state: JsonParserState): JsonEllipsis | null {
786
+ if (!state.text.startsWith("...", state.cursor)) {
787
+ return null;
788
+ }
789
+ const start = state.cursor;
790
+ state.cursor += 3;
791
+ return {
792
+ kind: "ellipsis",
793
+ codeSlice: jsonSlice(state, start, state.cursor),
794
+ };
795
+ }
796
+
797
+ function parseJsonArray(state: JsonParserState): JsonArray | null {
798
+ const start = state.cursor;
799
+ state.cursor += 1;
800
+ skipJsonSpaces(state);
801
+ const values: JsonValue[] = [];
802
+
803
+ if (state.text[state.cursor] === "]") {
804
+ state.cursor += 1;
805
+ return {
806
+ kind: "array",
807
+ codeSlice: jsonSlice(state, start, state.cursor),
808
+ values,
809
+ };
810
+ }
811
+
812
+ while (state.cursor < state.text.length) {
813
+ const value = parseJsonValue(state);
814
+ if (!value) {
815
+ return null;
816
+ }
817
+ values.push(value);
818
+ skipJsonSpaces(state);
819
+ if (state.text[state.cursor] === "]") {
820
+ state.cursor += 1;
821
+ return {
822
+ kind: "array",
823
+ codeSlice: jsonSlice(state, start, state.cursor),
824
+ values,
825
+ };
826
+ }
827
+ if (state.text[state.cursor] !== ",") {
828
+ state.errors.push({
829
+ expected: "',' or ']'",
830
+ codeSlice: jsonSlice(
831
+ state,
832
+ state.cursor,
833
+ Math.min(state.cursor + 1, state.text.length),
834
+ ),
835
+ });
836
+ return null;
837
+ }
838
+ state.cursor += 1;
839
+ skipJsonSpaces(state);
840
+ }
841
+
842
+ state.errors.push({
843
+ expected: "]",
844
+ codeSlice: jsonSlice(state, state.cursor, state.cursor),
845
+ });
846
+ return null;
847
+ }
848
+
849
+ function parseJsonString(state: JsonParserState): JsonString | null {
850
+ if (state.text[state.cursor] !== '"') {
851
+ state.errors.push({
852
+ expected: "JSON string",
853
+ codeSlice: jsonSlice(
854
+ state,
855
+ state.cursor,
856
+ Math.min(state.cursor + 1, state.text.length),
857
+ ),
858
+ });
859
+ return null;
860
+ }
861
+ const start = state.cursor;
862
+ state.cursor += 1;
863
+
864
+ while (state.cursor < state.text.length) {
865
+ const ch = state.text[state.cursor]!;
866
+ if (ch === "\\") {
867
+ state.cursor += 2;
868
+ continue;
869
+ }
870
+ if (ch === '"') {
871
+ const end = state.cursor + 1;
872
+ const raw = state.text.slice(start, end);
873
+ state.cursor = end;
874
+ return {
875
+ kind: "string",
876
+ value: decodeJsonString(raw),
877
+ codeSlice: jsonSlice(state, start, end),
878
+ };
879
+ }
880
+ state.cursor += 1;
881
+ }
882
+
883
+ state.errors.push({
884
+ expected: "closing quote for JSON string",
885
+ codeSlice: jsonSlice(state, start, state.cursor),
886
+ });
887
+ return null;
888
+ }
889
+
890
+ function parseJsonNumber(state: JsonParserState): JsonNumber | null {
891
+ const rest = state.text.slice(state.cursor);
892
+ const match = rest.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
893
+ if (!match) {
894
+ state.errors.push({
895
+ expected: "JSON number",
896
+ codeSlice: jsonSlice(
897
+ state,
898
+ state.cursor,
899
+ Math.min(state.cursor + 1, state.text.length),
900
+ ),
901
+ });
902
+ return null;
903
+ }
904
+ const start = state.cursor;
905
+ state.cursor += match[0].length;
906
+ return {
907
+ kind: "number",
908
+ value: Number(match[0]),
909
+ codeSlice: jsonSlice(state, start, state.cursor),
910
+ };
911
+ }
912
+
913
+ function parseJsonBoolean(state: JsonParserState): JsonBoolean | null {
914
+ const start = state.cursor;
915
+ if (state.text.startsWith("true", state.cursor)) {
916
+ state.cursor += 4;
917
+ return {
918
+ kind: "boolean",
919
+ value: true,
920
+ codeSlice: jsonSlice(state, start, state.cursor),
921
+ };
922
+ }
923
+ if (state.text.startsWith("false", state.cursor)) {
924
+ state.cursor += 5;
925
+ return {
926
+ kind: "boolean",
927
+ value: false,
928
+ codeSlice: jsonSlice(state, start, state.cursor),
929
+ };
930
+ }
931
+ state.errors.push({
932
+ expected: "'true' or 'false'",
933
+ codeSlice: jsonSlice(
934
+ state,
935
+ state.cursor,
936
+ Math.min(state.cursor + 1, state.text.length),
937
+ ),
938
+ });
939
+ return null;
940
+ }
941
+
942
+ function parseJsonNull(state: JsonParserState): JsonNull | null {
943
+ if (!state.text.startsWith("null", state.cursor)) {
944
+ state.errors.push({
945
+ expected: "'null'",
946
+ codeSlice: jsonSlice(
947
+ state,
948
+ state.cursor,
949
+ Math.min(state.cursor + 1, state.text.length),
950
+ ),
951
+ });
952
+ return null;
953
+ }
954
+ const start = state.cursor;
955
+ state.cursor += 4;
956
+ return {
957
+ kind: "null",
958
+ codeSlice: jsonSlice(state, start, state.cursor),
959
+ };
960
+ }
961
+
962
+ function decodeJsonString(raw: string): string {
963
+ try {
964
+ return JSON.parse(raw) as string;
965
+ } catch {
966
+ return raw.slice(1, -1);
967
+ }
968
+ }
969
+
970
+ function skipJsonSpaces(state: JsonParserState): void {
971
+ while (
972
+ state.cursor < state.text.length &&
973
+ /\s/.test(state.text[state.cursor]!)
974
+ ) {
975
+ state.cursor += 1;
976
+ }
977
+ }
978
+
979
+ function jsonSlice(
980
+ state: JsonParserState,
981
+ start: number,
982
+ end: number,
983
+ ): CodeSlice {
984
+ return {
985
+ text: state.text.slice(start, end),
986
+ startPos: state.sourceStartPos + start,
987
+ endPos: state.sourceStartPos + end,
988
+ line: state.line,
989
+ };
990
+ }
991
+
992
+ function parseTagAt(
993
+ text: string,
994
+ pos: number,
995
+ ): {
996
+ readonly name: "example" | "schema";
997
+ readonly closing: boolean;
998
+ readonly selfClosing: boolean;
999
+ readonly hide: boolean;
1000
+ readonly length: number;
1001
+ } | null {
1002
+ if (!text.startsWith("$<", pos)) {
1003
+ return null;
1004
+ }
1005
+ let cursor = pos + 2;
1006
+ while (cursor < text.length && isSpace(text[cursor]!)) {
1007
+ cursor += 1;
1008
+ }
1009
+
1010
+ let closing = false;
1011
+ if (text[cursor] === "/") {
1012
+ closing = true;
1013
+ cursor += 1;
1014
+ while (cursor < text.length && isSpace(text[cursor]!)) {
1015
+ cursor += 1;
1016
+ }
1017
+ }
1018
+
1019
+ let name = "";
1020
+ while (cursor < text.length && /[A-Za-z]/.test(text[cursor]!)) {
1021
+ name += text[cursor]!;
1022
+ cursor += 1;
1023
+ }
1024
+ if (name !== "example" && name !== "schema") {
1025
+ return null;
1026
+ }
1027
+
1028
+ while (cursor < text.length && isSpace(text[cursor]!)) {
1029
+ cursor += 1;
1030
+ }
1031
+
1032
+ // Detect optional "hide" attribute (only valid on non-closing schema tags)
1033
+ let hide = false;
1034
+ if (!closing && name !== "example") {
1035
+ if (
1036
+ text.startsWith("hide", cursor) &&
1037
+ (cursor + 4 >= text.length || !isIdentifierPart(text[cursor + 4]!))
1038
+ ) {
1039
+ hide = true;
1040
+ cursor += 4;
1041
+ while (cursor < text.length && isSpace(text[cursor]!)) {
1042
+ cursor += 1;
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ let selfClosing = false;
1048
+ if (text[cursor] === "/") {
1049
+ selfClosing = true;
1050
+ cursor += 1;
1051
+ while (cursor < text.length && isSpace(text[cursor]!)) {
1052
+ cursor += 1;
1053
+ }
1054
+ }
1055
+
1056
+ if (text[cursor] !== ">") {
1057
+ return null;
1058
+ }
1059
+ cursor += 1;
1060
+
1061
+ return {
1062
+ name,
1063
+ closing,
1064
+ selfClosing,
1065
+ hide,
1066
+ length: cursor - pos,
1067
+ };
1068
+ }
1069
+
1070
+ function findClosingTag(
1071
+ text: string,
1072
+ startPos: number,
1073
+ tagName: "example" | "schema",
1074
+ ): { readonly start: number; readonly length: number } | null {
1075
+ type ScanMode =
1076
+ | "normal"
1077
+ | "single"
1078
+ | "double"
1079
+ | "template"
1080
+ | "line-comment"
1081
+ | "block-comment";
1082
+ let mode: ScanMode = "normal";
1083
+
1084
+ let cursor = startPos;
1085
+ while (cursor < text.length) {
1086
+ const ch = text[cursor]!;
1087
+ const next = text[cursor + 1];
1088
+
1089
+ if (mode === "normal") {
1090
+ if (ch === "'") {
1091
+ mode = "single";
1092
+ cursor += 1;
1093
+ continue;
1094
+ }
1095
+ if (ch === '"') {
1096
+ mode = "double";
1097
+ cursor += 1;
1098
+ continue;
1099
+ }
1100
+ if (ch === "`") {
1101
+ mode = "template";
1102
+ cursor += 1;
1103
+ continue;
1104
+ }
1105
+ if (ch === "/" && next === "/") {
1106
+ mode = "line-comment";
1107
+ cursor += 2;
1108
+ continue;
1109
+ }
1110
+ if (ch === "/" && next === "*") {
1111
+ mode = "block-comment";
1112
+ cursor += 2;
1113
+ continue;
1114
+ }
1115
+ if (ch === "$" && next === "<") {
1116
+ const tag = parseTagAt(text, cursor);
1117
+ if (tag && tag.name === tagName && tag.closing) {
1118
+ return {
1119
+ start: cursor,
1120
+ length: tag.length,
1121
+ };
1122
+ }
1123
+ }
1124
+ cursor += 1;
1125
+ continue;
1126
+ }
1127
+
1128
+ if (mode === "single") {
1129
+ if (ch === "\n") {
1130
+ mode = "normal";
1131
+ cursor += 1;
1132
+ continue;
1133
+ }
1134
+ if (ch === "\\") {
1135
+ cursor += 2;
1136
+ continue;
1137
+ }
1138
+ if (ch === "'") {
1139
+ mode = "normal";
1140
+ }
1141
+ cursor += 1;
1142
+ continue;
1143
+ }
1144
+
1145
+ if (mode === "double") {
1146
+ if (ch === "\n") {
1147
+ mode = "normal";
1148
+ cursor += 1;
1149
+ continue;
1150
+ }
1151
+ if (ch === "\\") {
1152
+ cursor += 2;
1153
+ continue;
1154
+ }
1155
+ if (ch === '"') {
1156
+ mode = "normal";
1157
+ }
1158
+ cursor += 1;
1159
+ continue;
1160
+ }
1161
+
1162
+ if (mode === "template") {
1163
+ if (ch === "\\") {
1164
+ cursor += 2;
1165
+ continue;
1166
+ }
1167
+ if (ch === "`") {
1168
+ mode = "normal";
1169
+ }
1170
+ cursor += 1;
1171
+ continue;
1172
+ }
1173
+
1174
+ if (mode === "line-comment") {
1175
+ if (ch === "\n") {
1176
+ mode = "normal";
1177
+ }
1178
+ cursor += 1;
1179
+ continue;
1180
+ }
1181
+
1182
+ if (mode === "block-comment") {
1183
+ if (ch === "*" && next === "/") {
1184
+ mode = "normal";
1185
+ cursor += 2;
1186
+ continue;
1187
+ }
1188
+ cursor += 1;
1189
+ continue;
1190
+ }
1191
+ }
1192
+ return null;
1193
+ }
1194
+
1195
+ function currentLineExtraIndent(text: string, cursor: number): string {
1196
+ const lineStart = text.lastIndexOf("\n", cursor - 1) + 1;
1197
+ const leading = text.slice(lineStart, cursor).match(/^[ \t]*/);
1198
+ return leading ? leading[0] : "";
1199
+ }
1200
+
1201
+ function lineSlice(line: Line): CodeSlice {
1202
+ return {
1203
+ text: line.text,
1204
+ startPos: line.startPos,
1205
+ endPos: line.startPos + line.text.length,
1206
+ line,
1207
+ };
1208
+ }
1209
+
1210
+ function lineSliceFromColumn(line: Line, column: number): CodeSlice {
1211
+ return {
1212
+ text: line.text.slice(column),
1213
+ startPos: line.startPos + column,
1214
+ endPos: line.startPos + line.text.length,
1215
+ line,
1216
+ };
1217
+ }
1218
+
1219
+ function bodySlice(
1220
+ state: ParseState,
1221
+ body: BodyBuildResult,
1222
+ start: number,
1223
+ end: number,
1224
+ ): CodeSlice {
1225
+ const clampedStart = Math.max(0, Math.min(start, body.text.length));
1226
+ const clampedEnd = Math.max(clampedStart, Math.min(end, body.text.length));
1227
+ const startPos = mapBodyPosToSource(body, clampedStart);
1228
+ const endPos = mapBodyPosToSource(body, clampedEnd);
1229
+ return {
1230
+ text: body.text.slice(clampedStart, clampedEnd),
1231
+ startPos,
1232
+ endPos,
1233
+ line: findLineByPos(state.lines, startPos),
1234
+ };
1235
+ }
1236
+
1237
+ function mapBodyPosToSource(body: BodyBuildResult, bodyPos: number): number {
1238
+ if (bodyPos <= 0) {
1239
+ const first = body.sourceLineSegments[0];
1240
+ return first ? first.sourceLineStart + first.dedentLen : body.endSourcePos;
1241
+ }
1242
+ if (bodyPos >= body.text.length) {
1243
+ return body.endSourcePos;
1244
+ }
1245
+
1246
+ const segments = body.sourceLineSegments;
1247
+ let lo = 0;
1248
+ let hi = segments.length - 1;
1249
+ while (lo <= hi) {
1250
+ const mid = (lo + hi) >> 1;
1251
+ const segment = segments[mid]!;
1252
+ if (bodyPos < segment.bodyStart) {
1253
+ hi = mid - 1;
1254
+ continue;
1255
+ }
1256
+
1257
+ if (bodyPos <= segment.bodyEnd) {
1258
+ if (bodyPos < segment.bodyEnd) {
1259
+ const offsetInLine = bodyPos - segment.bodyStart;
1260
+ return segment.sourceLineStart + segment.dedentLen + offsetInLine;
1261
+ }
1262
+ // bodyPos hits the synthetic newline after this line.
1263
+ return segment.sourceLineStart + segment.sourceLineLength;
1264
+ }
1265
+
1266
+ lo = mid + 1;
1267
+ }
1268
+
1269
+ return body.endSourcePos;
1270
+ }
1271
+
1272
+ function findLineByPos(lines: readonly Line[], sourcePos: number): Line {
1273
+ let candidate = lines[0]!;
1274
+ for (const line of lines) {
1275
+ if (line.startPos <= sourcePos) {
1276
+ candidate = line;
1277
+ } else {
1278
+ break;
1279
+ }
1280
+ }
1281
+ return candidate;
1282
+ }
1283
+
1284
+ function splitIntoLines(content: string): Line[] {
1285
+ const getIndent = (lineText: string): string => {
1286
+ const match = lineText.match(/^[ \t]*/);
1287
+ return match ? match[0] : "";
1288
+ };
1289
+
1290
+ if (content.length === 0) {
1291
+ return [
1292
+ {
1293
+ lineNumber: 0,
1294
+ startPos: 0,
1295
+ text: "",
1296
+ indent: "",
1297
+ },
1298
+ ];
1299
+ }
1300
+
1301
+ const lines: Line[] = [];
1302
+ let lineNumber = 0;
1303
+ let cursor = 0;
1304
+ while (cursor < content.length) {
1305
+ const lineStart = cursor;
1306
+ while (
1307
+ cursor < content.length &&
1308
+ content[cursor] !== "\n" &&
1309
+ content[cursor] !== "\r"
1310
+ ) {
1311
+ cursor += 1;
1312
+ }
1313
+ const text = content.slice(lineStart, cursor);
1314
+ lines.push({
1315
+ lineNumber,
1316
+ startPos: lineStart,
1317
+ text,
1318
+ indent: getIndent(text),
1319
+ });
1320
+ if (cursor >= content.length) {
1321
+ break;
1322
+ }
1323
+ if (content[cursor] === "\r" && content[cursor + 1] === "\n") {
1324
+ cursor += 2;
1325
+ } else {
1326
+ cursor += 1;
1327
+ }
1328
+ lineNumber += 1;
1329
+ }
1330
+ if (content.endsWith("\n") || content.endsWith("\r")) {
1331
+ lines.push({
1332
+ lineNumber,
1333
+ startPos: content.length,
1334
+ text: "",
1335
+ indent: "",
1336
+ });
1337
+ }
1338
+ return lines;
1339
+ }
1340
+
1341
+ function isEmptyLine(line: Line): boolean {
1342
+ return line.text.trim() === "";
1343
+ }
1344
+
1345
+ function isTopLevelCommentLine(line: Line): boolean {
1346
+ return line.indent === "" && line.text.startsWith("#");
1347
+ }
1348
+
1349
+ function isIdentifierStart(ch: string): boolean {
1350
+ return /[A-Za-z_]/.test(ch);
1351
+ }
1352
+
1353
+ function isIdentifierPart(ch: string): boolean {
1354
+ return /[A-Za-z0-9_]/.test(ch);
1355
+ }
1356
+
1357
+ function isSpace(ch: string): boolean {
1358
+ return ch === " " || ch === "\t";
1359
+ }