@kernlang/core 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen-core.d.ts +18 -0
- package/dist/codegen-core.js +34 -9
- package/dist/codegen-core.js.map +1 -1
- package/dist/coverage-gap.js +1 -1
- package/dist/coverage-gap.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts +8 -0
- package/dist/parser.js +405 -297
- package/dist/parser.js.map +1 -1
- package/dist/scanner.js +4 -4
- package/dist/scanner.js.map +1 -1
- package/package.json +1 -1
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,250 @@
|
|
|
1
1
|
let _parseWarnings = [];
|
|
2
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────
|
|
3
|
+
function isIdentStart(ch) {
|
|
4
|
+
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
|
|
5
|
+
}
|
|
6
|
+
function isIdentChar(ch) {
|
|
7
|
+
return isIdentStart(ch) || (ch >= '0' && ch <= '9') || ch === '-';
|
|
8
|
+
}
|
|
9
|
+
function isDigit(ch) {
|
|
10
|
+
return ch >= '0' && ch <= '9';
|
|
11
|
+
}
|
|
12
|
+
/** Character-by-character tokenizer for a single KERN line (after indent stripped). */
|
|
13
|
+
export function tokenizeLine(line) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < line.length) {
|
|
17
|
+
const ch = line[i];
|
|
18
|
+
// Whitespace
|
|
19
|
+
if (ch === ' ' || ch === '\t') {
|
|
20
|
+
const start = i;
|
|
21
|
+
while (i < line.length && (line[i] === ' ' || line[i] === '\t'))
|
|
22
|
+
i++;
|
|
23
|
+
tokens.push({ kind: 'whitespace', value: line.slice(start, i), pos: start });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Expression block {{ ... }}
|
|
27
|
+
if (ch === '{' && i + 1 < line.length && line[i + 1] === '{') {
|
|
28
|
+
const start = i;
|
|
29
|
+
i += 2;
|
|
30
|
+
let depth = 1;
|
|
31
|
+
while (i < line.length - 1 && depth > 0) {
|
|
32
|
+
if (line[i] === '{' && line[i + 1] === '{') {
|
|
33
|
+
depth++;
|
|
34
|
+
i += 2;
|
|
35
|
+
}
|
|
36
|
+
else if (line[i] === '}' && line[i + 1] === '}') {
|
|
37
|
+
depth--;
|
|
38
|
+
if (depth === 0)
|
|
39
|
+
break;
|
|
40
|
+
i += 2;
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
const inner = line.slice(start + 2, i).trim();
|
|
46
|
+
i += 2;
|
|
47
|
+
tokens.push({ kind: 'expr', value: inner, pos: start });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Style block { ... } — find matching } respecting quotes
|
|
51
|
+
if (ch === '{') {
|
|
52
|
+
const start = i;
|
|
53
|
+
let inQuote = false;
|
|
54
|
+
let j = i + 1;
|
|
55
|
+
while (j < line.length) {
|
|
56
|
+
if (line[j] === '"')
|
|
57
|
+
inQuote = !inQuote;
|
|
58
|
+
if (!inQuote && line[j] === '}') {
|
|
59
|
+
j++;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
j++;
|
|
63
|
+
}
|
|
64
|
+
tokens.push({ kind: 'style', value: line.slice(start + 1, j - 1), pos: start });
|
|
65
|
+
i = j;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Quoted string "..."
|
|
69
|
+
if (ch === '"') {
|
|
70
|
+
const start = i;
|
|
71
|
+
i++;
|
|
72
|
+
while (i < line.length && line[i] !== '"')
|
|
73
|
+
i++;
|
|
74
|
+
const inner = line.slice(start + 1, i);
|
|
75
|
+
i++;
|
|
76
|
+
tokens.push({ kind: 'quoted', value: inner, pos: start });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Theme ref $name
|
|
80
|
+
if (ch === '$' && i + 1 < line.length && isIdentStart(line[i + 1])) {
|
|
81
|
+
const start = i;
|
|
82
|
+
i++;
|
|
83
|
+
while (i < line.length && isIdentChar(line[i]))
|
|
84
|
+
i++;
|
|
85
|
+
tokens.push({ kind: 'themeRef', value: line.slice(start + 1, i), pos: start });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Equals
|
|
89
|
+
if (ch === '=') {
|
|
90
|
+
tokens.push({ kind: 'equals', value: '=', pos: i });
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Comma
|
|
95
|
+
if (ch === ',') {
|
|
96
|
+
tokens.push({ kind: 'comma', value: ',', pos: i });
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Slash-prefixed path: /something
|
|
101
|
+
if (ch === '/') {
|
|
102
|
+
const start = i;
|
|
103
|
+
while (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '{' && line[i] !== '$')
|
|
104
|
+
i++;
|
|
105
|
+
tokens.push({ kind: 'slash', value: line.slice(start, i), pos: start });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Number (pure digits)
|
|
109
|
+
if (isDigit(ch)) {
|
|
110
|
+
const start = i;
|
|
111
|
+
while (i < line.length && isDigit(line[i]))
|
|
112
|
+
i++;
|
|
113
|
+
tokens.push({ kind: 'number', value: line.slice(start, i), pos: start });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Identifier: [A-Za-z_][A-Za-z0-9_-]*
|
|
117
|
+
// Handles evolved: prefix (evolved:keyword → strips prefix, returns keyword)
|
|
118
|
+
if (isIdentStart(ch)) {
|
|
119
|
+
const start = i;
|
|
120
|
+
while (i < line.length && isIdentChar(line[i]))
|
|
121
|
+
i++;
|
|
122
|
+
if (line[i] === ':' && line.slice(start, i) === 'evolved' && i + 1 < line.length && isIdentStart(line[i + 1])) {
|
|
123
|
+
i++;
|
|
124
|
+
const nameStart = i;
|
|
125
|
+
while (i < line.length && isIdentChar(line[i]))
|
|
126
|
+
i++;
|
|
127
|
+
tokens.push({ kind: 'identifier', value: line.slice(nameStart, i), pos: start });
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
tokens.push({ kind: 'identifier', value: line.slice(start, i), pos: start });
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Unknown character
|
|
135
|
+
tokens.push({ kind: 'unknown', value: ch, pos: i });
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
return tokens;
|
|
139
|
+
}
|
|
140
|
+
// ── Token stream ─────────────────────────────────────────────────────────
|
|
141
|
+
// Opus: class-based cursor. Codex contribution: consumeAnyValue for evolved hints.
|
|
142
|
+
class TokenStream {
|
|
143
|
+
tokens;
|
|
144
|
+
idx = 0;
|
|
145
|
+
constructor(tokens) { this.tokens = tokens; }
|
|
146
|
+
peek() { return this.tokens[this.idx]; }
|
|
147
|
+
next() { return this.tokens[this.idx++]; }
|
|
148
|
+
done() { return this.idx >= this.tokens.length; }
|
|
149
|
+
position() { return this.idx; }
|
|
150
|
+
setPosition(pos) { this.idx = pos; }
|
|
151
|
+
skipWS() {
|
|
152
|
+
while (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'whitespace')
|
|
153
|
+
this.idx++;
|
|
154
|
+
}
|
|
155
|
+
/** Try to consume an identifier. Returns its value or null. */
|
|
156
|
+
tryIdent() {
|
|
157
|
+
if (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'identifier') {
|
|
158
|
+
return this.tokens[this.idx++].value;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** Try to consume a number token. Returns its value or null. */
|
|
163
|
+
tryNumber() {
|
|
164
|
+
if (this.idx < this.tokens.length && this.tokens[this.idx].kind === 'number') {
|
|
165
|
+
return this.tokens[this.idx++].value;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** Check if the next non-WS token is an identifier followed by '='. */
|
|
170
|
+
isKeyValue() {
|
|
171
|
+
let j = this.idx;
|
|
172
|
+
while (j < this.tokens.length && this.tokens[j].kind === 'whitespace')
|
|
173
|
+
j++;
|
|
174
|
+
if (j >= this.tokens.length || this.tokens[j].kind !== 'identifier')
|
|
175
|
+
return false;
|
|
176
|
+
return j + 1 < this.tokens.length && this.tokens[j + 1].kind === 'equals';
|
|
177
|
+
}
|
|
178
|
+
/** Check if any remaining token contains '='. */
|
|
179
|
+
hasEquals() {
|
|
180
|
+
for (let j = this.idx; j < this.tokens.length; j++) {
|
|
181
|
+
if (this.tokens[j].kind === 'equals')
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
/** Check if there are more non-whitespace tokens. */
|
|
187
|
+
hasMore() {
|
|
188
|
+
let j = this.idx;
|
|
189
|
+
while (j < this.tokens.length && this.tokens[j].kind === 'whitespace')
|
|
190
|
+
j++;
|
|
191
|
+
return j < this.tokens.length;
|
|
192
|
+
}
|
|
193
|
+
/** Get remaining raw text from current position (for fallback / params). */
|
|
194
|
+
remainingRaw(line) {
|
|
195
|
+
if (this.idx >= this.tokens.length)
|
|
196
|
+
return '';
|
|
197
|
+
const startPos = this.tokens[this.idx].pos;
|
|
198
|
+
this.idx = this.tokens.length;
|
|
199
|
+
return line.slice(startPos);
|
|
200
|
+
}
|
|
201
|
+
/** Consume any single non-whitespace token as a value (for evolved positional args). */
|
|
202
|
+
consumeAnyValue() {
|
|
203
|
+
this.skipWS();
|
|
204
|
+
const tok = this.peek();
|
|
205
|
+
if (!tok || tok.kind === 'whitespace')
|
|
206
|
+
return undefined;
|
|
207
|
+
return this.next();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ── Prop parsing (extracted from Codex's parsePropToken pattern) ──────────
|
|
211
|
+
/** Map a value token to its JS representation. */
|
|
212
|
+
function tokenValue(tok) {
|
|
213
|
+
if (tok.kind === 'expr')
|
|
214
|
+
return { __expr: true, code: tok.value };
|
|
215
|
+
if (tok.kind === 'quoted')
|
|
216
|
+
return tok.value;
|
|
217
|
+
return tok.value;
|
|
218
|
+
}
|
|
219
|
+
/** Try to parse a key=value prop from the stream. Returns true if consumed. */
|
|
220
|
+
function parseProp(s, props) {
|
|
221
|
+
if (!s.isKeyValue())
|
|
222
|
+
return false;
|
|
223
|
+
s.skipWS();
|
|
224
|
+
const key = s.next().value; // identifier
|
|
225
|
+
s.next(); // =
|
|
226
|
+
const valTok = s.peek();
|
|
227
|
+
if (!valTok || valTok.kind === 'whitespace') {
|
|
228
|
+
props[key] = '';
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// key={{expr}} or key="quoted"
|
|
232
|
+
if (valTok.kind === 'expr' || valTok.kind === 'quoted') {
|
|
233
|
+
props[key] = tokenValue(s.next());
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
// key=bareValue — collect tokens up to next WS/style/themeRef
|
|
237
|
+
let value = '';
|
|
238
|
+
while (!s.done()) {
|
|
239
|
+
const vt = s.peek();
|
|
240
|
+
if (vt.kind === 'whitespace' || vt.kind === 'style' || vt.kind === 'themeRef')
|
|
241
|
+
break;
|
|
242
|
+
value += vt.value;
|
|
243
|
+
s.next();
|
|
244
|
+
}
|
|
245
|
+
props[key] = value;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
2
248
|
const MULTILINE_BLOCK_TYPES = new Set(['logic', 'handler', 'cleanup', 'body']);
|
|
3
249
|
const _parserHints = new Map();
|
|
4
250
|
/** Register parser hints for an evolved node type. */
|
|
@@ -18,328 +264,198 @@ export function unregisterParserHints(keyword) {
|
|
|
18
264
|
}
|
|
19
265
|
/** Clear all parser hints (for test isolation). */
|
|
20
266
|
export function clearParserHints() {
|
|
21
|
-
// Remove evolved entries from MULTILINE_BLOCK_TYPES, keep core ones
|
|
22
267
|
for (const [keyword, hints] of _parserHints) {
|
|
23
268
|
if (hints.multilineBlock)
|
|
24
269
|
MULTILINE_BLOCK_TYPES.delete(keyword);
|
|
25
270
|
}
|
|
26
271
|
_parserHints.clear();
|
|
27
272
|
}
|
|
273
|
+
/** Consume a bare identifier into props if it's not a key=value pair. */
|
|
274
|
+
function consumeBareIdent(s, props, propName) {
|
|
275
|
+
s.skipWS();
|
|
276
|
+
if (s.isKeyValue())
|
|
277
|
+
return;
|
|
278
|
+
const id = s.tryIdent();
|
|
279
|
+
if (id)
|
|
280
|
+
props[propName] = id;
|
|
281
|
+
}
|
|
282
|
+
const KEYWORD_HANDLERS = new Map([
|
|
283
|
+
['theme', (s, props) => {
|
|
284
|
+
consumeBareIdent(s, props, 'name');
|
|
285
|
+
}],
|
|
286
|
+
['import', (s, props) => {
|
|
287
|
+
s.skipWS();
|
|
288
|
+
const pos = s.position();
|
|
289
|
+
const id = s.tryIdent();
|
|
290
|
+
if (id === 'default') {
|
|
291
|
+
if (!s.done() && s.peek()?.kind !== 'equals') {
|
|
292
|
+
props.default = true;
|
|
293
|
+
s.skipWS();
|
|
294
|
+
}
|
|
295
|
+
else if (s.peek()?.kind === 'equals') {
|
|
296
|
+
s.setPosition(pos);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
props.default = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (id) {
|
|
305
|
+
s.setPosition(pos);
|
|
306
|
+
}
|
|
307
|
+
if (!s.isKeyValue()) {
|
|
308
|
+
s.skipWS();
|
|
309
|
+
const name = s.tryIdent();
|
|
310
|
+
if (name)
|
|
311
|
+
props.name = name;
|
|
312
|
+
}
|
|
313
|
+
}],
|
|
314
|
+
['route', (s, props) => {
|
|
315
|
+
s.skipWS();
|
|
316
|
+
const pos = s.position();
|
|
317
|
+
const verb = s.tryIdent();
|
|
318
|
+
if (verb && /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/i.test(verb)) {
|
|
319
|
+
props.method = verb.toLowerCase();
|
|
320
|
+
s.skipWS();
|
|
321
|
+
const tok = s.peek();
|
|
322
|
+
if (tok && tok.kind === 'slash') {
|
|
323
|
+
props.path = tok.value;
|
|
324
|
+
s.next();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else if (verb) {
|
|
328
|
+
s.setPosition(pos);
|
|
329
|
+
}
|
|
330
|
+
}],
|
|
331
|
+
['params', (s, props, content) => {
|
|
332
|
+
s.skipWS();
|
|
333
|
+
const remaining = s.remainingRaw(content);
|
|
334
|
+
if (remaining.length > 0) {
|
|
335
|
+
const items = [];
|
|
336
|
+
const parts = remaining.split(',').map(p => p.trim()).filter(Boolean);
|
|
337
|
+
for (const part of parts) {
|
|
338
|
+
const m = part.match(/^([A-Za-z_]\w*):([A-Za-z_]\w*(?:\[\])?)(?:\s*=\s*(.+))?$/);
|
|
339
|
+
if (m) {
|
|
340
|
+
const item = { name: m[1], type: m[2] };
|
|
341
|
+
if (m[3] !== undefined)
|
|
342
|
+
item.default = m[3].trim();
|
|
343
|
+
items.push(item);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
props.items = items;
|
|
347
|
+
}
|
|
348
|
+
}],
|
|
349
|
+
['auth', (s, props) => { consumeBareIdent(s, props, 'mode'); }],
|
|
350
|
+
['validate', (s, props) => { consumeBareIdent(s, props, 'schema'); }],
|
|
351
|
+
['error', (s, props) => {
|
|
352
|
+
s.skipWS();
|
|
353
|
+
const num = s.tryNumber();
|
|
354
|
+
if (num) {
|
|
355
|
+
props.status = parseInt(num, 10);
|
|
356
|
+
s.skipWS();
|
|
357
|
+
const tok = s.peek();
|
|
358
|
+
if (tok && tok.kind === 'quoted') {
|
|
359
|
+
props.message = tok.value;
|
|
360
|
+
s.next();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}],
|
|
364
|
+
['derive', (s, props) => { consumeBareIdent(s, props, 'name'); }],
|
|
365
|
+
['guard', (s, props) => { consumeBareIdent(s, props, 'name'); }],
|
|
366
|
+
['effect', (s, props) => { consumeBareIdent(s, props, 'name'); }],
|
|
367
|
+
['strategy', (s, props) => { consumeBareIdent(s, props, 'name'); }],
|
|
368
|
+
['trigger', (s, props) => { consumeBareIdent(s, props, 'kind'); }],
|
|
369
|
+
['respond', (s, props) => {
|
|
370
|
+
s.skipWS();
|
|
371
|
+
const num = s.tryNumber();
|
|
372
|
+
if (num)
|
|
373
|
+
props.status = parseInt(num, 10);
|
|
374
|
+
}],
|
|
375
|
+
['middleware', (s, props, content) => {
|
|
376
|
+
s.skipWS();
|
|
377
|
+
if (!s.hasMore())
|
|
378
|
+
return;
|
|
379
|
+
if (s.hasEquals())
|
|
380
|
+
return;
|
|
381
|
+
const remaining = s.remainingRaw(content).trim();
|
|
382
|
+
if (remaining.length > 0) {
|
|
383
|
+
const names = remaining.split(',').map(n => n.trim()).filter(Boolean);
|
|
384
|
+
if (names.length > 1) {
|
|
385
|
+
props.names = names;
|
|
386
|
+
}
|
|
387
|
+
else if (names.length === 1) {
|
|
388
|
+
props.name = names[0];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}],
|
|
392
|
+
]);
|
|
393
|
+
// ── parseLine (token-based) ──────────────────────────────────────────────
|
|
28
394
|
function parseLine(raw, lineNum) {
|
|
29
395
|
if (raw.trim() === '')
|
|
30
396
|
return null;
|
|
31
397
|
const indent = raw.search(/\S/);
|
|
32
|
-
|
|
398
|
+
const content = raw.slice(indent);
|
|
33
399
|
const col = indent + 1;
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
400
|
+
const tokens = tokenizeLine(content);
|
|
401
|
+
const s = new TokenStream(tokens);
|
|
402
|
+
// First token must be an identifier (the node type)
|
|
403
|
+
const typeToken = s.tryIdent();
|
|
404
|
+
if (!typeToken)
|
|
37
405
|
return null;
|
|
38
|
-
const type =
|
|
39
|
-
rest = rest.slice(typeMatch[0].length);
|
|
406
|
+
const type = typeToken;
|
|
40
407
|
const props = {};
|
|
41
408
|
const styles = {};
|
|
42
409
|
const pseudoStyles = {};
|
|
43
410
|
const themeRefs = [];
|
|
44
411
|
// ── Evolved node parser hints (v4) ──────────────────────────────────
|
|
45
|
-
// Check if this type has parser hints from graduated evolved nodes.
|
|
46
|
-
// Must run BEFORE core special cases to allow evolved nodes to use
|
|
47
|
-
// positional args, bare words, etc.
|
|
48
412
|
const evolvedHints = _parserHints.get(type);
|
|
49
413
|
if (evolvedHints) {
|
|
50
|
-
// Positional args: "api-route GET /users" → props.method="GET", props.path="/users"
|
|
51
414
|
if (evolvedHints.positionalArgs) {
|
|
52
415
|
for (const argName of evolvedHints.positionalArgs) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
props[argName] = argMatch[1];
|
|
57
|
-
rest = rest.slice(argMatch[0].length);
|
|
58
|
-
}
|
|
416
|
+
const tok = s.consumeAnyValue();
|
|
417
|
+
if (tok)
|
|
418
|
+
props[argName] = tok.value;
|
|
59
419
|
}
|
|
60
420
|
}
|
|
61
|
-
// Bare word: "auth-guard admin" → props.name="admin"
|
|
62
421
|
if (evolvedHints.bareWord) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// Special: theme nodes have a bare name after the type: "theme bar {h:8}"
|
|
72
|
-
if (type === 'theme') {
|
|
73
|
-
rest = rest.replace(/^ +/, '');
|
|
74
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
|
|
75
|
-
if (nameMatch) {
|
|
76
|
-
props.name = nameMatch[1];
|
|
77
|
-
rest = rest.slice(nameMatch[0].length);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
// Special: import nodes support bare words for name and optional "default" flag
|
|
81
|
-
// Syntax: "import [default] <name> from=<path>"
|
|
82
|
-
if (type === 'import') {
|
|
83
|
-
rest = rest.replace(/^ +/, '');
|
|
84
|
-
// Check for "default" keyword
|
|
85
|
-
if (rest.startsWith('default')) {
|
|
86
|
-
const afterDefault = rest.slice(7);
|
|
87
|
-
if (afterDefault.length === 0 || afterDefault[0] === ' ') {
|
|
88
|
-
props.default = true;
|
|
89
|
-
rest = afterDefault.replace(/^ +/, '');
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
// Capture the import name (bare word before from=)
|
|
93
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
|
|
94
|
-
if (nameMatch && !rest.startsWith('from=')) {
|
|
95
|
-
props.name = nameMatch[1];
|
|
96
|
-
rest = rest.slice(nameMatch[0].length);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// Special: route v3 positional syntax — "route GET /api/users"
|
|
100
|
-
if (type === 'route') {
|
|
101
|
-
rest = rest.replace(/^ +/, '');
|
|
102
|
-
const verbMatch = rest.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i);
|
|
103
|
-
if (verbMatch) {
|
|
104
|
-
props.method = verbMatch[1].toLowerCase();
|
|
105
|
-
rest = rest.slice(verbMatch[0].length);
|
|
106
|
-
const pathMatch = rest.match(/^(\/\S*)/);
|
|
107
|
-
if (pathMatch) {
|
|
108
|
-
props.path = pathMatch[1];
|
|
109
|
-
rest = rest.slice(pathMatch[0].length);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
// Special: params — "params page:number = 1, limit:number = 20"
|
|
114
|
-
if (type === 'params') {
|
|
115
|
-
rest = rest.replace(/^ +/, '');
|
|
116
|
-
if (rest.length > 0) {
|
|
117
|
-
const items = [];
|
|
118
|
-
const parts = rest.split(',').map(s => s.trim()).filter(Boolean);
|
|
119
|
-
for (const part of parts) {
|
|
120
|
-
const m = part.match(/^([A-Za-z_]\w*):([A-Za-z_]\w*(?:\[\])?)(?:\s*=\s*(.+))?$/);
|
|
121
|
-
if (m) {
|
|
122
|
-
const item = { name: m[1], type: m[2] };
|
|
123
|
-
if (m[3] !== undefined)
|
|
124
|
-
item.default = m[3].trim();
|
|
125
|
-
items.push(item);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
props.items = items;
|
|
129
|
-
rest = '';
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// Special: auth — "auth required" / "auth optional" / "auth bearer"
|
|
133
|
-
if (type === 'auth') {
|
|
134
|
-
rest = rest.replace(/^ +/, '');
|
|
135
|
-
const modeMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
|
|
136
|
-
if (modeMatch) {
|
|
137
|
-
props.mode = modeMatch[1];
|
|
138
|
-
rest = rest.slice(modeMatch[0].length);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// Special: validate — "validate UserQuerySchema"
|
|
142
|
-
if (type === 'validate') {
|
|
143
|
-
rest = rest.replace(/^ +/, '');
|
|
144
|
-
const schemaMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
145
|
-
if (schemaMatch) {
|
|
146
|
-
props.schema = schemaMatch[1];
|
|
147
|
-
rest = rest.slice(schemaMatch[0].length);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Special: error with numeric status — "error 401 "Unauthorized""
|
|
151
|
-
if (type === 'error') {
|
|
152
|
-
rest = rest.replace(/^ +/, '');
|
|
153
|
-
const statusMatch = rest.match(/^(\d{3})/);
|
|
154
|
-
if (statusMatch) {
|
|
155
|
-
props.status = parseInt(statusMatch[1], 10);
|
|
156
|
-
rest = rest.slice(statusMatch[0].length).replace(/^ +/, '');
|
|
157
|
-
if (rest.startsWith('"')) {
|
|
158
|
-
const endQuote = rest.indexOf('"', 1);
|
|
159
|
-
if (endQuote > 0) {
|
|
160
|
-
props.message = rest.slice(1, endQuote);
|
|
161
|
-
rest = rest.slice(endQuote + 1);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Special: derive with bare name — "derive user expr={{...}}"
|
|
167
|
-
if (type === 'derive') {
|
|
168
|
-
rest = rest.replace(/^ +/, '');
|
|
169
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
170
|
-
// Only consume bare name if it's NOT a key=value pair (e.g., "derive name=foo" or "derive from=x")
|
|
171
|
-
if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
|
|
172
|
-
props.name = nameMatch[1];
|
|
173
|
-
rest = rest.slice(nameMatch[0].length);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
// Special: guard with bare name — "guard exists expr={{...}} else=404"
|
|
177
|
-
if (type === 'guard') {
|
|
178
|
-
rest = rest.replace(/^ +/, '');
|
|
179
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
180
|
-
if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
|
|
181
|
-
props.name = nameMatch[1];
|
|
182
|
-
rest = rest.slice(nameMatch[0].length);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// Special: effect with bare name — "effect fetchUsers"
|
|
186
|
-
if (type === 'effect') {
|
|
187
|
-
rest = rest.replace(/^ +/, '');
|
|
188
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
189
|
-
if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
|
|
190
|
-
props.name = nameMatch[1];
|
|
191
|
-
rest = rest.slice(nameMatch[0].length);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// Special: strategy with bare name — "strategy read-through"
|
|
195
|
-
if (type === 'strategy') {
|
|
196
|
-
rest = rest.replace(/^ +/, '');
|
|
197
|
-
const nameMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)/);
|
|
198
|
-
if (nameMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_-]*=/)) {
|
|
199
|
-
props.name = nameMatch[1];
|
|
200
|
-
rest = rest.slice(nameMatch[0].length);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// Special: trigger with bare type — "trigger db query=..."
|
|
204
|
-
if (type === 'trigger') {
|
|
205
|
-
rest = rest.replace(/^ +/, '');
|
|
206
|
-
const typeMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
207
|
-
if (typeMatch && !rest.match(/^[A-Za-z_][A-Za-z0-9_]*=/)) {
|
|
208
|
-
props.kind = typeMatch[1];
|
|
209
|
-
rest = rest.slice(typeMatch[0].length);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
// Special: respond with optional status — "respond 200 json=users" / "respond redirect=/login"
|
|
213
|
-
if (type === 'respond') {
|
|
214
|
-
rest = rest.replace(/^ +/, '');
|
|
215
|
-
const statusMatch = rest.match(/^(\d{3})/);
|
|
216
|
-
if (statusMatch) {
|
|
217
|
-
props.status = parseInt(statusMatch[1], 10);
|
|
218
|
-
rest = rest.slice(statusMatch[0].length);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Special: middleware bare word list — "middleware rateLimit, cors"
|
|
222
|
-
if (type === 'middleware') {
|
|
223
|
-
rest = rest.replace(/^ +/, '');
|
|
224
|
-
if (rest.length > 0 && !rest.includes('=')) {
|
|
225
|
-
const names = rest.split(',').map(s => s.trim()).filter(Boolean);
|
|
226
|
-
if (names.length > 1) {
|
|
227
|
-
props.names = names;
|
|
228
|
-
}
|
|
229
|
-
else if (names.length === 1) {
|
|
230
|
-
props.name = names[0];
|
|
422
|
+
s.skipWS();
|
|
423
|
+
if (!s.isKeyValue()) {
|
|
424
|
+
const id = s.tryIdent();
|
|
425
|
+
if (id)
|
|
426
|
+
props[evolvedHints.bareWord] = id;
|
|
231
427
|
}
|
|
232
|
-
rest = '';
|
|
233
428
|
}
|
|
234
429
|
}
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
430
|
+
// ── Keyword-specific handling ──────────────────────────────────────
|
|
431
|
+
const handler = KEYWORD_HANDLERS.get(type);
|
|
432
|
+
if (handler)
|
|
433
|
+
handler(s, props, content);
|
|
434
|
+
// ── Generic prop/style/theme parsing ───────────────────────────────
|
|
435
|
+
while (!s.done()) {
|
|
436
|
+
s.skipWS();
|
|
437
|
+
if (s.done())
|
|
239
438
|
break;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (rest[j] === '"')
|
|
246
|
-
inQuote = !inQuote;
|
|
247
|
-
if (!inQuote && rest[j] === '}') {
|
|
248
|
-
close = j;
|
|
249
|
-
break;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (close === -1)
|
|
253
|
-
break;
|
|
254
|
-
const block = rest.slice(1, close);
|
|
255
|
-
parseStyleBlock(block, styles, pseudoStyles);
|
|
256
|
-
rest = rest.slice(close + 1);
|
|
439
|
+
const tok = s.peek();
|
|
440
|
+
// Style block
|
|
441
|
+
if (tok.kind === 'style') {
|
|
442
|
+
parseStyleBlock(tok.value, styles, pseudoStyles);
|
|
443
|
+
s.next();
|
|
257
444
|
continue;
|
|
258
445
|
}
|
|
259
446
|
// Theme ref
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
themeRefs.push(refMatch[1]);
|
|
264
|
-
rest = rest.slice(refMatch[0].length);
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
// Prop: key={{ expression }}
|
|
269
|
-
const exprPropMatch = rest.match(/^([A-Za-z_][A-Za-z0-9_-]*)=\{\{/);
|
|
270
|
-
if (exprPropMatch) {
|
|
271
|
-
const key = exprPropMatch[1];
|
|
272
|
-
rest = rest.slice(exprPropMatch[0].length);
|
|
273
|
-
// Find matching }}
|
|
274
|
-
let depth = 1;
|
|
275
|
-
let j = 0;
|
|
276
|
-
for (; j < rest.length - 1; j++) {
|
|
277
|
-
if (rest[j] === '{' && rest[j + 1] === '{') {
|
|
278
|
-
depth++;
|
|
279
|
-
j++;
|
|
280
|
-
}
|
|
281
|
-
else if (rest[j] === '}' && rest[j + 1] === '}') {
|
|
282
|
-
depth--;
|
|
283
|
-
j++;
|
|
284
|
-
if (depth === 0)
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
const expr = rest.slice(0, j - 1).trim();
|
|
289
|
-
rest = rest.slice(j + 1);
|
|
290
|
-
props[key] = { __expr: true, code: expr };
|
|
447
|
+
if (tok.kind === 'themeRef') {
|
|
448
|
+
themeRefs.push(tok.value);
|
|
449
|
+
s.next();
|
|
291
450
|
continue;
|
|
292
451
|
}
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
if (propMatch) {
|
|
296
|
-
const key = propMatch[1];
|
|
297
|
-
rest = rest.slice(propMatch[0].length);
|
|
298
|
-
let value;
|
|
299
|
-
if (rest[0] === '"') {
|
|
300
|
-
const endQuote = rest.indexOf('"', 1);
|
|
301
|
-
value = rest.slice(1, endQuote);
|
|
302
|
-
rest = rest.slice(endQuote + 1);
|
|
303
|
-
}
|
|
304
|
-
else if (rest.startsWith('{{')) {
|
|
305
|
-
// Bare expression without key= prefix (e.g. value={{ x }})
|
|
306
|
-
rest = rest.slice(2);
|
|
307
|
-
let depth = 1;
|
|
308
|
-
let j = 0;
|
|
309
|
-
for (; j < rest.length - 1; j++) {
|
|
310
|
-
if (rest[j] === '{' && rest[j + 1] === '{') {
|
|
311
|
-
depth++;
|
|
312
|
-
j++;
|
|
313
|
-
}
|
|
314
|
-
else if (rest[j] === '}' && rest[j + 1] === '}') {
|
|
315
|
-
depth--;
|
|
316
|
-
j++;
|
|
317
|
-
if (depth === 0)
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
const expr = rest.slice(0, j - 1).trim();
|
|
322
|
-
rest = rest.slice(j + 1);
|
|
323
|
-
props[key] = { __expr: true, code: expr };
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
const endMatch = rest.match(/^[^\s{$]+/);
|
|
328
|
-
value = endMatch ? endMatch[0] : '';
|
|
329
|
-
rest = rest.slice(value.length);
|
|
330
|
-
}
|
|
331
|
-
props[key] = value;
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
// Unknown token — collect as warning, skip to next whitespace
|
|
335
|
-
const skipped = rest.match(/^\S+/);
|
|
336
|
-
if (skipped) {
|
|
337
|
-
const errCol = col + (raw.length - rest.length);
|
|
338
|
-
_parseWarnings.push(`Unexpected token "${skipped[0]}" at line ${lineNum}:${errCol}`);
|
|
339
|
-
rest = rest.slice(skipped[0].length);
|
|
452
|
+
// Key=value prop (extracted helper from Codex)
|
|
453
|
+
if (parseProp(s, props))
|
|
340
454
|
continue;
|
|
341
|
-
|
|
342
|
-
|
|
455
|
+
// Unknown token — skip with warning
|
|
456
|
+
const skipped = s.next();
|
|
457
|
+
const errCol = col + skipped.pos;
|
|
458
|
+
_parseWarnings.push(`Unexpected token "${skipped.value}" at line ${lineNum}:${errCol}`);
|
|
343
459
|
}
|
|
344
460
|
return {
|
|
345
461
|
indent: indent / 2,
|
|
@@ -351,6 +467,7 @@ function parseLine(raw, lineNum) {
|
|
|
351
467
|
loc: { line: lineNum, col },
|
|
352
468
|
};
|
|
353
469
|
}
|
|
470
|
+
// ── Style block parsing (unchanged) ──────────────────────────────────────
|
|
354
471
|
function splitStylePairs(block) {
|
|
355
472
|
const pairs = [];
|
|
356
473
|
let current = '';
|
|
@@ -400,7 +517,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
|
|
|
400
517
|
if (quotedKeyMatch) {
|
|
401
518
|
const key = quotedKeyMatch[1];
|
|
402
519
|
let value = quotedKeyMatch[2].trim();
|
|
403
|
-
// Strip surrounding quotes from value if present
|
|
404
520
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
405
521
|
value = value.slice(1, -1);
|
|
406
522
|
}
|
|
@@ -412,7 +528,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
|
|
|
412
528
|
if (colonIdx > 0) {
|
|
413
529
|
const key = pair.slice(0, colonIdx).trim();
|
|
414
530
|
let value = pair.slice(colonIdx + 1).trim();
|
|
415
|
-
// Strip surrounding quotes from value if present
|
|
416
531
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
417
532
|
value = value.slice(1, -1);
|
|
418
533
|
}
|
|
@@ -421,8 +536,6 @@ function parseStyleBlock(block, styles, pseudoStyles) {
|
|
|
421
536
|
}
|
|
422
537
|
}
|
|
423
538
|
function expandMinified(source) {
|
|
424
|
-
// Detect minified S-expression format: node(child1,child2)
|
|
425
|
-
// Convert to indented format for the standard parser
|
|
426
539
|
if (!source.includes('(') || source.split('\n').length > 2)
|
|
427
540
|
return source;
|
|
428
541
|
const result = [];
|
|
@@ -483,7 +596,6 @@ function expandMinified(source) {
|
|
|
483
596
|
export function getParseWarnings() { return [..._parseWarnings]; }
|
|
484
597
|
export function parse(source) {
|
|
485
598
|
_parseWarnings = [];
|
|
486
|
-
// Handle minified S-expression format
|
|
487
599
|
source = expandMinified(source);
|
|
488
600
|
const lines = source.split('\n');
|
|
489
601
|
const parsed = [];
|
|
@@ -495,7 +607,6 @@ export function parse(source) {
|
|
|
495
607
|
const codeLines = [];
|
|
496
608
|
const startLine = i + 1;
|
|
497
609
|
const blockOpen = `${multilineType} <<<`;
|
|
498
|
-
// Check if inline close on same line
|
|
499
610
|
const afterOpen = trimmed.slice(blockOpen.length);
|
|
500
611
|
if (afterOpen.includes('>>>')) {
|
|
501
612
|
codeLines.push(afterOpen.split('>>>')[0]);
|
|
@@ -506,7 +617,6 @@ export function parse(source) {
|
|
|
506
617
|
codeLines.push(lines[i]);
|
|
507
618
|
i++;
|
|
508
619
|
}
|
|
509
|
-
// Capture text before >>> on closing line
|
|
510
620
|
if (i < lines.length) {
|
|
511
621
|
const closeLine = lines[i];
|
|
512
622
|
const closeIdx = closeLine.indexOf('>>>');
|
|
@@ -547,13 +657,11 @@ export function parse(source) {
|
|
|
547
657
|
node.props.themeRefs = p.themeRefs;
|
|
548
658
|
return node;
|
|
549
659
|
}
|
|
550
|
-
// Build tree using indent levels
|
|
551
660
|
const root = toNode(parsed[0]);
|
|
552
661
|
const stack = [{ node: root, indent: parsed[0].indent }];
|
|
553
662
|
for (let i = 1; i < parsed.length; i++) {
|
|
554
663
|
const p = parsed[i];
|
|
555
664
|
const node = toNode(p);
|
|
556
|
-
// Pop stack until we find a parent at a lower indent level
|
|
557
665
|
while (stack.length > 1 && stack[stack.length - 1].indent >= p.indent) {
|
|
558
666
|
stack.pop();
|
|
559
667
|
}
|