@fazelstudio/floe 1.0.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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/cli/check.d.ts +14 -0
  3. package/dist/src/cli/check.d.ts.map +1 -0
  4. package/dist/src/cli/check.js +88 -0
  5. package/dist/src/cli/check.js.map +1 -0
  6. package/dist/src/cli/format.d.ts +9 -0
  7. package/dist/src/cli/format.d.ts.map +1 -0
  8. package/dist/src/cli/format.js +114 -0
  9. package/dist/src/cli/format.js.map +1 -0
  10. package/dist/src/cli/main.d.ts +19 -0
  11. package/dist/src/cli/main.d.ts.map +1 -0
  12. package/dist/src/cli/main.js +207 -0
  13. package/dist/src/cli/main.js.map +1 -0
  14. package/dist/src/cli/render.d.ts +7 -0
  15. package/dist/src/cli/render.d.ts.map +1 -0
  16. package/dist/src/cli/render.js +90 -0
  17. package/dist/src/cli/render.js.map +1 -0
  18. package/dist/src/diagnostics.d.ts +32 -0
  19. package/dist/src/diagnostics.d.ts.map +1 -0
  20. package/dist/src/diagnostics.js +4 -0
  21. package/dist/src/diagnostics.js.map +1 -0
  22. package/dist/src/index.d.ts +40 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/index.js +64 -0
  25. package/dist/src/index.js.map +1 -0
  26. package/dist/src/language/completion.d.ts +6 -0
  27. package/dist/src/language/completion.d.ts.map +1 -0
  28. package/dist/src/language/completion.js +363 -0
  29. package/dist/src/language/completion.js.map +1 -0
  30. package/dist/src/language/definitions.d.ts +9 -0
  31. package/dist/src/language/definitions.d.ts.map +1 -0
  32. package/dist/src/language/definitions.js +70 -0
  33. package/dist/src/language/definitions.js.map +1 -0
  34. package/dist/src/language/diagnosticsService.d.ts +14 -0
  35. package/dist/src/language/diagnosticsService.d.ts.map +1 -0
  36. package/dist/src/language/diagnosticsService.js +37 -0
  37. package/dist/src/language/diagnosticsService.js.map +1 -0
  38. package/dist/src/language/folding.d.ts +8 -0
  39. package/dist/src/language/folding.d.ts.map +1 -0
  40. package/dist/src/language/folding.js +58 -0
  41. package/dist/src/language/folding.js.map +1 -0
  42. package/dist/src/language/formatting.d.ts +5 -0
  43. package/dist/src/language/formatting.d.ts.map +1 -0
  44. package/dist/src/language/formatting.js +274 -0
  45. package/dist/src/language/formatting.js.map +1 -0
  46. package/dist/src/language/highlighting.d.ts +9 -0
  47. package/dist/src/language/highlighting.d.ts.map +1 -0
  48. package/dist/src/language/highlighting.js +74 -0
  49. package/dist/src/language/highlighting.js.map +1 -0
  50. package/dist/src/language/hover.d.ts +6 -0
  51. package/dist/src/language/hover.d.ts.map +1 -0
  52. package/dist/src/language/hover.js +197 -0
  53. package/dist/src/language/hover.js.map +1 -0
  54. package/dist/src/language/indentation.d.ts +15 -0
  55. package/dist/src/language/indentation.d.ts.map +1 -0
  56. package/dist/src/language/indentation.js +97 -0
  57. package/dist/src/language/indentation.js.map +1 -0
  58. package/dist/src/language/index.d.ts +54 -0
  59. package/dist/src/language/index.d.ts.map +1 -0
  60. package/dist/src/language/index.js +85 -0
  61. package/dist/src/language/index.js.map +1 -0
  62. package/dist/src/language/references.d.ts +12 -0
  63. package/dist/src/language/references.d.ts.map +1 -0
  64. package/dist/src/language/references.js +35 -0
  65. package/dist/src/language/references.js.map +1 -0
  66. package/dist/src/language/rename.d.ts +18 -0
  67. package/dist/src/language/rename.d.ts.map +1 -0
  68. package/dist/src/language/rename.js +53 -0
  69. package/dist/src/language/rename.js.map +1 -0
  70. package/dist/src/language/semanticModel.d.ts +21 -0
  71. package/dist/src/language/semanticModel.d.ts.map +1 -0
  72. package/dist/src/language/semanticModel.js +43 -0
  73. package/dist/src/language/semanticModel.js.map +1 -0
  74. package/dist/src/language/symbols.d.ts +9 -0
  75. package/dist/src/language/symbols.d.ts.map +1 -0
  76. package/dist/src/language/symbols.js +160 -0
  77. package/dist/src/language/symbols.js.map +1 -0
  78. package/dist/src/language/types.d.ts +49 -0
  79. package/dist/src/language/types.d.ts.map +1 -0
  80. package/dist/src/language/types.js +2 -0
  81. package/dist/src/language/types.js.map +1 -0
  82. package/dist/src/language/utils.d.ts +24 -0
  83. package/dist/src/language/utils.d.ts.map +1 -0
  84. package/dist/src/language/utils.js +154 -0
  85. package/dist/src/language/utils.js.map +1 -0
  86. package/dist/src/layout/dagre.d.ts +13 -0
  87. package/dist/src/layout/dagre.d.ts.map +1 -0
  88. package/dist/src/layout/dagre.js +229 -0
  89. package/dist/src/layout/dagre.js.map +1 -0
  90. package/dist/src/layout/index.d.ts +15 -0
  91. package/dist/src/layout/index.d.ts.map +1 -0
  92. package/dist/src/layout/index.js +22 -0
  93. package/dist/src/layout/index.js.map +1 -0
  94. package/dist/src/layout/simple.d.ts +20 -0
  95. package/dist/src/layout/simple.d.ts.map +1 -0
  96. package/dist/src/layout/simple.js +674 -0
  97. package/dist/src/layout/simple.js.map +1 -0
  98. package/dist/src/layout/types.d.ts +65 -0
  99. package/dist/src/layout/types.d.ts.map +1 -0
  100. package/dist/src/layout/types.js +2 -0
  101. package/dist/src/layout/types.js.map +1 -0
  102. package/dist/src/lexer.d.ts +25 -0
  103. package/dist/src/lexer.d.ts.map +1 -0
  104. package/dist/src/lexer.js +280 -0
  105. package/dist/src/lexer.js.map +1 -0
  106. package/dist/src/lsp/documents.d.ts +24 -0
  107. package/dist/src/lsp/documents.d.ts.map +1 -0
  108. package/dist/src/lsp/documents.js +97 -0
  109. package/dist/src/lsp/documents.js.map +1 -0
  110. package/dist/src/lsp/server.d.ts +70 -0
  111. package/dist/src/lsp/server.d.ts.map +1 -0
  112. package/dist/src/lsp/server.js +623 -0
  113. package/dist/src/lsp/server.js.map +1 -0
  114. package/dist/src/lsp/utils.d.ts +47 -0
  115. package/dist/src/lsp/utils.d.ts.map +1 -0
  116. package/dist/src/lsp/utils.js +142 -0
  117. package/dist/src/lsp/utils.js.map +1 -0
  118. package/dist/src/parser.d.ts +106 -0
  119. package/dist/src/parser.d.ts.map +1 -0
  120. package/dist/src/parser.js +1018 -0
  121. package/dist/src/parser.js.map +1 -0
  122. package/dist/src/pipeline.d.ts +51 -0
  123. package/dist/src/pipeline.d.ts.map +1 -0
  124. package/dist/src/pipeline.js +65 -0
  125. package/dist/src/pipeline.js.map +1 -0
  126. package/dist/src/range.d.ts +24 -0
  127. package/dist/src/range.d.ts.map +1 -0
  128. package/dist/src/range.js +20 -0
  129. package/dist/src/range.js.map +1 -0
  130. package/dist/src/render/index.d.ts +3 -0
  131. package/dist/src/render/index.d.ts.map +1 -0
  132. package/dist/src/render/index.js +3 -0
  133. package/dist/src/render/index.js.map +1 -0
  134. package/dist/src/render/shapes.d.ts +20 -0
  135. package/dist/src/render/shapes.d.ts.map +1 -0
  136. package/dist/src/render/shapes.js +110 -0
  137. package/dist/src/render/shapes.js.map +1 -0
  138. package/dist/src/render/svg.d.ts +19 -0
  139. package/dist/src/render/svg.d.ts.map +1 -0
  140. package/dist/src/render/svg.js +227 -0
  141. package/dist/src/render/svg.js.map +1 -0
  142. package/dist/src/security/url.d.ts +7 -0
  143. package/dist/src/security/url.d.ts.map +1 -0
  144. package/dist/src/security/url.js +27 -0
  145. package/dist/src/security/url.js.map +1 -0
  146. package/dist/src/types.d.ts +111 -0
  147. package/dist/src/types.d.ts.map +1 -0
  148. package/dist/src/types.js +16 -0
  149. package/dist/src/types.js.map +1 -0
  150. package/dist/src/validator.d.ts +47 -0
  151. package/dist/src/validator.d.ts.map +1 -0
  152. package/dist/src/validator.js +235 -0
  153. package/dist/src/validator.js.map +1 -0
  154. package/package.json +90 -0
