@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.
- package/LICENSE +21 -0
- package/dist/src/cli/check.d.ts +14 -0
- package/dist/src/cli/check.d.ts.map +1 -0
- package/dist/src/cli/check.js +88 -0
- package/dist/src/cli/check.js.map +1 -0
- package/dist/src/cli/format.d.ts +9 -0
- package/dist/src/cli/format.d.ts.map +1 -0
- package/dist/src/cli/format.js +114 -0
- package/dist/src/cli/format.js.map +1 -0
- package/dist/src/cli/main.d.ts +19 -0
- package/dist/src/cli/main.d.ts.map +1 -0
- package/dist/src/cli/main.js +207 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/render.d.ts +7 -0
- package/dist/src/cli/render.d.ts.map +1 -0
- package/dist/src/cli/render.js +90 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/diagnostics.d.ts +32 -0
- package/dist/src/diagnostics.d.ts.map +1 -0
- package/dist/src/diagnostics.js +4 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +64 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/language/completion.d.ts +6 -0
- package/dist/src/language/completion.d.ts.map +1 -0
- package/dist/src/language/completion.js +363 -0
- package/dist/src/language/completion.js.map +1 -0
- package/dist/src/language/definitions.d.ts +9 -0
- package/dist/src/language/definitions.d.ts.map +1 -0
- package/dist/src/language/definitions.js +70 -0
- package/dist/src/language/definitions.js.map +1 -0
- package/dist/src/language/diagnosticsService.d.ts +14 -0
- package/dist/src/language/diagnosticsService.d.ts.map +1 -0
- package/dist/src/language/diagnosticsService.js +37 -0
- package/dist/src/language/diagnosticsService.js.map +1 -0
- package/dist/src/language/folding.d.ts +8 -0
- package/dist/src/language/folding.d.ts.map +1 -0
- package/dist/src/language/folding.js +58 -0
- package/dist/src/language/folding.js.map +1 -0
- package/dist/src/language/formatting.d.ts +5 -0
- package/dist/src/language/formatting.d.ts.map +1 -0
- package/dist/src/language/formatting.js +274 -0
- package/dist/src/language/formatting.js.map +1 -0
- package/dist/src/language/highlighting.d.ts +9 -0
- package/dist/src/language/highlighting.d.ts.map +1 -0
- package/dist/src/language/highlighting.js +74 -0
- package/dist/src/language/highlighting.js.map +1 -0
- package/dist/src/language/hover.d.ts +6 -0
- package/dist/src/language/hover.d.ts.map +1 -0
- package/dist/src/language/hover.js +197 -0
- package/dist/src/language/hover.js.map +1 -0
- package/dist/src/language/indentation.d.ts +15 -0
- package/dist/src/language/indentation.d.ts.map +1 -0
- package/dist/src/language/indentation.js +97 -0
- package/dist/src/language/indentation.js.map +1 -0
- package/dist/src/language/index.d.ts +54 -0
- package/dist/src/language/index.d.ts.map +1 -0
- package/dist/src/language/index.js +85 -0
- package/dist/src/language/index.js.map +1 -0
- package/dist/src/language/references.d.ts +12 -0
- package/dist/src/language/references.d.ts.map +1 -0
- package/dist/src/language/references.js +35 -0
- package/dist/src/language/references.js.map +1 -0
- package/dist/src/language/rename.d.ts +18 -0
- package/dist/src/language/rename.d.ts.map +1 -0
- package/dist/src/language/rename.js +53 -0
- package/dist/src/language/rename.js.map +1 -0
- package/dist/src/language/semanticModel.d.ts +21 -0
- package/dist/src/language/semanticModel.d.ts.map +1 -0
- package/dist/src/language/semanticModel.js +43 -0
- package/dist/src/language/semanticModel.js.map +1 -0
- package/dist/src/language/symbols.d.ts +9 -0
- package/dist/src/language/symbols.d.ts.map +1 -0
- package/dist/src/language/symbols.js +160 -0
- package/dist/src/language/symbols.js.map +1 -0
- package/dist/src/language/types.d.ts +49 -0
- package/dist/src/language/types.d.ts.map +1 -0
- package/dist/src/language/types.js +2 -0
- package/dist/src/language/types.js.map +1 -0
- package/dist/src/language/utils.d.ts +24 -0
- package/dist/src/language/utils.d.ts.map +1 -0
- package/dist/src/language/utils.js +154 -0
- package/dist/src/language/utils.js.map +1 -0
- package/dist/src/layout/dagre.d.ts +13 -0
- package/dist/src/layout/dagre.d.ts.map +1 -0
- package/dist/src/layout/dagre.js +229 -0
- package/dist/src/layout/dagre.js.map +1 -0
- package/dist/src/layout/index.d.ts +15 -0
- package/dist/src/layout/index.d.ts.map +1 -0
- package/dist/src/layout/index.js +22 -0
- package/dist/src/layout/index.js.map +1 -0
- package/dist/src/layout/simple.d.ts +20 -0
- package/dist/src/layout/simple.d.ts.map +1 -0
- package/dist/src/layout/simple.js +674 -0
- package/dist/src/layout/simple.js.map +1 -0
- package/dist/src/layout/types.d.ts +65 -0
- package/dist/src/layout/types.d.ts.map +1 -0
- package/dist/src/layout/types.js +2 -0
- package/dist/src/layout/types.js.map +1 -0
- package/dist/src/lexer.d.ts +25 -0
- package/dist/src/lexer.d.ts.map +1 -0
- package/dist/src/lexer.js +280 -0
- package/dist/src/lexer.js.map +1 -0
- package/dist/src/lsp/documents.d.ts +24 -0
- package/dist/src/lsp/documents.d.ts.map +1 -0
- package/dist/src/lsp/documents.js +97 -0
- package/dist/src/lsp/documents.js.map +1 -0
- package/dist/src/lsp/server.d.ts +70 -0
- package/dist/src/lsp/server.d.ts.map +1 -0
- package/dist/src/lsp/server.js +623 -0
- package/dist/src/lsp/server.js.map +1 -0
- package/dist/src/lsp/utils.d.ts +47 -0
- package/dist/src/lsp/utils.d.ts.map +1 -0
- package/dist/src/lsp/utils.js +142 -0
- package/dist/src/lsp/utils.js.map +1 -0
- package/dist/src/parser.d.ts +106 -0
- package/dist/src/parser.d.ts.map +1 -0
- package/dist/src/parser.js +1018 -0
- package/dist/src/parser.js.map +1 -0
- package/dist/src/pipeline.d.ts +51 -0
- package/dist/src/pipeline.d.ts.map +1 -0
- package/dist/src/pipeline.js +65 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/range.d.ts +24 -0
- package/dist/src/range.d.ts.map +1 -0
- package/dist/src/range.js +20 -0
- package/dist/src/range.js.map +1 -0
- package/dist/src/render/index.d.ts +3 -0
- package/dist/src/render/index.d.ts.map +1 -0
- package/dist/src/render/index.js +3 -0
- package/dist/src/render/index.js.map +1 -0
- package/dist/src/render/shapes.d.ts +20 -0
- package/dist/src/render/shapes.d.ts.map +1 -0
- package/dist/src/render/shapes.js +110 -0
- package/dist/src/render/shapes.js.map +1 -0
- package/dist/src/render/svg.d.ts +19 -0
- package/dist/src/render/svg.d.ts.map +1 -0
- package/dist/src/render/svg.js +227 -0
- package/dist/src/render/svg.js.map +1 -0
- package/dist/src/security/url.d.ts +7 -0
- package/dist/src/security/url.d.ts.map +1 -0
- package/dist/src/security/url.js +27 -0
- package/dist/src/security/url.js.map +1 -0
- package/dist/src/types.d.ts +111 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +16 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/validator.d.ts +47 -0
- package/dist/src/validator.d.ts.map +1 -0
- package/dist/src/validator.js +235 -0
- package/dist/src/validator.js.map +1 -0
- 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
|