@zenithbuild/core 0.4.2 → 0.4.4

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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Fragment Lowering
3
+ *
4
+ * Transforms JSX-returning expressions into structural fragment nodes.
5
+ *
6
+ * This phase runs AFTER parsing, BEFORE component resolution.
7
+ * Transforms ExpressionNode → ConditionalFragmentNode | OptionalFragmentNode | LoopFragmentNode
8
+ *
9
+ * IMPORTANT: JSX in Zenith is compile-time sugar only.
10
+ * The compiler enumerates all possible DOM shapes and lowers them at compile time.
11
+ * Runtime never creates DOM — it only toggles visibility and binds values.
12
+ */
13
+
14
+ import type {
15
+ TemplateNode,
16
+ ExpressionNode,
17
+ ConditionalFragmentNode,
18
+ OptionalFragmentNode,
19
+ LoopFragmentNode,
20
+ LoopContext,
21
+ SourceLocation,
22
+ ExpressionIR
23
+ } from '../ir/types'
24
+ import { classifyExpression, requiresStructuralLowering } from './classifyExpression'
25
+ import { InvariantError } from '../errors/compilerError'
26
+ import { INVARIANT } from '../validate/invariants'
27
+
28
+ /**
29
+ * Lower JSX-returning expressions into structural fragments
30
+ *
31
+ * Walks the node tree and transforms ExpressionNode instances
32
+ * that return JSX into the appropriate fragment node types.
33
+ *
34
+ * @param nodes - Template nodes to process
35
+ * @param filePath - Source file path for error reporting
36
+ * @param expressions - Expression registry (mutated to add new expressions)
37
+ * @returns Lowered nodes with fragment bindings
38
+ */
39
+ export function lowerFragments(
40
+ nodes: TemplateNode[],
41
+ filePath: string,
42
+ expressions: ExpressionIR[]
43
+ ): TemplateNode[] {
44
+ return nodes.map(node => lowerNode(node, filePath, expressions))
45
+ }
46
+
47
+ /**
48
+ * Lower a single node
49
+ */
50
+ function lowerNode(
51
+ node: TemplateNode,
52
+ filePath: string,
53
+ expressions: ExpressionIR[]
54
+ ): TemplateNode {
55
+ switch (node.type) {
56
+ case 'expression':
57
+ return lowerExpressionNode(node, filePath, expressions)
58
+
59
+ case 'element':
60
+ return {
61
+ ...node,
62
+ children: lowerFragments(node.children, filePath, expressions)
63
+ }
64
+
65
+ case 'component':
66
+ return {
67
+ ...node,
68
+ children: lowerFragments(node.children, filePath, expressions)
69
+ }
70
+
71
+ case 'conditional-fragment':
72
+ return {
73
+ ...node,
74
+ consequent: lowerFragments(node.consequent, filePath, expressions),
75
+ alternate: lowerFragments(node.alternate, filePath, expressions)
76
+ }
77
+
78
+ case 'optional-fragment':
79
+ return {
80
+ ...node,
81
+ fragment: lowerFragments(node.fragment, filePath, expressions)
82
+ }
83
+
84
+ case 'loop-fragment':
85
+ return {
86
+ ...node,
87
+ body: lowerFragments(node.body, filePath, expressions)
88
+ }
89
+
90
+ case 'text':
91
+ default:
92
+ return node
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Lower an expression node to a fragment if it returns JSX
98
+ */
99
+ function lowerExpressionNode(
100
+ node: ExpressionNode,
101
+ filePath: string,
102
+ expressions: ExpressionIR[]
103
+ ): TemplateNode {
104
+ const classification = classifyExpression(node.expression)
105
+
106
+ // Primitive expressions pass through unchanged
107
+ if (classification.type === 'primitive') {
108
+ return node
109
+ }
110
+
111
+ // Unknown expressions with JSX are compile errors
112
+ if (classification.type === 'unknown') {
113
+ throw new InvariantError(
114
+ INVARIANT.NON_ENUMERABLE_JSX,
115
+ `JSX expression output cannot be statically determined: ${node.expression.slice(0, 50)}...`,
116
+ 'JSX expressions must have statically enumerable output. The compiler must know all possible DOM shapes at compile time.',
117
+ filePath,
118
+ node.location.line,
119
+ node.location.column
120
+ )
121
+ }
122
+
123
+ // Lower based on classification type
124
+ switch (classification.type) {
125
+ case 'conditional':
126
+ return lowerConditionalExpression(
127
+ node,
128
+ classification.condition!,
129
+ classification.consequent!,
130
+ classification.alternate!,
131
+ filePath,
132
+ expressions
133
+ )
134
+
135
+ case 'optional':
136
+ return lowerOptionalExpression(
137
+ node,
138
+ classification.optionalCondition!,
139
+ classification.optionalFragment!,
140
+ filePath,
141
+ expressions
142
+ )
143
+
144
+ case 'loop':
145
+ return lowerLoopExpression(
146
+ node,
147
+ classification.loopSource!,
148
+ classification.loopItemVar!,
149
+ classification.loopIndexVar,
150
+ classification.loopBody!,
151
+ filePath,
152
+ expressions
153
+ )
154
+
155
+ case 'fragment':
156
+ return lowerInlineFragment(
157
+ node,
158
+ classification.fragmentCode!,
159
+ filePath,
160
+ expressions
161
+ )
162
+
163
+ default:
164
+ // Should not reach here
165
+ return node
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Lower conditional expression: condition ? <A /> : <B />
171
+ *
172
+ * Both branches are parsed and compiled at compile time.
173
+ */
174
+ function lowerConditionalExpression(
175
+ node: ExpressionNode,
176
+ condition: string,
177
+ consequentCode: string,
178
+ alternateCode: string,
179
+ filePath: string,
180
+ expressions: ExpressionIR[]
181
+ ): ConditionalFragmentNode {
182
+ // Parse both branches as JSX fragments
183
+ const consequent = parseJSXToNodes(consequentCode, node.location, filePath, expressions, node.loopContext)
184
+ const alternate = parseJSXToNodes(alternateCode, node.location, filePath, expressions, node.loopContext)
185
+
186
+ return {
187
+ type: 'conditional-fragment',
188
+ condition,
189
+ consequent,
190
+ alternate,
191
+ location: node.location,
192
+ loopContext: node.loopContext
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Lower optional expression: condition && <A />
198
+ *
199
+ * Fragment is parsed and compiled at compile time.
200
+ */
201
+ function lowerOptionalExpression(
202
+ node: ExpressionNode,
203
+ condition: string,
204
+ fragmentCode: string,
205
+ filePath: string,
206
+ expressions: ExpressionIR[]
207
+ ): OptionalFragmentNode {
208
+ const fragment = parseJSXToNodes(fragmentCode, node.location, filePath, expressions, node.loopContext)
209
+
210
+ return {
211
+ type: 'optional-fragment',
212
+ condition,
213
+ fragment,
214
+ location: node.location,
215
+ loopContext: node.loopContext
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Lower loop expression: items.map(item => <li>...</li>)
221
+ *
222
+ * Body is parsed and compiled once, instantiated per item at runtime.
223
+ */
224
+ function lowerLoopExpression(
225
+ node: ExpressionNode,
226
+ source: string,
227
+ itemVar: string,
228
+ indexVar: string | undefined,
229
+ bodyCode: string,
230
+ filePath: string,
231
+ expressions: ExpressionIR[]
232
+ ): LoopFragmentNode {
233
+ // Create loop context for the body
234
+ const loopVariables = [itemVar]
235
+ if (indexVar) {
236
+ loopVariables.push(indexVar)
237
+ }
238
+
239
+ const bodyLoopContext: LoopContext = {
240
+ variables: node.loopContext
241
+ ? [...node.loopContext.variables, ...loopVariables]
242
+ : loopVariables,
243
+ mapSource: source
244
+ }
245
+
246
+ // Parse body with loop context
247
+ const body = parseJSXToNodes(bodyCode, node.location, filePath, expressions, bodyLoopContext)
248
+
249
+ return {
250
+ type: 'loop-fragment',
251
+ source,
252
+ itemVar,
253
+ indexVar,
254
+ body,
255
+ location: node.location,
256
+ loopContext: bodyLoopContext
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Lower inline fragment: <A /> or <><A /><B /></>
262
+ *
263
+ * JSX is parsed and inlined directly into the node tree.
264
+ * Returns the original expression node since inline JSX
265
+ * is already handled by the expression transformer.
266
+ */
267
+ function lowerInlineFragment(
268
+ node: ExpressionNode,
269
+ fragmentCode: string,
270
+ filePath: string,
271
+ expressions: ExpressionIR[]
272
+ ): TemplateNode {
273
+ // For now, inline fragments are handled by the existing expression transformer
274
+ // which converts JSX to __zenith.h() calls
275
+ // In a future iteration, we could parse them to static nodes here
276
+ return node
277
+ }
278
+
279
+ /**
280
+ * Parse JSX code string into TemplateNode[]
281
+ *
282
+ * This is a simplified parser for JSX fragments within expressions.
283
+ * It handles basic JSX structure for lowering purposes.
284
+ */
285
+ function parseJSXToNodes(
286
+ code: string,
287
+ baseLocation: SourceLocation,
288
+ filePath: string,
289
+ expressions: ExpressionIR[],
290
+ loopContext?: LoopContext
291
+ ): TemplateNode[] {
292
+ const trimmed = code.trim()
293
+
294
+ // Handle fragment syntax <>...</>
295
+ if (trimmed.startsWith('<>')) {
296
+ const content = extractFragmentContent(trimmed)
297
+ return parseJSXChildren(content, baseLocation, filePath, expressions, loopContext)
298
+ }
299
+
300
+ // Handle single element
301
+ if (trimmed.startsWith('<')) {
302
+ const element = parseJSXElement(trimmed, baseLocation, filePath, expressions, loopContext)
303
+ return element ? [element] : []
304
+ }
305
+
306
+ // Handle parenthesized expression
307
+ if (trimmed.startsWith('(')) {
308
+ const inner = trimmed.slice(1, -1).trim()
309
+ return parseJSXToNodes(inner, baseLocation, filePath, expressions, loopContext)
310
+ }
311
+
312
+ // Not JSX - return as expression node
313
+ return [{
314
+ type: 'expression',
315
+ expression: trimmed,
316
+ location: baseLocation,
317
+ loopContext
318
+ }]
319
+ }
320
+
321
+ /**
322
+ * Extract content from fragment syntax <>content</>
323
+ */
324
+ function extractFragmentContent(code: string): string {
325
+ // Remove <> prefix and </> suffix
326
+ const withoutOpen = code.slice(2)
327
+ const closeIndex = withoutOpen.lastIndexOf('</>')
328
+ if (closeIndex === -1) {
329
+ return withoutOpen
330
+ }
331
+ return withoutOpen.slice(0, closeIndex)
332
+ }
333
+
334
+ /**
335
+ * Parse JSX children content
336
+ */
337
+ function parseJSXChildren(
338
+ content: string,
339
+ baseLocation: SourceLocation,
340
+ filePath: string,
341
+ expressions: ExpressionIR[],
342
+ loopContext?: LoopContext
343
+ ): TemplateNode[] {
344
+ const nodes: TemplateNode[] = []
345
+ let i = 0
346
+ let currentText = ''
347
+
348
+ while (i < content.length) {
349
+ const char = content[i]
350
+
351
+ // Check for JSX element
352
+ if (char === '<' && /[a-zA-Z]/.test(content[i + 1] || '')) {
353
+ // Save accumulated text
354
+ if (currentText.trim()) {
355
+ nodes.push({
356
+ type: 'text',
357
+ value: currentText.trim(),
358
+ location: baseLocation
359
+ })
360
+ currentText = ''
361
+ }
362
+
363
+ // Parse element
364
+ const result = parseJSXElementWithEnd(content, i, baseLocation, filePath, expressions, loopContext)
365
+ if (result) {
366
+ nodes.push(result.node)
367
+ i = result.endIndex
368
+ continue
369
+ }
370
+ }
371
+
372
+ // Check for expression {expr}
373
+ if (char === '{') {
374
+ const endBrace = findBalancedBraceEnd(content, i)
375
+ if (endBrace !== -1) {
376
+ // Save accumulated text
377
+ if (currentText.trim()) {
378
+ nodes.push({
379
+ type: 'text',
380
+ value: currentText.trim(),
381
+ location: baseLocation
382
+ })
383
+ currentText = ''
384
+ }
385
+
386
+ const exprCode = content.slice(i + 1, endBrace - 1).trim()
387
+ if (exprCode) {
388
+ nodes.push({
389
+ type: 'expression',
390
+ expression: exprCode,
391
+ location: baseLocation,
392
+ loopContext
393
+ })
394
+ }
395
+ i = endBrace
396
+ continue
397
+ }
398
+ }
399
+
400
+ currentText += char
401
+ i++
402
+ }
403
+
404
+ // Add remaining text
405
+ if (currentText.trim()) {
406
+ nodes.push({
407
+ type: 'text',
408
+ value: currentText.trim(),
409
+ location: baseLocation
410
+ })
411
+ }
412
+
413
+ return nodes
414
+ }
415
+
416
+ /**
417
+ * Parse a single JSX element
418
+ */
419
+ function parseJSXElement(
420
+ code: string,
421
+ baseLocation: SourceLocation,
422
+ filePath: string,
423
+ expressions: ExpressionIR[],
424
+ loopContext?: LoopContext
425
+ ): TemplateNode | null {
426
+ const result = parseJSXElementWithEnd(code, 0, baseLocation, filePath, expressions, loopContext)
427
+ return result ? result.node : null
428
+ }
429
+
430
+ /**
431
+ * Parse JSX element and return end index
432
+ */
433
+ function parseJSXElementWithEnd(
434
+ code: string,
435
+ startIndex: number,
436
+ baseLocation: SourceLocation,
437
+ filePath: string,
438
+ expressions: ExpressionIR[],
439
+ loopContext?: LoopContext
440
+ ): { node: TemplateNode; endIndex: number } | null {
441
+ // Extract tag name
442
+ const tagMatch = code.slice(startIndex).match(/^<([a-zA-Z][a-zA-Z0-9.]*)/)
443
+ if (!tagMatch) return null
444
+
445
+ const tagName = tagMatch[1]!
446
+ let i = startIndex + tagMatch[0].length
447
+
448
+ // Parse attributes (simplified)
449
+ const attributes: Array<{ name: string; value: string; location: SourceLocation }> = []
450
+
451
+ // Skip whitespace and parse attributes until > or />
452
+ while (i < code.length) {
453
+ // Skip whitespace
454
+ while (i < code.length && /\s/.test(code[i]!)) i++
455
+
456
+ // Check for end of opening tag
457
+ if (code[i] === '>') {
458
+ i++
459
+ break
460
+ }
461
+ if (code[i] === '/' && code[i + 1] === '>') {
462
+ // Self-closing tag
463
+ const isComponent = tagName[0] === tagName[0]!.toUpperCase()
464
+ const node: TemplateNode = isComponent ? {
465
+ type: 'component',
466
+ name: tagName,
467
+ attributes: attributes.map(a => ({ ...a, value: a.value })),
468
+ children: [],
469
+ location: baseLocation,
470
+ loopContext
471
+ } : {
472
+ type: 'element',
473
+ tag: tagName.toLowerCase(),
474
+ attributes: attributes.map(a => ({ ...a, value: a.value })),
475
+ children: [],
476
+ location: baseLocation,
477
+ loopContext
478
+ }
479
+ return { node, endIndex: i + 2 }
480
+ }
481
+
482
+ // Parse attribute name
483
+ const attrMatch = code.slice(i).match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/)
484
+ if (!attrMatch) {
485
+ i++
486
+ continue
487
+ }
488
+
489
+ const attrName = attrMatch[1]!
490
+ i += attrName.length
491
+
492
+ // Skip whitespace
493
+ while (i < code.length && /\s/.test(code[i]!)) i++
494
+
495
+ // Check for value
496
+ if (code[i] !== '=') {
497
+ attributes.push({ name: attrName, value: 'true', location: baseLocation })
498
+ continue
499
+ }
500
+ i++ // Skip =
501
+
502
+ // Skip whitespace
503
+ while (i < code.length && /\s/.test(code[i]!)) i++
504
+
505
+ // Parse value
506
+ if (code[i] === '"' || code[i] === "'") {
507
+ const quote = code[i]
508
+ let endQuote = i + 1
509
+ while (endQuote < code.length && code[endQuote] !== quote) {
510
+ if (code[endQuote] === '\\') endQuote++
511
+ endQuote++
512
+ }
513
+ attributes.push({ name: attrName, value: code.slice(i + 1, endQuote), location: baseLocation })
514
+ i = endQuote + 1
515
+ } else if (code[i] === '{') {
516
+ const endBrace = findBalancedBraceEnd(code, i)
517
+ if (endBrace !== -1) {
518
+ attributes.push({ name: attrName, value: code.slice(i, endBrace), location: baseLocation })
519
+ i = endBrace
520
+ }
521
+ }
522
+ }
523
+
524
+ // Parse children until closing tag
525
+ const closeTag = `</${tagName}>`
526
+ const closeIndex = findClosingTag(code, i, tagName)
527
+
528
+ let children: TemplateNode[] = []
529
+ if (closeIndex !== -1 && closeIndex > i) {
530
+ const childContent = code.slice(i, closeIndex)
531
+ children = parseJSXChildren(childContent, baseLocation, filePath, expressions, loopContext)
532
+ i = closeIndex + closeTag.length
533
+ }
534
+
535
+ const isComponent = tagName[0] === tagName[0]!.toUpperCase()
536
+ const node: TemplateNode = isComponent ? {
537
+ type: 'component',
538
+ name: tagName,
539
+ attributes: attributes.map(a => ({ ...a, value: a.value })),
540
+ children,
541
+ location: baseLocation,
542
+ loopContext
543
+ } : {
544
+ type: 'element',
545
+ tag: tagName.toLowerCase(),
546
+ attributes: attributes.map(a => ({ ...a, value: a.value })),
547
+ children,
548
+ location: baseLocation,
549
+ loopContext
550
+ }
551
+
552
+ return { node, endIndex: i }
553
+ }
554
+
555
+ /**
556
+ * Find closing tag for an element
557
+ */
558
+ function findClosingTag(code: string, startIndex: number, tagName: string): number {
559
+ const closeTag = `</${tagName}>`
560
+ let depth = 1
561
+ let i = startIndex
562
+
563
+ while (i < code.length && depth > 0) {
564
+ // Check for closing tag
565
+ if (code.slice(i, i + closeTag.length) === closeTag) {
566
+ depth--
567
+ if (depth === 0) return i
568
+ i += closeTag.length
569
+ continue
570
+ }
571
+
572
+ // Check for opening tag (same name, nested)
573
+ const openPattern = new RegExp(`^<${tagName}(?:\\s|>|/>)`)
574
+ const match = code.slice(i).match(openPattern)
575
+ if (match) {
576
+ // Check if self-closing
577
+ const selfClosing = code.slice(i).match(new RegExp(`^<${tagName}[^>]*/>`))
578
+ if (!selfClosing) {
579
+ depth++
580
+ }
581
+ i += match[0].length
582
+ continue
583
+ }
584
+
585
+ i++
586
+ }
587
+
588
+ return -1
589
+ }
590
+
591
+ /**
592
+ * Find balanced brace end
593
+ */
594
+ function findBalancedBraceEnd(code: string, startIndex: number): number {
595
+ if (code[startIndex] !== '{') return -1
596
+
597
+ let depth = 1
598
+ let i = startIndex + 1
599
+ let inString = false
600
+ let stringChar = ''
601
+
602
+ while (i < code.length && depth > 0) {
603
+ const char = code[i]
604
+ const prevChar = code[i - 1]
605
+
606
+ // Handle escape
607
+ if (prevChar === '\\') {
608
+ i++
609
+ continue
610
+ }
611
+
612
+ // Handle strings
613
+ if (!inString && (char === '"' || char === "'")) {
614
+ inString = true
615
+ stringChar = char
616
+ i++
617
+ continue
618
+ }
619
+ if (inString && char === stringChar) {
620
+ inString = false
621
+ i++
622
+ continue
623
+ }
624
+
625
+ if (!inString) {
626
+ if (char === '{') depth++
627
+ else if (char === '}') depth--
628
+ }
629
+
630
+ i++
631
+ }
632
+
633
+ return depth === 0 ? i : -1
634
+ }