@@ -0,0 +1,1018 @@
1
+ import { Lexer } from "./lexer.js";
2
+ import { diag } from "./diagnostics.js";
3
+ import { DEFAULT_DIRECTION, DIRECTIONS } from "./types.js";
4
+ /**
5
+ * Floe grammar-based parser
6
+ *
7
+ * Grammar (EBNF):
8
+ * Program ::= (Statement | NEWLINE | COMMENT)* EOF
9
+ * Statement ::= DirectionStmt | NodeStmt | EdgeStmt | GroupStmt | MetadataStmt | AnnotationStmt | LinkStmt
10
+ * DirectionStmt ::= "direction" Direction
11
+ * Direction ::= "TB" | "BT" | "LR" | "RL"
12
+ * NodeStmt ::= IDENT ("[" IDENT "]")? (STRING)?
13
+ * EdgeStmt ::= IDENT EdgeOp IDENT (":" Label)?
14
+ * EdgeOp ::= "->" | "--"
15
+ * Label ::= <trimmed raw slice after ":"> (non-empty) OR STRING decoded
16
+ * GroupStmt ::= "group" IDENT ("[" IDENT "]")? (STRING)? "{" GroupBody "}"
17
+ * GroupBody ::= (Statement | NEWLINE | COMMENT)*
18
+ * MetadataStmt ::= "meta" IDENT "=" STRING
19
+ * AnnotationStmt::= "note" (IDENT)? STRING
20
+ * LinkStmt ::= "link" IDENT STRING
21
+ *
22
+ * Features:
23
+ * - source ranges for all nodes/edges/groups
24
+ * - syntax error reporting with stable codes (E001-E014)
25
+ * - recovery via synchronization to next NEWLINE or RBRACE
26
+ * - never crashes on malformed input
27
+ * - nested groups supported
28
+ */
29
+ export class Parser {
30
+ source;
31
+ tokens;
32
+ idx = 0;
33
+ diagnostics = [];
34
+ explicitNodes = [];
35
+ edges = [];
36
+ direction = DEFAULT_DIRECTION;
37
+ directionRange;
38
+ directionSeen = false;
39
+ directionDeclarations = [];
40
+ // v0.3 fields
41
+ groups = [];
42
+ groupStack = [];
43
+ metadata = {};
44
+ metadataRanges = new Map();
45
+ annotations = [];
46
+ links = [];
47
+ linkMap = new Map();
48
+ constructor(source) {
49
+ this.source = source;
50
+ this.tokens = new Lexer(source).tokenize();
51
+ }
52
+ parse() {
53
+ try {
54
+ this.loop();
55
+ }
56
+ catch (e) {
57
+ const msg = e instanceof Error ? e.message : String(e);
58
+ const pos = this.peek().range.start;
59
+ this.diagnostics.push(diag("error", "E005", `Internal parser error: ${msg}`, {
60
+ start: pos,
61
+ end: pos,
62
+ }));
63
+ }
64
+ // Handle unclosed groups (missing })
65
+ for (const grp of this.groupStack) {
66
+ this.diagnostics.push(diag("error", "E012", `Unclosed group '${grp.id}': missing closing '}'`, grp.range));
67
+ // Treat as closed at EOF
68
+ }
69
+ // Include remaining unclosed groups as if closed (they are already in hierarchy)
70
+ // groupStack should be cleared
71
+ // Note: groups[] already contains them via hierarchy, but top-level groups already inserted
72
+ // No extra needed; just clear stack
73
+ this.groupStack = [];
74
+ // Build full node list: explicit + implicit from edges
75
+ const nodeMap = new Map();
76
+ for (const n of this.explicitNodes) {
77
+ if (!nodeMap.has(n.id))
78
+ nodeMap.set(n.id, n);
79
+ }
80
+ for (const e of this.edges) {
81
+ if (!nodeMap.has(e.source)) {
82
+ nodeMap.set(e.source, {
83
+ id: e.source,
84
+ range: e.sourceRange,
85
+ });
86
+ }
87
+ if (!nodeMap.has(e.target)) {
88
+ nodeMap.set(e.target, {
89
+ id: e.target,
90
+ range: e.targetRange,
91
+ });
92
+ }
93
+ }
94
+ const nodes = Array.from(nodeMap.values());
95
+ const diagram = {
96
+ direction: this.direction,
97
+ directionRange: this.directionRange,
98
+ nodes,
99
+ edges: this.edges,
100
+ groups: this.groups,
101
+ metadata: this.metadata,
102
+ annotations: this.annotations,
103
+ links: this.links,
104
+ };
105
+ this.diagnostics.sort((a, b) => a.range.start.offset - b.range.start.offset);
106
+ return {
107
+ diagram,
108
+ diagnostics: this.diagnostics,
109
+ explicitNodes: this.explicitNodes,
110
+ edges: this.edges,
111
+ directionDeclarations: this.directionDeclarations,
112
+ groups: this.groups,
113
+ metadata: this.metadata,
114
+ annotations: this.annotations,
115
+ links: this.links,
116
+ };
117
+ }
118
+ currentGroup() {
119
+ if (this.groupStack.length === 0)
120
+ return undefined;
121
+ return this.groupStack[this.groupStack.length - 1];
122
+ }
123
+ peek() {
124
+ return this.tokens[this.idx] ?? this.tokens[this.tokens.length - 1];
125
+ }
126
+ previous() {
127
+ return this.tokens[Math.max(0, this.idx - 1)];
128
+ }
129
+ isAtEnd() {
130
+ return this.peek().type === "EOF";
131
+ }
132
+ check(type) {
133
+ if (this.isAtEnd() && type !== "EOF")
134
+ return false;
135
+ return this.peek().type === type;
136
+ }
137
+ advance() {
138
+ if (!this.isAtEnd())
139
+ this.idx++;
140
+ return this.previous();
141
+ }
142
+ match(type) {
143
+ if (this.check(type)) {
144
+ this.advance();
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ codeForUnexpected(tok) {
150
+ if (tok.type === "UNKNOWN") {
151
+ if (/^[0-9]/.test(tok.lexeme))
152
+ return "E002";
153
+ return "E007";
154
+ }
155
+ return "E005";
156
+ }
157
+ synchronize() {
158
+ // Skip until NEWLINE, RBRACE, COMMENT or EOF
159
+ while (!this.isAtEnd()) {
160
+ if (this.check("NEWLINE")) {
161
+ this.advance();
162
+ return;
163
+ }
164
+ if (this.check("RBRACE")) {
165
+ // Do not consume RBRACE here; let loop handle group close
166
+ return;
167
+ }
168
+ if (this.check("COMMENT")) {
169
+ this.advance();
170
+ if (this.check("NEWLINE"))
171
+ this.advance();
172
+ return;
173
+ }
174
+ this.advance();
175
+ }
176
+ }
177
+ loop() {
178
+ while (!this.isAtEnd()) {
179
+ if (this.match("NEWLINE"))
180
+ continue;
181
+ if (this.match("COMMENT")) {
182
+ continue;
183
+ }
184
+ // Handle closing brace for groups
185
+ if (this.check("RBRACE")) {
186
+ const tok = this.advance();
187
+ const grp = this.groupStack.pop();
188
+ if (!grp) {
189
+ this.diagnostics.push(diag("error", "E005", `Unexpected '}' without matching 'group'`, tok.range));
190
+ }
191
+ else {
192
+ // Update group's range to include closing brace
193
+ grp.range = { start: grp.range.start, end: tok.range.end };
194
+ }
195
+ continue;
196
+ }
197
+ if (this.check("UNKNOWN")) {
198
+ const bad = this.advance();
199
+ // Distinguish unterminated string: lexeme starts with "
200
+ if (bad.lexeme.startsWith('"')) {
201
+ this.diagnostics.push(diag("error", "E006", `Unterminated string: missing closing '"'`, bad.range));
202
+ }
203
+ else {
204
+ const isDigitStart = /^[0-9]/.test(bad.lexeme);
205
+ if (isDigitStart) {
206
+ this.diagnostics.push(diag("error", "E002", `Invalid identifier '${bad.lexeme}': must start with a letter or underscore`, bad.range));
207
+ }
208
+ else {
209
+ this.diagnostics.push(diag("error", "E007", `Invalid character '${bad.lexeme}'`, bad.range));
210
+ }
211
+ }
212
+ this.synchronize();
213
+ continue;
214
+ }
215
+ if (this.check("DIRECTION_KW")) {
216
+ this.parseDirectionStmt();
217
+ continue;
218
+ }
219
+ if (this.check("GROUP_KW")) {
220
+ this.parseGroupStmt();
221
+ continue;
222
+ }
223
+ if (this.check("META_KW")) {
224
+ this.parseMetadataStmt();
225
+ continue;
226
+ }
227
+ if (this.check("NOTE_KW")) {
228
+ this.parseAnnotationStmt();
229
+ continue;
230
+ }
231
+ if (this.check("LINK_KW")) {
232
+ this.parseLinkStmt();
233
+ continue;
234
+ }
235
+ if (this.check("IDENT")) {
236
+ const look = this.tokens[this.idx + 1];
237
+ if (!look) {
238
+ this.parseNodeStmt();
239
+ continue;
240
+ }
241
+ if (look.type === "ARROW" || look.type === "DASHDASH") {
242
+ this.parseEdgeStmt();
243
+ continue;
244
+ }
245
+ if (look.type === "LBRACKET") {
246
+ this.parseNodeStmt();
247
+ continue;
248
+ }
249
+ if (look.type === "STRING") {
250
+ // Could be node with label: IDENT STRING
251
+ // Peek ahead after STRING to see if next is ARROW/DASHDASH? e.g., API "label" -> B ? That's unlikely but treat as node for now
252
+ // If after IDENT STRING there is ARROW, then this IDENT would be edge source; but we already checked next is STRING not ARROW, so it's node.
253
+ this.parseNodeStmt();
254
+ continue;
255
+ }
256
+ if (look.type === "NEWLINE" ||
257
+ look.type === "COMMENT" ||
258
+ look.type === "EOF" ||
259
+ look.type === "RBRACE") {
260
+ this.parseNodeStmt();
261
+ continue;
262
+ }
263
+ if (look.type === "COLON") {
264
+ const idTok = this.advance();
265
+ const colon = this.advance();
266
+ this.diagnostics.push(diag("error", "E005", `Malformed statement: expected '->' or '--' between identifiers before ':'`, { start: idTok.range.start, end: colon.range.end }));
267
+ this.synchronize();
268
+ continue;
269
+ }
270
+ this.parseNodeStmt();
271
+ continue;
272
+ }
273
+ {
274
+ const tok = this.advance();
275
+ this.diagnostics.push(diag("error", "E005", `Unexpected token '${tok.lexeme}' at start of statement`, tok.range));
276
+ this.synchronize();
277
+ continue;
278
+ }
279
+ }
280
+ }
281
+ parseDirectionStmt() {
282
+ const kw = this.advance(); // DIRECTION_KW
283
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
284
+ this.diagnostics.push(diag("error", "E006", `Incomplete direction statement: expected one of ${DIRECTIONS.join(", ")} after 'direction'`, kw.range));
285
+ return;
286
+ }
287
+ let dirTok = null;
288
+ if (this.check("IDENT") || this.check("UNKNOWN")) {
289
+ dirTok = this.advance();
290
+ }
291
+ else {
292
+ const tok = this.advance();
293
+ this.diagnostics.push(diag("error", "E005", `Unexpected token '${tok.lexeme}' after 'direction'; expected one of ${DIRECTIONS.join(", ")}`, tok.range));
294
+ this.synchronize();
295
+ return;
296
+ }
297
+ const raw = dirTok.lexeme;
298
+ const isValid = DIRECTIONS.includes(raw);
299
+ const declRange = { start: kw.range.start, end: dirTok.range.end };
300
+ this.directionDeclarations.push({ value: raw, range: declRange, rawLexeme: raw });
301
+ if (!isValid) {
302
+ if (/^[0-9]/.test(raw)) {
303
+ this.diagnostics.push(diag("error", "E002", `Invalid identifier '${raw}' for direction; expected one of ${DIRECTIONS.join(", ")}`, dirTok.range));
304
+ }
305
+ else {
306
+ this.diagnostics.push(diag("error", "E001", `Invalid direction '${raw}': expected one of ${DIRECTIONS.join(", ")}`, dirTok.range));
307
+ }
308
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
309
+ const extra = this.peek();
310
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after direction value`, extra.range));
311
+ this.synchronize();
312
+ }
313
+ return;
314
+ }
315
+ const newDirection = raw;
316
+ this.direction = newDirection;
317
+ this.directionRange = declRange;
318
+ this.directionSeen = true;
319
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
320
+ const extra = this.peek();
321
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after direction value`, extra.range));
322
+ this.synchronize();
323
+ }
324
+ }
325
+ parseGroupStmt() {
326
+ const kw = this.advance(); // GROUP_KW
327
+ let start = kw.range.start;
328
+ let end = kw.range.end;
329
+ // Expect group id IDENT
330
+ if (this.check("IDENT")) {
331
+ const idTok = this.advance();
332
+ const id = idTok.lexeme;
333
+ let type;
334
+ let label;
335
+ let labelRange;
336
+ let headerEnd = idTok.range.end;
337
+ // Optional type [IDENT]
338
+ if (this.check("LBRACKET")) {
339
+ const lb = this.advance();
340
+ if (this.check("IDENT")) {
341
+ const typeTok = this.advance();
342
+ type = typeTok.lexeme;
343
+ if (this.check("RBRACKET")) {
344
+ const rb = this.advance();
345
+ headerEnd = rb.range.end;
346
+ }
347
+ else {
348
+ this.diagnostics.push(diag("error", "E006", `Missing closing ']' for group type after '${typeTok.lexeme}'`, { start: lb.range.start, end: typeTok.range.end }));
349
+ headerEnd = typeTok.range.end;
350
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("LBRACE") && !this.check("STRING")) {
351
+ const nxt = this.peek();
352
+ // Only report if not terminator
353
+ if (nxt.type !== "NEWLINE" && nxt.type !== "COMMENT" && nxt.type !== "EOF" && nxt.type !== "LBRACE" && nxt.type !== "STRING") {
354
+ this.diagnostics.push(diag("error", this.codeForUnexpected(nxt), `Unexpected token '${nxt.lexeme}' after group type`, nxt.range));
355
+ this.synchronize();
356
+ }
357
+ }
358
+ }
359
+ }
360
+ else if (this.check("UNKNOWN")) {
361
+ const bad = this.advance();
362
+ const isDigitStart = /^[0-9]/.test(bad.lexeme);
363
+ this.diagnostics.push(diag("error", isDigitStart ? "E002" : "E008", `Invalid group type '${bad.lexeme}'`, bad.range));
364
+ headerEnd = bad.range.end;
365
+ if (this.check("RBRACKET")) {
366
+ const rb = this.advance();
367
+ headerEnd = rb.range.end;
368
+ }
369
+ else {
370
+ this.diagnostics.push(diag("error", "E006", `Missing closing ']' after invalid group type`, { start: lb.range.start, end: bad.range.end }));
371
+ }
372
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("LBRACE") && !this.check("STRING")) {
373
+ const nxt = this.peek();
374
+ this.diagnostics.push(diag("error", this.codeForUnexpected(nxt), `Unexpected token '${nxt.lexeme}' after group type`, nxt.range));
375
+ this.synchronize();
376
+ }
377
+ }
378
+ else if (this.check("RBRACKET")) {
379
+ const rb = this.advance();
380
+ this.diagnostics.push(diag("error", "E008", `Empty group type: expected identifier inside brackets`, { start: lb.range.start, end: rb.range.end }));
381
+ headerEnd = rb.range.end;
382
+ }
383
+ else if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("LBRACE") || this.check("STRING")) {
384
+ this.diagnostics.push(diag("error", "E006", `Incomplete group declaration: expected type identifier and ']' after '['`, lb.range));
385
+ headerEnd = lb.range.end;
386
+ }
387
+ else {
388
+ const tok = this.advance();
389
+ this.diagnostics.push(diag("error", this.codeForUnexpected(tok), `Unexpected token '${tok.lexeme}' inside group type brackets`, tok.range));
390
+ if (this.check("RBRACKET")) {
391
+ const rb = this.advance();
392
+ headerEnd = rb.range.end;
393
+ }
394
+ else {
395
+ this.synchronize();
396
+ headerEnd = tok.range.end;
397
+ // Create group with whatever we have and return early without expecting brace?
398
+ // But we need to attempt to create group
399
+ }
400
+ }
401
+ }
402
+ // Optional label STRING
403
+ if (this.check("STRING")) {
404
+ const labelTok = this.advance();
405
+ label = labelTok.lexeme;
406
+ labelRange = labelTok.range;
407
+ headerEnd = labelTok.range.end;
408
+ end = headerEnd;
409
+ }
410
+ else {
411
+ end = headerEnd;
412
+ }
413
+ // Expect LBRACE
414
+ let hasBrace = false;
415
+ let braceRange;
416
+ if (this.check("LBRACE")) {
417
+ const lb = this.advance();
418
+ braceRange = lb.range;
419
+ end = lb.range.end;
420
+ hasBrace = true;
421
+ }
422
+ else {
423
+ this.diagnostics.push(diag("error", "E006", `Missing opening '{' for group '${id}'`, { start: start, end: headerEnd }));
424
+ // Alternative E012 also
425
+ // Do not push to stack? Should still create group but treat as if brace missing, so we still push but warn
426
+ // For recovery, if missing brace, we won't push to stack to avoid swallowing rest of file into group.
427
+ // But spec allows groups without brace as error; we will still create group but not push
428
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
429
+ // If next token is not newline, maybe we should synchronize?
430
+ // For now just push diagnostic and synchronize
431
+ // Don't create group with missing brace as container
432
+ }
433
+ // create group without brace - not pushed to stack, just top-level placeholder? Better still push but require closing?
434
+ // Decide: if brace missing, create group but not push; errors will be reported later as unclosed.
435
+ // Let's create group and NOT push to avoid swallowing file
436
+ const grp = {
437
+ id,
438
+ label,
439
+ type,
440
+ range: { start, end: headerEnd },
441
+ nodeIds: [],
442
+ groups: [],
443
+ metadata: {},
444
+ annotations: [],
445
+ parentId: this.currentGroup()?.id,
446
+ };
447
+ // Validate id already? Duplicate check in validator
448
+ const parent = this.currentGroup();
449
+ if (parent)
450
+ parent.groups.push(grp);
451
+ else
452
+ this.groups.push(grp);
453
+ // Check extra after header without brace
454
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
455
+ const extra = this.peek();
456
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after group header`, extra.range));
457
+ this.synchronize();
458
+ }
459
+ return;
460
+ }
461
+ // Create group object
462
+ const grp = {
463
+ id,
464
+ label,
465
+ type,
466
+ range: { start, end },
467
+ nodeIds: [],
468
+ groups: [],
469
+ metadata: {},
470
+ annotations: [],
471
+ parentId: this.currentGroup()?.id,
472
+ };
473
+ // Add to parent or top-level
474
+ const parent = this.currentGroup();
475
+ if (parent)
476
+ parent.groups.push(grp);
477
+ else
478
+ this.groups.push(grp);
479
+ // Push onto stack
480
+ this.groupStack.push(grp);
481
+ // Check for extra tokens before newline after brace (should be none except comment)
482
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
483
+ const extra = this.peek();
484
+ // If next token is not at line end but could be start of body on same line? Our spec requires newline after {
485
+ // But allow body on same line? For simplicity, allow statements after brace without newline? Better require newline but we can just allow and continue
486
+ // If extra is IDENT etc that could be body, we should not error if brace is followed immediately by content without newline (unlikely but possible: "group X { API }")
487
+ // To support same-line body, we should not error if extra can start a statement inside group.
488
+ // So we allow if extra is IDENT/GROUP_KW/META_KW/NOTE_KW/LINK_KW/DIRECTION_KW etc., don't flag.
489
+ // Only flag if extra is unexpected like UNKNOWN
490
+ const starterTypes = ["IDENT", "GROUP_KW", "META_KW", "NOTE_KW", "LINK_KW", "DIRECTION_KW", "RBRACE"];
491
+ if (!starterTypes.includes(extra.type)) {
492
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after '{'`, extra.range));
493
+ this.synchronize();
494
+ }
495
+ // else allow body to be parsed next loop iteration (even without newline)
496
+ }
497
+ return;
498
+ }
499
+ else if (this.check("UNKNOWN")) {
500
+ const bad = this.advance();
501
+ const isDigitStart = /^[0-9]/.test(bad.lexeme);
502
+ this.diagnostics.push(diag("error", isDigitStart ? "E002" : "E007", `Invalid identifier '${bad.lexeme}' for group id`, bad.range));
503
+ this.synchronize();
504
+ return;
505
+ }
506
+ else if (this.check("LBRACE")) {
507
+ // Missing id, but has brace: `group {`
508
+ this.diagnostics.push(diag("error", "E006", `Missing group identifier after 'group'`, kw.range));
509
+ // Consume brace and create placeholder? But for recovery, just consume brace and continue without creating group
510
+ this.advance(); // consume {
511
+ // Create anonymous group? skip
512
+ this.diagnostics.push(diag("error", "E005", `Group without identifier ignored`, kw.range));
513
+ return;
514
+ }
515
+ else if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
516
+ this.diagnostics.push(diag("error", "E006", `Missing group identifier after 'group'`, kw.range));
517
+ return;
518
+ }
519
+ else {
520
+ const tok = this.advance();
521
+ this.diagnostics.push(diag("error", this.codeForUnexpected(tok), `Unexpected token '${tok.lexeme}' after 'group'`, tok.range));
522
+ this.synchronize();
523
+ return;
524
+ }
525
+ }
526
+ parseMetadataStmt() {
527
+ const kw = this.advance(); // META_KW
528
+ let start = kw.range.start;
529
+ let end = kw.range.end;
530
+ // Expect IDENT key
531
+ if (!this.check("IDENT")) {
532
+ if (this.check("UNKNOWN")) {
533
+ const bad = this.advance();
534
+ this.diagnostics.push(diag("error", "E002", `Invalid identifier '${bad.lexeme}' for metadata key`, bad.range));
535
+ this.synchronize();
536
+ return;
537
+ }
538
+ this.diagnostics.push(diag("error", "E006", `Missing metadata key after 'meta'`, kw.range));
539
+ this.synchronize();
540
+ return;
541
+ }
542
+ const keyTok = this.advance();
543
+ const key = keyTok.lexeme;
544
+ end = keyTok.range.end;
545
+ // Expect EQUALS
546
+ if (!this.check("EQUALS")) {
547
+ this.diagnostics.push(diag("error", "E006", `Missing '=' after metadata key '${key}'`, { start: kw.range.start, end: keyTok.range.end }));
548
+ this.synchronize();
549
+ return;
550
+ }
551
+ const eq = this.advance();
552
+ end = eq.range.end;
553
+ // Expect STRING value
554
+ if (!this.check("STRING")) {
555
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
556
+ this.diagnostics.push(diag("error", "E006", `Missing value for metadata key '${key}': expected quoted string`, eq.range));
557
+ return;
558
+ }
559
+ const tok = this.advance();
560
+ // If UNKNOWN that is unterminated string, already flagged as UNKNOWN starting with "
561
+ if (tok.lexeme.startsWith('"')) {
562
+ this.diagnostics.push(diag("error", "E006", `Unterminated string for metadata key '${key}'`, tok.range));
563
+ }
564
+ else {
565
+ this.diagnostics.push(diag("error", "E013", `Invalid metadata value for '${key}': expected quoted string, got '${tok.lexeme}'`, tok.range));
566
+ }
567
+ this.synchronize();
568
+ return;
569
+ }
570
+ const valTok = this.advance();
571
+ const value = valTok.lexeme;
572
+ end = valTok.range.end;
573
+ // Check extra tokens
574
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
575
+ const extra = this.peek();
576
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after metadata value`, extra.range));
577
+ this.synchronize();
578
+ }
579
+ const metaRange = { start, end };
580
+ const cur = this.currentGroup();
581
+ if (cur) {
582
+ // check duplicate within group
583
+ if (cur.metadata.hasOwnProperty(key)) {
584
+ // validator will flag? But we emit warning here as E013?
585
+ // For now allow override, but could diag
586
+ }
587
+ cur.metadata[key] = value;
588
+ }
589
+ else {
590
+ if (this.metadata.hasOwnProperty(key)) {
591
+ // duplicate key at diagram level - validator will handle? But emit E013?
592
+ }
593
+ this.metadata[key] = value;
594
+ this.metadataRanges.set(key, metaRange);
595
+ }
596
+ }
597
+ parseAnnotationStmt() {
598
+ const kw = this.advance(); // NOTE_KW
599
+ let start = kw.range.start;
600
+ let end = kw.range.end;
601
+ let target;
602
+ let text;
603
+ let textRange;
604
+ // Two forms: note STRING or note IDENT STRING
605
+ if (this.check("STRING")) {
606
+ const t = this.advance();
607
+ text = t.lexeme;
608
+ textRange = t.range;
609
+ end = t.range.end;
610
+ }
611
+ else if (this.check("IDENT")) {
612
+ const idTok = this.advance();
613
+ target = idTok.lexeme;
614
+ end = idTok.range.end;
615
+ if (this.check("STRING")) {
616
+ const t = this.advance();
617
+ text = t.lexeme;
618
+ textRange = t.range;
619
+ end = t.range.end;
620
+ }
621
+ else {
622
+ // Missing text
623
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
624
+ this.diagnostics.push(diag("error", "E006", `Missing annotation text after target '${target}': expected quoted string`, idTok.range));
625
+ return;
626
+ }
627
+ const tok = this.advance();
628
+ if (tok.lexeme.startsWith('"')) {
629
+ this.diagnostics.push(diag("error", "E006", `Unterminated string for annotation`, tok.range));
630
+ }
631
+ else {
632
+ this.diagnostics.push(diag("error", "E014", `Invalid annotation text: expected quoted string, got '${tok.lexeme}'`, tok.range));
633
+ }
634
+ this.synchronize();
635
+ return;
636
+ }
637
+ }
638
+ else {
639
+ // No IDENT nor STRING
640
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
641
+ this.diagnostics.push(diag("error", "E006", `Incomplete note statement: expected quoted string or target identifier`, kw.range));
642
+ return;
643
+ }
644
+ if (this.check("UNKNOWN")) {
645
+ const bad = this.advance();
646
+ if (bad.lexeme.startsWith('"')) {
647
+ this.diagnostics.push(diag("error", "E006", `Unterminated string for annotation`, bad.range));
648
+ }
649
+ else {
650
+ const isDigit = /^[0-9]/.test(bad.lexeme);
651
+ this.diagnostics.push(diag("error", isDigit ? "E002" : "E007", `Invalid identifier '${bad.lexeme}' for annotation target`, bad.range));
652
+ }
653
+ this.synchronize();
654
+ return;
655
+ }
656
+ const tok = this.advance();
657
+ this.diagnostics.push(diag("error", "E005", `Unexpected token '${tok.lexeme}' after 'note'`, tok.range));
658
+ this.synchronize();
659
+ return;
660
+ }
661
+ // Validate text non-empty? Empty string "" would be allowed? Spec says annotation text should be non-empty; we treat empty as E014
662
+ if (text !== undefined && text.trim().length === 0) {
663
+ this.diagnostics.push(diag("error", "E014", `Empty annotation text`, textRange));
664
+ // Still store? Allow but flagged
665
+ }
666
+ // Check extra tokens
667
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
668
+ const extra = this.peek();
669
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after annotation`, extra.range));
670
+ this.synchronize();
671
+ }
672
+ const annRange = { start, end };
673
+ const ann = { target, text: text, range: annRange };
674
+ this.annotations.push(ann);
675
+ const cur = this.currentGroup();
676
+ if (cur)
677
+ cur.annotations.push(ann);
678
+ }
679
+ parseLinkStmt() {
680
+ const kw = this.advance(); // LINK_KW
681
+ let start = kw.range.start;
682
+ let end = kw.range.end;
683
+ // Expect IDENT target
684
+ if (!this.check("IDENT")) {
685
+ if (this.check("UNKNOWN")) {
686
+ const bad = this.advance();
687
+ this.diagnostics.push(diag("error", "E002", `Invalid identifier '${bad.lexeme}' for link target`, bad.range));
688
+ this.synchronize();
689
+ return;
690
+ }
691
+ this.diagnostics.push(diag("error", "E006", `Missing link target after 'link': expected identifier`, kw.range));
692
+ this.synchronize();
693
+ return;
694
+ }
695
+ const targetTok = this.advance();
696
+ const target = targetTok.lexeme;
697
+ end = targetTok.range.end;
698
+ // Expect STRING url
699
+ if (!this.check("STRING")) {
700
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
701
+ this.diagnostics.push(diag("error", "E006", `Missing URL for link target '${target}': expected quoted string`, targetTok.range));
702
+ return;
703
+ }
704
+ const tok = this.advance();
705
+ if (tok.lexeme.startsWith('"')) {
706
+ this.diagnostics.push(diag("error", "E006", `Unterminated string for link URL`, tok.range));
707
+ }
708
+ else {
709
+ this.diagnostics.push(diag("error", "E014", `Invalid link URL: expected quoted string, got '${tok.lexeme}'`, tok.range));
710
+ }
711
+ this.synchronize();
712
+ return;
713
+ }
714
+ const urlTok = this.advance();
715
+ const url = urlTok.lexeme;
716
+ end = urlTok.range.end;
717
+ if (url.trim().length === 0) {
718
+ this.diagnostics.push(diag("error", "E014", `Empty link URL for target '${target}'`, urlTok.range));
719
+ }
720
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
721
+ const extra = this.peek();
722
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after link`, extra.range));
723
+ this.synchronize();
724
+ }
725
+ const range = { start, end };
726
+ const link = { target, url, range };
727
+ // Check duplicate
728
+ if (this.linkMap.has(target)) {
729
+ // Duplicate link for same target - validator will handle or we diag
730
+ this.diagnostics.push(diag("error", "E014", `Duplicate link for target '${target}'`, range));
731
+ }
732
+ else {
733
+ this.linkMap.set(target, link);
734
+ this.links.push(link);
735
+ // If target is a group id, also set group.link
736
+ const grp = this.findGroupById(target);
737
+ if (grp)
738
+ grp.link = url;
739
+ }
740
+ }
741
+ findGroupById(id) {
742
+ // search recursively in groups
743
+ const search = (arr) => {
744
+ for (const g of arr) {
745
+ if (g.id === id)
746
+ return g;
747
+ const found = search(g.groups);
748
+ if (found)
749
+ return found;
750
+ }
751
+ return undefined;
752
+ };
753
+ return search(this.groups);
754
+ }
755
+ parseNodeStmt() {
756
+ const idTok = this.advance(); // IDENT
757
+ let start = idTok.range.start;
758
+ let end = idTok.range.end;
759
+ let type;
760
+ let label;
761
+ let labelRange;
762
+ if (this.check("LBRACKET")) {
763
+ const lb = this.advance();
764
+ if (this.check("IDENT")) {
765
+ const typeTok = this.advance();
766
+ type = typeTok.lexeme;
767
+ if (this.check("RBRACKET")) {
768
+ const rb = this.advance();
769
+ end = rb.range.end;
770
+ }
771
+ else {
772
+ this.diagnostics.push(diag("error", "E006", `Missing closing ']' for node type after '${typeTok.lexeme}'`, { start: lb.range.start, end: typeTok.range.end }));
773
+ end = typeTok.range.end;
774
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE") && !this.check("STRING")) {
775
+ const nxt = this.peek();
776
+ if (nxt.type !== "NEWLINE" && nxt.type !== "COMMENT" && nxt.type !== "EOF" && nxt.type !== "RBRACE" && nxt.type !== "STRING") {
777
+ this.diagnostics.push(diag("error", this.codeForUnexpected(nxt), `Unexpected token '${nxt.lexeme}' after node type`, nxt.range));
778
+ this.synchronize();
779
+ }
780
+ }
781
+ }
782
+ }
783
+ else if (this.check("UNKNOWN")) {
784
+ const bad = this.advance();
785
+ const isDigitStart = /^[0-9]/.test(bad.lexeme);
786
+ if (isDigitStart) {
787
+ this.diagnostics.push(diag("error", "E002", `Invalid identifier '${bad.lexeme}' for node type: must start with a letter or underscore`, bad.range));
788
+ }
789
+ else {
790
+ this.diagnostics.push(diag("error", "E008", `Invalid node type '${bad.lexeme}'`, bad.range));
791
+ }
792
+ end = bad.range.end;
793
+ if (this.check("RBRACKET")) {
794
+ const rb = this.advance();
795
+ end = rb.range.end;
796
+ }
797
+ else {
798
+ this.diagnostics.push(diag("error", "E006", `Missing closing ']' after invalid node type`, { start: lb.range.start, end: bad.range.end }));
799
+ }
800
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE") && !this.check("STRING")) {
801
+ const nxt = this.peek();
802
+ this.diagnostics.push(diag("error", this.codeForUnexpected(nxt), `Unexpected token '${nxt.lexeme}' after node type`, nxt.range));
803
+ this.synchronize();
804
+ }
805
+ }
806
+ else if (this.check("RBRACKET")) {
807
+ const rb = this.advance();
808
+ this.diagnostics.push(diag("error", "E008", `Empty node type: expected identifier inside brackets`, { start: lb.range.start, end: rb.range.end }));
809
+ end = rb.range.end;
810
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE") && !this.check("STRING")) {
811
+ const nxt = this.peek();
812
+ this.diagnostics.push(diag("error", this.codeForUnexpected(nxt), `Unexpected token '${nxt.lexeme}' after node declaration`, nxt.range));
813
+ this.synchronize();
814
+ }
815
+ }
816
+ else if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE") || this.check("STRING")) {
817
+ // STRING could be label after missing bracket close? e.g., Node [ "label"
818
+ // But treat as missing bracket before label
819
+ this.diagnostics.push(diag("error", "E006", `Incomplete node declaration: expected type identifier and ']' after '['`, lb.range));
820
+ end = lb.range.end;
821
+ // Do not consume string yet; fall through to label handling
822
+ }
823
+ else {
824
+ const tok = this.advance();
825
+ this.diagnostics.push(diag("error", this.codeForUnexpected(tok), `Unexpected token '${tok.lexeme}' inside node type brackets`, tok.range));
826
+ if (this.check("RBRACKET")) {
827
+ const rb = this.advance();
828
+ end = rb.range.end;
829
+ }
830
+ else {
831
+ this.synchronize();
832
+ const nodeRange = { start, end };
833
+ this.pushNode({ id: idTok.lexeme, type, label, range: nodeRange });
834
+ return;
835
+ }
836
+ }
837
+ }
838
+ // Optional display label STRING
839
+ if (this.check("STRING")) {
840
+ const labelTok = this.advance();
841
+ label = labelTok.lexeme;
842
+ labelRange = labelTok.range;
843
+ end = labelTok.range.end;
844
+ // Validate label non-empty already: empty string "" would be lexeme "" (empty after trimming?), we could flag E010-like
845
+ if (label.trim().length === 0) {
846
+ this.diagnostics.push(diag("error", "E010", `Empty display label for node '${idTok.lexeme}'`, labelTok.range));
847
+ }
848
+ }
849
+ // After node (and optional label), check for extra tokens before line end
850
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
851
+ const extra = this.peek();
852
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after node declaration`, extra.range));
853
+ this.synchronize();
854
+ }
855
+ const nodeRange = { start, end };
856
+ this.pushNode({ id: idTok.lexeme, type, label, range: nodeRange });
857
+ }
858
+ pushNode(node) {
859
+ this.explicitNodes.push(node);
860
+ const cur = this.currentGroup();
861
+ if (cur) {
862
+ cur.nodeIds.push(node.id);
863
+ }
864
+ }
865
+ parseEdgeStmt() {
866
+ const sourceTok = this.advance(); // IDENT
867
+ // Expect ARROW or DASHDASH
868
+ let kind = "directed";
869
+ let opTok = null;
870
+ if (this.check("ARROW")) {
871
+ opTok = this.advance();
872
+ kind = "directed";
873
+ }
874
+ else if (this.check("DASHDASH")) {
875
+ opTok = this.advance();
876
+ kind = "undirected";
877
+ }
878
+ else {
879
+ this.diagnostics.push(diag("error", "E005", `Missing '->' or '--' after source identifier '${sourceTok.lexeme}'`, sourceTok.range));
880
+ this.synchronize();
881
+ return;
882
+ }
883
+ // Expect target IDENT
884
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
885
+ this.diagnostics.push(diag("error", "E009", `Missing edge target after '${opTok.lexeme}'`, opTok.range));
886
+ this.diagnostics.push(diag("error", "E006", `Incomplete edge: expected target identifier after '${opTok.lexeme}'`, { start: sourceTok.range.start, end: opTok.range.end }));
887
+ return;
888
+ }
889
+ if (this.check("UNKNOWN")) {
890
+ const bad = this.advance();
891
+ const isDigitStart = /^[0-9]/.test(bad.lexeme);
892
+ this.diagnostics.push(diag("error", isDigitStart ? "E002" : "E007", isDigitStart
893
+ ? `Invalid identifier '${bad.lexeme}' for edge target: must start with a letter or underscore`
894
+ : `Invalid character '${bad.lexeme}' for edge target`, bad.range));
895
+ this.synchronize();
896
+ return;
897
+ }
898
+ if (!this.check("IDENT")) {
899
+ const tok = this.advance();
900
+ this.diagnostics.push(diag("error", "E005", `Unexpected token '${tok.lexeme}' after '${opTok.lexeme}'; expected target identifier`, tok.range));
901
+ this.synchronize();
902
+ return;
903
+ }
904
+ const targetTok = this.advance(); // IDENT
905
+ let edgeEnd = targetTok.range.end;
906
+ let label;
907
+ let labelRange;
908
+ if (this.check("COLON")) {
909
+ const colon = this.advance();
910
+ if (this.check("NEWLINE") || this.check("COMMENT") || this.check("EOF") || this.check("RBRACE")) {
911
+ this.diagnostics.push(diag("error", "E010", `Empty edge label after ':'`, colon.range));
912
+ edgeEnd = colon.range.end;
913
+ }
914
+ else {
915
+ // If next token is STRING, treat that as label (decoded) and consume single STRING
916
+ if (this.check("STRING")) {
917
+ const lblTok = this.advance();
918
+ label = lblTok.lexeme;
919
+ labelRange = lblTok.range;
920
+ edgeEnd = lblTok.range.end;
921
+ if (label.trim().length === 0) {
922
+ this.diagnostics.push(diag("error", "E010", `Empty edge label after ':'`, colon.range));
923
+ label = undefined;
924
+ labelRange = undefined;
925
+ }
926
+ // Check extra after string? Edge label after colon currently expects single string or raw; if after STRING there are more tokens before newline, that's extra? But original spec allowed any chars after colon until newline as label, so if we used STRING, we shouldn't allow extra.
927
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
928
+ // If there are extra tokens after STRING label, treat them as unexpected? But original raw slice would have captured them as part of label.
929
+ // For consistency, we will capture remaining tokens as part of label raw if not just STRING? But if we consumed STRING as label, we should allow trailing? For now, treat extra as error.
930
+ // However, to preserve original behavior where label after colon includes everything, we could instead reconstruct raw if there are extra tokens.
931
+ // Decide: if after STRING there are more tokens, combine: label is STRING plus raw remainder?
932
+ // Simpler: if extra tokens exist, push error and synchronize
933
+ const extra = this.peek();
934
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after edge label`, extra.range));
935
+ this.synchronize();
936
+ }
937
+ }
938
+ else {
939
+ const firstOffset = colon.range.end.offset;
940
+ const labelTokens = [];
941
+ let lastTok = null;
942
+ let firstTok = null;
943
+ while (!this.isAtEnd() &&
944
+ !this.check("NEWLINE") &&
945
+ !this.check("COMMENT") &&
946
+ !this.check("EOF") &&
947
+ !this.check("RBRACE")) {
948
+ const t = this.advance();
949
+ labelTokens.push(t);
950
+ lastTok = t;
951
+ if (!firstTok)
952
+ firstTok = t;
953
+ }
954
+ if (labelTokens.length === 0) {
955
+ this.diagnostics.push(diag("error", "E010", `Empty edge label after ':'`, colon.range));
956
+ edgeEnd = colon.range.end;
957
+ }
958
+ else {
959
+ // Handle single STRING token case already above, but if raw includes quotes? Already handled.
960
+ // Reconstruct raw slice
961
+ const endOffset = lastTok.range.end.offset;
962
+ const rawSlice = this.source.slice(firstOffset, endOffset);
963
+ const trimmed = rawSlice.trim();
964
+ if (trimmed.length === 0) {
965
+ this.diagnostics.push(diag("error", "E010", `Empty edge label after ':'`, colon.range));
966
+ }
967
+ else {
968
+ // If trimmed is quoted string, unwrap? Check if labelTokens is single STRING already handled, but rawSlice includes quotes - we can unwrap if needed
969
+ // For uniform handling, if labelTokens length==1 && labelTokens[0].type==="STRING", use decoded lexeme. But we already handled that branch.
970
+ // Here we are in non-STRING branch, so rawSlice may contain quoted? We'll keep as trimmed raw (preserve quotes if any) or strip outer quotes if detected
971
+ // If the raw trimmed starts with " and ends with ", strip? But we would have tokenized string as STRING, not raw, so not here.
972
+ // So just keep trimmed.
973
+ label = trimmed;
974
+ labelRange = {
975
+ start: firstTok.range.start,
976
+ end: lastTok.range.end,
977
+ };
978
+ edgeEnd = lastTok.range.end;
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+ if (!this.check("NEWLINE") && !this.check("COMMENT") && !this.check("EOF") && !this.check("RBRACE")) {
985
+ const extra = this.peek();
986
+ this.diagnostics.push(diag("error", this.codeForUnexpected(extra), `Unexpected token '${extra.lexeme}' after edge`, extra.range));
987
+ this.synchronize();
988
+ }
989
+ const edgeRange = {
990
+ start: sourceTok.range.start,
991
+ end: edgeEnd,
992
+ };
993
+ this.edges.push({
994
+ source: sourceTok.lexeme,
995
+ target: targetTok.lexeme,
996
+ label,
997
+ kind,
998
+ range: edgeRange,
999
+ sourceRange: sourceTok.range,
1000
+ targetRange: targetTok.range,
1001
+ labelRange,
1002
+ });
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Convenience parse function — raw parse without semantic validation
1007
+ * For full validation use `parseFloe` from index.
1008
+ */
1009
+ export function parse(source) {
1010
+ return new Parser(source).parse();
1011
+ }
1012
+ /**
1013
+ * Raw parse alias for validator integration
1014
+ */
1015
+ export function parseRaw(source) {
1016
+ return new Parser(source).parse();
1017
+ }
1018
+ //# sourceMappingURL=parser.js.map