tova 0.3.1 → 0.3.3
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/bin/tova.js +58 -23
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +5 -0
- package/src/analyzer/client-analyzer.js +14 -0
- package/src/codegen/base-codegen.js +13 -0
- package/src/codegen/client-codegen.js +44 -2
- package/src/codegen/shared-codegen.js +1 -1
- package/src/lexer/lexer.js +24 -7
- package/src/parser/ast.js +1 -1
- package/src/parser/client-ast.js +9 -0
- package/src/parser/client-parser.js +80 -7
- package/src/parser/parser.js +35 -5
- package/src/version.js +1 -1
package/bin/tova.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createHash } from 'crypto';
|
|
|
7
7
|
import { Lexer } from '../src/lexer/lexer.js';
|
|
8
8
|
import { Parser } from '../src/parser/parser.js';
|
|
9
9
|
import { Analyzer } from '../src/analyzer/analyzer.js';
|
|
10
|
+
import { Symbol } from '../src/analyzer/scope.js';
|
|
10
11
|
import { Program } from '../src/parser/ast.js';
|
|
11
12
|
import { CodeGenerator } from '../src/codegen/codegen.js';
|
|
12
13
|
import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from '../src/diagnostics/formatter.js';
|
|
@@ -209,9 +210,15 @@ function compileTova(source, filename, options = {}) {
|
|
|
209
210
|
const ast = parser.parse();
|
|
210
211
|
|
|
211
212
|
const analyzer = new Analyzer(ast, filename, { strict: options.strict || false });
|
|
213
|
+
// Pre-define extra names in the analyzer scope (used by REPL for cross-line persistence)
|
|
214
|
+
if (options.knownNames) {
|
|
215
|
+
for (const name of options.knownNames) {
|
|
216
|
+
analyzer.globalScope.define(name, new Symbol(name, 'variable', null, true, { line: 0, column: 0, file: '<repl>' }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
212
219
|
const { warnings } = analyzer.analyze();
|
|
213
220
|
|
|
214
|
-
if (warnings.length > 0) {
|
|
221
|
+
if (warnings.length > 0 && !options.suppressWarnings) {
|
|
215
222
|
const formatter = new DiagnosticFormatter(source, filename);
|
|
216
223
|
for (const w of warnings) {
|
|
217
224
|
console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
|
|
@@ -550,8 +557,22 @@ async function runFile(filePath, options = {}) {
|
|
|
550
557
|
const source = readFileSync(resolved, 'utf-8');
|
|
551
558
|
|
|
552
559
|
try {
|
|
553
|
-
//
|
|
554
|
-
const
|
|
560
|
+
// Detect local .tova imports (with or without .tova extension)
|
|
561
|
+
const importDetectRegex = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]+)['"]/gm;
|
|
562
|
+
let importMatch;
|
|
563
|
+
const tovaImportPaths = [];
|
|
564
|
+
while ((importMatch = importDetectRegex.exec(source)) !== null) {
|
|
565
|
+
const importSource = importMatch[1];
|
|
566
|
+
if (!importSource.startsWith('.') && !importSource.startsWith('/')) continue;
|
|
567
|
+
let depPath = resolve(dirname(resolved), importSource);
|
|
568
|
+
if (!depPath.endsWith('.tova') && existsSync(depPath + '.tova')) {
|
|
569
|
+
depPath = depPath + '.tova';
|
|
570
|
+
}
|
|
571
|
+
if (depPath.endsWith('.tova') && existsSync(depPath)) {
|
|
572
|
+
tovaImportPaths.push({ source: importSource, resolved: depPath });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const hasTovaImports = tovaImportPaths.length > 0;
|
|
555
576
|
|
|
556
577
|
const output = compileTova(source, filePath, { strict: options.strict });
|
|
557
578
|
|
|
@@ -562,29 +583,29 @@ async function runFile(filePath, options = {}) {
|
|
|
562
583
|
// Compile .tova dependencies and inline them
|
|
563
584
|
let depCode = '';
|
|
564
585
|
if (hasTovaImports) {
|
|
565
|
-
const importRegex = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]*\.tova)['"]/gm;
|
|
566
|
-
let match;
|
|
567
586
|
const compiled = new Set();
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
// Strip export keywords for inlining
|
|
577
|
-
depShared = depShared.replace(/^export /gm, '');
|
|
578
|
-
depCode += depShared + '\n';
|
|
579
|
-
}
|
|
587
|
+
for (const imp of tovaImportPaths) {
|
|
588
|
+
if (compiled.has(imp.resolved)) continue;
|
|
589
|
+
compiled.add(imp.resolved);
|
|
590
|
+
const depSource = readFileSync(imp.resolved, 'utf-8');
|
|
591
|
+
const dep = compileTova(depSource, imp.resolved, { strict: options.strict });
|
|
592
|
+
let depShared = dep.shared || '';
|
|
593
|
+
depShared = depShared.replace(/^export /gm, '');
|
|
594
|
+
depCode += depShared + '\n';
|
|
580
595
|
}
|
|
581
596
|
}
|
|
582
597
|
|
|
583
598
|
let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.client || '');
|
|
584
599
|
// Strip 'export ' keywords — not valid inside AsyncFunction (used in tova build only)
|
|
585
600
|
code = code.replace(/^export /gm, '');
|
|
586
|
-
// Strip import lines for
|
|
601
|
+
// Strip import lines for local modules (already inlined above)
|
|
587
602
|
code = code.replace(/^import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.(?:tova|(?:shared\.)?js)['"];?\s*$/gm, '');
|
|
603
|
+
if (hasTovaImports) {
|
|
604
|
+
for (const imp of tovaImportPaths) {
|
|
605
|
+
const escaped = imp.source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
606
|
+
code = code.replace(new RegExp('^import\\s+(?:\\{[^}]*\\}|[\\w$]+|\\*\\s+as\\s+[\\w$]+)\\s+from\\s+[\'"]' + escaped + '[\'"];?\\s*$', 'gm'), '');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
588
609
|
// Auto-call main() if the compiled code defines a main function
|
|
589
610
|
const scriptArgs = options.scriptArgs || [];
|
|
590
611
|
if (/\bfunction\s+main\s*\(/.test(code)) {
|
|
@@ -689,8 +710,10 @@ async function buildProject(args) {
|
|
|
689
710
|
if (!result) continue;
|
|
690
711
|
|
|
691
712
|
const { output, single } = result;
|
|
692
|
-
//
|
|
693
|
-
const outBaseName = single
|
|
713
|
+
// Preserve relative directory structure in output (e.g., src/lib/math.tova → lib/math.js)
|
|
714
|
+
const outBaseName = single
|
|
715
|
+
? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
|
|
716
|
+
: (relDir === '.' ? dirName : relDir + '/' + dirName);
|
|
694
717
|
const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
|
|
695
718
|
const elapsed = Date.now() - groupStart;
|
|
696
719
|
const timing = isVerbose ? ` (${elapsed}ms)` : '';
|
|
@@ -711,6 +734,10 @@ async function buildProject(args) {
|
|
|
711
734
|
return code;
|
|
712
735
|
};
|
|
713
736
|
|
|
737
|
+
// Ensure output subdirectory exists for nested paths (e.g., lib/math.js)
|
|
738
|
+
const outSubDir = dirname(join(outDir, outBaseName));
|
|
739
|
+
if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
|
|
740
|
+
|
|
714
741
|
// Module files: write single <name>.js (not .shared.js)
|
|
715
742
|
if (output.isModule) {
|
|
716
743
|
if (output.shared && output.shared.trim()) {
|
|
@@ -2495,7 +2522,7 @@ async function startRepl() {
|
|
|
2495
2522
|
buffer = '';
|
|
2496
2523
|
|
|
2497
2524
|
try {
|
|
2498
|
-
const output = compileTova(input, '<repl>');
|
|
2525
|
+
const output = compileTova(input, '<repl>', { suppressWarnings: true });
|
|
2499
2526
|
const code = output.shared || '';
|
|
2500
2527
|
if (code.trim()) {
|
|
2501
2528
|
// Extract function/const/let names from compiled code
|
|
@@ -2533,8 +2560,16 @@ async function startRepl() {
|
|
|
2533
2560
|
const lines = code.trim().split('\n');
|
|
2534
2561
|
const lastLine = lines[lines.length - 1].trim();
|
|
2535
2562
|
let evalCode = code;
|
|
2536
|
-
//
|
|
2537
|
-
|
|
2563
|
+
// For simple assignments (const x = expr;), echo the assigned value
|
|
2564
|
+
const constAssignMatch = lastLine.match(/^(const|let)\s+([a-zA-Z_]\w*)\s*=\s*(.+);?$/);
|
|
2565
|
+
if (constAssignMatch) {
|
|
2566
|
+
const varName = constAssignMatch[2];
|
|
2567
|
+
if (allSave) {
|
|
2568
|
+
evalCode = `${code}\n${allSave}\nreturn ${varName};`;
|
|
2569
|
+
} else {
|
|
2570
|
+
evalCode = `${code}\nreturn ${varName};`;
|
|
2571
|
+
}
|
|
2572
|
+
} else if (!/^(const |let |var |function |if |for |while |class |try |switch )/.test(lastLine) && !lastLine.endsWith('{')) {
|
|
2538
2573
|
// Replace the last statement with a return
|
|
2539
2574
|
const allButLast = lines.slice(0, -1).join('\n');
|
|
2540
2575
|
// Strip trailing semicolon from last line for the return
|
package/package.json
CHANGED
package/src/analyzer/analyzer.js
CHANGED
|
@@ -953,6 +953,11 @@ export class Analyzer {
|
|
|
953
953
|
const target = node.targets[i];
|
|
954
954
|
const existing = this._lookupAssignTarget(target);
|
|
955
955
|
if (existing) {
|
|
956
|
+
// Allow user code to shadow builtins (e.g., url = "/api")
|
|
957
|
+
if (existing.kind === 'builtin') {
|
|
958
|
+
this.currentScope.define(target, new Symbol(target, 'variable', null, false, node.loc));
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
956
961
|
if (!existing.mutable) {
|
|
957
962
|
this.error(`Cannot reassign immutable variable '${target}'. Use 'var' for mutable variables.`, node.loc, null, {
|
|
958
963
|
code: 'E202',
|
|
@@ -130,6 +130,8 @@ export function installClientAnalyzer(AnalyzerClass) {
|
|
|
130
130
|
this.visitJSXFor(child);
|
|
131
131
|
} else if (child.type === 'JSXIf') {
|
|
132
132
|
this.visitJSXIf(child);
|
|
133
|
+
} else if (child.type === 'JSXMatch') {
|
|
134
|
+
this.visitJSXMatch(child);
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
};
|
|
@@ -146,6 +148,8 @@ export function installClientAnalyzer(AnalyzerClass) {
|
|
|
146
148
|
this.visitJSXFor(child);
|
|
147
149
|
} else if (child.type === 'JSXIf') {
|
|
148
150
|
this.visitJSXIf(child);
|
|
151
|
+
} else if (child.type === 'JSXMatch') {
|
|
152
|
+
this.visitJSXMatch(child);
|
|
149
153
|
}
|
|
150
154
|
}
|
|
151
155
|
};
|
|
@@ -188,4 +192,14 @@ export function installClientAnalyzer(AnalyzerClass) {
|
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
};
|
|
195
|
+
|
|
196
|
+
AnalyzerClass.prototype.visitJSXMatch = function(node) {
|
|
197
|
+
this.visitExpression(node.subject);
|
|
198
|
+
for (const arm of node.arms) {
|
|
199
|
+
// Visit pattern bindings in a child scope
|
|
200
|
+
for (const child of arm.body) {
|
|
201
|
+
this.visitNode(child);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
191
205
|
}
|
|
@@ -10,6 +10,7 @@ export class BaseCodegen {
|
|
|
10
10
|
this._needsContainsHelper = false; // track if __contains helper is needed
|
|
11
11
|
this._needsPropagateHelper = false; // track if __propagate helper is needed
|
|
12
12
|
this._usedBuiltins = new Set(); // track which stdlib builtins are actually used
|
|
13
|
+
this._userDefinedNames = new Set(); // track user-defined top-level names (to avoid stdlib conflicts)
|
|
13
14
|
this._needsResultOption = false; // track if Ok/Err/Some/None are used
|
|
14
15
|
this._variantFields = { 'Ok': ['value'], 'Err': ['error'], 'Some': ['value'] }; // map variant name -> [field names] for pattern destructuring
|
|
15
16
|
this._traitDecls = new Map(); // traitName -> { methods: [...] }
|
|
@@ -113,6 +114,14 @@ export class BaseCodegen {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
getUsedBuiltins() {
|
|
117
|
+
// Exclude builtins that the user has redefined at top level
|
|
118
|
+
if (this._userDefinedNames.size > 0) {
|
|
119
|
+
const filtered = new Set(this._usedBuiltins);
|
|
120
|
+
for (const name of this._userDefinedNames) {
|
|
121
|
+
filtered.delete(name);
|
|
122
|
+
}
|
|
123
|
+
return filtered;
|
|
124
|
+
}
|
|
116
125
|
return this._usedBuiltins;
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -300,6 +309,10 @@ export class BaseCodegen {
|
|
|
300
309
|
return `${this.i()}${target} = ${this.genExpression(node.values[0])};`;
|
|
301
310
|
}
|
|
302
311
|
this.declareVar(target);
|
|
312
|
+
// Track top-level user definitions to avoid stdlib conflicts
|
|
313
|
+
if (this._scopes.length === 1 && BUILTIN_NAMES.has(target)) {
|
|
314
|
+
this._userDefinedNames.add(target);
|
|
315
|
+
}
|
|
303
316
|
return `${this.i()}${exportPrefix}const ${target} = ${this.genExpression(node.values[0])};`;
|
|
304
317
|
}
|
|
305
318
|
|
|
@@ -595,7 +595,10 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
595
595
|
if (node.object.type === 'Identifier' && this.storeNames.has(node.object.name)) {
|
|
596
596
|
return true; // Store property access is reactive (getters call signals)
|
|
597
597
|
}
|
|
598
|
-
return this._exprReadsSignal(node.object);
|
|
598
|
+
return this._exprReadsSignal(node.object) || (node.computed && this._exprReadsSignal(node.property));
|
|
599
|
+
}
|
|
600
|
+
if (node.type === 'OptionalChain') {
|
|
601
|
+
return this._exprReadsSignal(node.object) || (node.computed && this._exprReadsSignal(node.property));
|
|
599
602
|
}
|
|
600
603
|
if (node.type === 'TemplateLiteral') {
|
|
601
604
|
return node.parts.some(p => p.type === 'expr' && this._exprReadsSignal(p.value));
|
|
@@ -640,6 +643,7 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
640
643
|
}
|
|
641
644
|
case 'JSXFor': return this.genJSXFor(node);
|
|
642
645
|
case 'JSXIf': return this.genJSXIf(node);
|
|
646
|
+
case 'JSXMatch': return this.genJSXMatch(node);
|
|
643
647
|
default: return this.genExpression(node);
|
|
644
648
|
}
|
|
645
649
|
}
|
|
@@ -667,7 +671,12 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
667
671
|
if (this.stateNames.has(exprName)) {
|
|
668
672
|
// <select> fires 'change', all other inputs fire 'input'
|
|
669
673
|
const eventName = node.tag === 'select' ? 'change' : 'input';
|
|
670
|
-
|
|
674
|
+
// For number/range inputs, coerce e.target.value to Number
|
|
675
|
+
const typeAttr = node.attributes.find(a => a.name === 'type');
|
|
676
|
+
const typeStr = typeAttr && typeAttr.value ? (typeAttr.value.value || '') : '';
|
|
677
|
+
const isNumeric = typeStr === 'number' || typeStr === 'range';
|
|
678
|
+
const valueExpr = isNumeric ? 'Number(e.target.value)' : 'e.target.value';
|
|
679
|
+
events[eventName] = `(e) => { set${capitalize(exprName)}(${valueExpr}); }`;
|
|
671
680
|
}
|
|
672
681
|
} else if (attr.name === 'bind:checked') {
|
|
673
682
|
// Two-way binding: bind:checked={flag} → reactive checked + onChange
|
|
@@ -924,6 +933,39 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
924
933
|
return `() => ${result}`;
|
|
925
934
|
}
|
|
926
935
|
|
|
936
|
+
genJSXMatch(node) {
|
|
937
|
+
const subject = this.genExpression(node.subject);
|
|
938
|
+
const p = [];
|
|
939
|
+
p.push(`((__match) => { `);
|
|
940
|
+
|
|
941
|
+
for (let idx = 0; idx < node.arms.length; idx++) {
|
|
942
|
+
const arm = node.arms[idx];
|
|
943
|
+
const condition = this.genPatternCondition(arm.pattern, '__match', arm.guard);
|
|
944
|
+
const body = arm.body.map(c => this.genJSX(c));
|
|
945
|
+
const bodyExpr = body.length === 1 ? body[0] : `tova_fragment([${body.join(', ')}])`;
|
|
946
|
+
|
|
947
|
+
if (arm.pattern.type === 'WildcardPattern' || arm.pattern.type === 'BindingPattern') {
|
|
948
|
+
if (idx === node.arms.length - 1 && !arm.guard) {
|
|
949
|
+
// Default case
|
|
950
|
+
if (arm.pattern.type === 'BindingPattern') {
|
|
951
|
+
p.push(`const ${arm.pattern.name} = __match; `);
|
|
952
|
+
}
|
|
953
|
+
p.push(`return ${bodyExpr}; `);
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const keyword = idx === 0 ? 'if' : 'else if';
|
|
959
|
+
p.push(`${keyword} (${condition}) { `);
|
|
960
|
+
p.push(this.genPatternBindings(arm.pattern, '__match'));
|
|
961
|
+
p.push(`return ${bodyExpr}; } `);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
p.push(`})(${subject})`);
|
|
965
|
+
// Wrap in reactive closure
|
|
966
|
+
return `() => ${p.join('')}`;
|
|
967
|
+
}
|
|
968
|
+
|
|
927
969
|
genJSXFragment(node) {
|
|
928
970
|
const children = node.children.map(c => this.genJSX(c)).join(', ');
|
|
929
971
|
return `tova_fragment([${children}])`;
|
|
@@ -22,7 +22,7 @@ export class SharedCodegen extends BaseCodegen {
|
|
|
22
22
|
helpers.push(this.getPropagateHelper());
|
|
23
23
|
}
|
|
24
24
|
// Include only used builtin functions
|
|
25
|
-
const selectiveStdlib = buildSelectiveStdlib(this.
|
|
25
|
+
const selectiveStdlib = buildSelectiveStdlib(this.getUsedBuiltins());
|
|
26
26
|
if (selectiveStdlib) helpers.push(selectiveStdlib);
|
|
27
27
|
return helpers.join('\n');
|
|
28
28
|
}
|
package/src/lexer/lexer.js
CHANGED
|
@@ -18,7 +18,8 @@ export class Lexer {
|
|
|
18
18
|
this._jsxTagMode = null; // null | 'open' | 'close' — current tag parsing state
|
|
19
19
|
this._jsxSelfClosing = false; // true when / seen in opening tag (before >)
|
|
20
20
|
this._jsxExprDepth = 0; // brace depth for {expr} inside JSX
|
|
21
|
-
this._jsxCF = null; // null | { paren: 0, brace: 0 } — control flow state
|
|
21
|
+
this._jsxCF = null; // null | { paren: 0, brace: 0, keyword? } — control flow state
|
|
22
|
+
this._matchBlockDepth = 0; // brace depth for match body inside JSX
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
error(message) {
|
|
@@ -104,8 +105,9 @@ export class Lexer {
|
|
|
104
105
|
|
|
105
106
|
scanToken() {
|
|
106
107
|
// In JSX children mode, scan raw text instead of normal tokens
|
|
108
|
+
// (but not inside matchblock — match bodies need normal scanning for patterns/arrows)
|
|
107
109
|
if (this._jsxStack.length > 0 && this._jsxExprDepth === 0 &&
|
|
108
|
-
!this._jsxTagMode && !this._jsxCF) {
|
|
110
|
+
!this._jsxTagMode && !this._jsxCF && this._matchBlockDepth === 0) {
|
|
109
111
|
return this._scanInJSXChildren();
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -226,7 +228,7 @@ export class Lexer {
|
|
|
226
228
|
while (wp < this.length && this.isAlphaNumeric(this.source[wp])) {
|
|
227
229
|
word += this.source[wp]; wp++;
|
|
228
230
|
}
|
|
229
|
-
if (['if', 'for', 'elif', 'else'].includes(word)) {
|
|
231
|
+
if (['if', 'for', 'elif', 'else', 'match'].includes(word)) {
|
|
230
232
|
while (this.pos < pp) this.advance();
|
|
231
233
|
return;
|
|
232
234
|
}
|
|
@@ -254,16 +256,16 @@ export class Lexer {
|
|
|
254
256
|
if (ch === '"') { this.scanString(); return; }
|
|
255
257
|
if (ch === "'") { this.scanSimpleString(); return; }
|
|
256
258
|
|
|
257
|
-
// Check for JSX control flow keywords: if, for, elif, else
|
|
259
|
+
// Check for JSX control flow keywords: if, for, elif, else, match
|
|
258
260
|
if (this.isAlpha(ch)) {
|
|
259
261
|
let word = '', peekPos = this.pos;
|
|
260
262
|
while (peekPos < this.length && this.isAlphaNumeric(this.source[peekPos])) {
|
|
261
263
|
word += this.source[peekPos]; peekPos++;
|
|
262
264
|
}
|
|
263
|
-
if (['if', 'for', 'elif', 'else'].includes(word)) {
|
|
265
|
+
if (['if', 'for', 'elif', 'else', 'match'].includes(word)) {
|
|
264
266
|
this.scanIdentifier();
|
|
265
267
|
// After keyword, enter control flow mode for normal scanning
|
|
266
|
-
this._jsxCF = { paren: 0, brace: 0 };
|
|
268
|
+
this._jsxCF = { paren: 0, brace: 0, keyword: word };
|
|
267
269
|
return;
|
|
268
270
|
}
|
|
269
271
|
}
|
|
@@ -284,7 +286,7 @@ export class Lexer {
|
|
|
284
286
|
while (pp < this.length && this.isAlphaNumeric(this.source[pp])) {
|
|
285
287
|
word += this.source[pp]; pp++;
|
|
286
288
|
}
|
|
287
|
-
if (['if', 'for', 'elif', 'else'].includes(word)) break;
|
|
289
|
+
if (['if', 'for', 'elif', 'else', 'match'].includes(word)) break;
|
|
288
290
|
}
|
|
289
291
|
text += this.advance();
|
|
290
292
|
}
|
|
@@ -924,6 +926,11 @@ export class Lexer {
|
|
|
924
926
|
const prev = this.tokens.length > 1 ? this.tokens[this.tokens.length - 2] : null;
|
|
925
927
|
if (prev && (prev.type === TokenType.ASSIGN || prev.type === TokenType.FOR)) {
|
|
926
928
|
this._jsxCF.brace++;
|
|
929
|
+
} else if (this._jsxCF.keyword === 'match') {
|
|
930
|
+
// Match body: scan normally (patterns, =>, etc.) — not JSX children mode
|
|
931
|
+
this._jsxCF = null;
|
|
932
|
+
this._jsxStack.push('matchblock');
|
|
933
|
+
this._matchBlockDepth = 1;
|
|
927
934
|
} else {
|
|
928
935
|
// This is the block opener for the control flow body
|
|
929
936
|
this._jsxCF = null;
|
|
@@ -932,12 +939,22 @@ export class Lexer {
|
|
|
932
939
|
}
|
|
933
940
|
} else if (this._jsxExprDepth > 0) {
|
|
934
941
|
this._jsxExprDepth++;
|
|
942
|
+
} else if (this._matchBlockDepth > 0) {
|
|
943
|
+
this._matchBlockDepth++;
|
|
935
944
|
}
|
|
936
945
|
break;
|
|
937
946
|
case '}':
|
|
938
947
|
this.tokens.push(new Token(TokenType.RBRACE, '}', startLine, startCol));
|
|
939
948
|
if (this._jsxCF && this._jsxCF.brace > 0) {
|
|
940
949
|
this._jsxCF.brace--;
|
|
950
|
+
} else if (this._matchBlockDepth > 0) {
|
|
951
|
+
this._matchBlockDepth--;
|
|
952
|
+
if (this._matchBlockDepth === 0) {
|
|
953
|
+
// Match body closed — pop matchblock from JSX stack
|
|
954
|
+
if (this._jsxStack.length > 0 && this._jsxStack[this._jsxStack.length - 1] === 'matchblock') {
|
|
955
|
+
this._jsxStack.pop();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
941
958
|
} else if (this._jsxExprDepth > 0) {
|
|
942
959
|
this._jsxExprDepth--;
|
|
943
960
|
}
|
package/src/parser/ast.js
CHANGED
|
@@ -663,7 +663,7 @@ export {
|
|
|
663
663
|
StateDeclaration, ComputedDeclaration, EffectDeclaration,
|
|
664
664
|
ComponentDeclaration, ComponentStyleBlock, StoreDeclaration,
|
|
665
665
|
JSXElement, JSXAttribute, JSXSpreadAttribute, JSXFragment,
|
|
666
|
-
JSXText, JSXExpression, JSXFor, JSXIf,
|
|
666
|
+
JSXText, JSXExpression, JSXFor, JSXIf, JSXMatch,
|
|
667
667
|
} from './client-ast.js';
|
|
668
668
|
|
|
669
669
|
// ============================================================
|
package/src/parser/client-ast.js
CHANGED
|
@@ -136,3 +136,12 @@ export class JSXIf {
|
|
|
136
136
|
this.loc = loc;
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
export class JSXMatch {
|
|
141
|
+
constructor(subject, arms, loc) {
|
|
142
|
+
this.type = 'JSXMatch';
|
|
143
|
+
this.subject = subject; // expression being matched
|
|
144
|
+
this.arms = arms; // Array of { pattern, guard, body: [JSXChild] }
|
|
145
|
+
this.loc = loc;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Client-specific parser methods for the Tova language
|
|
2
2
|
// Extracted from parser.js for lazy loading — only loaded when client { } blocks are encountered.
|
|
3
3
|
|
|
4
|
-
import { TokenType } from '../lexer/tokens.js';
|
|
4
|
+
import { TokenType, Keywords } from '../lexer/tokens.js';
|
|
5
5
|
import * as AST from './ast.js';
|
|
6
6
|
|
|
7
7
|
export function installClientParser(ParserClass) {
|
|
@@ -52,7 +52,7 @@ export function installClientParser(ParserClass) {
|
|
|
52
52
|
body.push(this.parseState());
|
|
53
53
|
} else if (this.check(TokenType.COMPUTED)) {
|
|
54
54
|
body.push(this.parseComputed());
|
|
55
|
-
} else if (this.check(TokenType.FN) && this.peek(1).type === TokenType.IDENTIFIER) {
|
|
55
|
+
} else if (this.check(TokenType.FN) && (this.peek(1).type === TokenType.IDENTIFIER || this._isContextualKeywordToken(this.peek(1)))) {
|
|
56
56
|
body.push(this.parseFunctionDeclaration());
|
|
57
57
|
} else {
|
|
58
58
|
this.error("Expected 'state', 'computed', or 'fn' inside store block");
|
|
@@ -226,6 +226,12 @@ export function installClientParser(ParserClass) {
|
|
|
226
226
|
continue;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
// match inside JSX
|
|
230
|
+
if (this.check(TokenType.MATCH)) {
|
|
231
|
+
children.push(this.parseJSXMatch());
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
229
235
|
break;
|
|
230
236
|
}
|
|
231
237
|
|
|
@@ -270,11 +276,9 @@ export function installClientParser(ParserClass) {
|
|
|
270
276
|
|
|
271
277
|
ParserClass.prototype.parseJSXAttribute = function() {
|
|
272
278
|
const l = this.loc();
|
|
273
|
-
// Accept keywords as attribute names (type, class, for, etc. are valid HTML attributes)
|
|
279
|
+
// Accept keywords as attribute names (type, class, for, async, defer, etc. are valid HTML attributes)
|
|
274
280
|
let name;
|
|
275
|
-
if (this.check(TokenType.IDENTIFIER) || this.
|
|
276
|
-
this.check(TokenType.IN) || this.check(TokenType.AS) || this.check(TokenType.EXPORT) ||
|
|
277
|
-
this.check(TokenType.STATE) || this.check(TokenType.COMPUTED) || this.check(TokenType.ROUTE)) {
|
|
281
|
+
if (this.check(TokenType.IDENTIFIER) || (this.peek().value in Keywords)) {
|
|
278
282
|
name = this.advance().value;
|
|
279
283
|
} else {
|
|
280
284
|
this.error("Expected attribute name");
|
|
@@ -283,7 +287,7 @@ export function installClientParser(ParserClass) {
|
|
|
283
287
|
// Handle namespaced attributes: on:click, bind:value, class:active
|
|
284
288
|
if (this.match(TokenType.COLON)) {
|
|
285
289
|
let suffix;
|
|
286
|
-
if (this.check(TokenType.IDENTIFIER) || this.
|
|
290
|
+
if (this.check(TokenType.IDENTIFIER) || (this.peek().value in Keywords)) {
|
|
287
291
|
suffix = this.advance().value;
|
|
288
292
|
} else {
|
|
289
293
|
suffix = this.expect(TokenType.IDENTIFIER, "Expected name after ':'").value;
|
|
@@ -371,6 +375,12 @@ export function installClientParser(ParserClass) {
|
|
|
371
375
|
continue;
|
|
372
376
|
}
|
|
373
377
|
|
|
378
|
+
// match inside JSX
|
|
379
|
+
if (this.check(TokenType.MATCH)) {
|
|
380
|
+
children.push(this.parseJSXMatch());
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
374
384
|
break;
|
|
375
385
|
}
|
|
376
386
|
|
|
@@ -438,6 +448,12 @@ export function installClientParser(ParserClass) {
|
|
|
438
448
|
this.advance();
|
|
439
449
|
body.push(new AST.JSXExpression(this.parseExpression(), this.loc()));
|
|
440
450
|
this.expect(TokenType.RBRACE);
|
|
451
|
+
} else if (this.check(TokenType.FOR)) {
|
|
452
|
+
body.push(this.parseJSXFor());
|
|
453
|
+
} else if (this.check(TokenType.IF)) {
|
|
454
|
+
body.push(this.parseJSXIf());
|
|
455
|
+
} else if (this.check(TokenType.MATCH)) {
|
|
456
|
+
body.push(this.parseJSXMatch());
|
|
441
457
|
} else {
|
|
442
458
|
break;
|
|
443
459
|
}
|
|
@@ -464,6 +480,12 @@ export function installClientParser(ParserClass) {
|
|
|
464
480
|
this.advance();
|
|
465
481
|
body.push(new AST.JSXExpression(this.parseExpression(), this.loc()));
|
|
466
482
|
this.expect(TokenType.RBRACE);
|
|
483
|
+
} else if (this.check(TokenType.FOR)) {
|
|
484
|
+
body.push(this.parseJSXFor());
|
|
485
|
+
} else if (this.check(TokenType.IF)) {
|
|
486
|
+
body.push(this.parseJSXIf());
|
|
487
|
+
} else if (this.check(TokenType.MATCH)) {
|
|
488
|
+
body.push(this.parseJSXMatch());
|
|
467
489
|
} else {
|
|
468
490
|
break;
|
|
469
491
|
}
|
|
@@ -501,4 +523,55 @@ export function installClientParser(ParserClass) {
|
|
|
501
523
|
|
|
502
524
|
return new AST.JSXIf(condition, consequent, alternate, l, alternates);
|
|
503
525
|
};
|
|
526
|
+
|
|
527
|
+
ParserClass.prototype.parseJSXMatch = function() {
|
|
528
|
+
const l = this.loc();
|
|
529
|
+
this.expect(TokenType.MATCH);
|
|
530
|
+
const subject = this.parseExpression();
|
|
531
|
+
this.expect(TokenType.LBRACE, "Expected '{' to open JSX match body");
|
|
532
|
+
|
|
533
|
+
const arms = [];
|
|
534
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
535
|
+
const al = this.loc();
|
|
536
|
+
const pattern = this.parsePattern();
|
|
537
|
+
|
|
538
|
+
let guard = null;
|
|
539
|
+
if (this.match(TokenType.IF)) {
|
|
540
|
+
guard = this.parseExpression();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
this.expect(TokenType.ARROW, "Expected '=>' in JSX match arm");
|
|
544
|
+
|
|
545
|
+
// Parse arm body as JSX children
|
|
546
|
+
const body = [];
|
|
547
|
+
if (this.check(TokenType.LESS)) {
|
|
548
|
+
body.push(this.parseJSXElementOrFragment());
|
|
549
|
+
} else if (this.check(TokenType.STRING) || this.check(TokenType.STRING_TEMPLATE)) {
|
|
550
|
+
body.push(new AST.JSXText(this.parseStringLiteral(), this.loc()));
|
|
551
|
+
} else if (this.check(TokenType.JSX_TEXT)) {
|
|
552
|
+
const tok = this.advance();
|
|
553
|
+
const text = this._collapseJSXWhitespace(tok.value);
|
|
554
|
+
if (text.length > 0) {
|
|
555
|
+
body.push(new AST.JSXText(new AST.StringLiteral(text, this.loc()), this.loc()));
|
|
556
|
+
}
|
|
557
|
+
} else if (this.check(TokenType.LBRACE)) {
|
|
558
|
+
this.advance();
|
|
559
|
+
body.push(new AST.JSXExpression(this.parseExpression(), this.loc()));
|
|
560
|
+
this.expect(TokenType.RBRACE);
|
|
561
|
+
} else if (this.check(TokenType.FOR)) {
|
|
562
|
+
body.push(this.parseJSXFor());
|
|
563
|
+
} else if (this.check(TokenType.IF)) {
|
|
564
|
+
body.push(this.parseJSXIf());
|
|
565
|
+
} else {
|
|
566
|
+
// Fallback to regular expression (e.g., null, number literals)
|
|
567
|
+
body.push(new AST.JSXExpression(this.parseExpression(), this.loc()));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
arms.push({ pattern, guard, body, loc: al });
|
|
571
|
+
this.match(TokenType.COMMA); // Optional comma between arms
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.expect(TokenType.RBRACE, "Expected '}' to close JSX match body");
|
|
575
|
+
return new AST.JSXMatch(subject, arms, l);
|
|
576
|
+
};
|
|
504
577
|
}
|
package/src/parser/parser.js
CHANGED
|
@@ -136,6 +136,20 @@ export class Parser {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Full-stack keywords (route, state, computed, effect, component, store) are contextual —
|
|
140
|
+
// they act as keywords inside server/client blocks but can be used as identifiers elsewhere.
|
|
141
|
+
_isContextualKeyword() {
|
|
142
|
+
const t = this.current().type;
|
|
143
|
+
return t === TokenType.ROUTE || t === TokenType.STATE || t === TokenType.COMPUTED ||
|
|
144
|
+
t === TokenType.EFFECT || t === TokenType.COMPONENT || t === TokenType.STORE;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_isContextualKeywordToken(token) {
|
|
148
|
+
const t = token.type;
|
|
149
|
+
return t === TokenType.ROUTE || t === TokenType.STATE || t === TokenType.COMPUTED ||
|
|
150
|
+
t === TokenType.EFFECT || t === TokenType.COMPONENT || t === TokenType.STORE;
|
|
151
|
+
}
|
|
152
|
+
|
|
139
153
|
_synchronizeBlock() {
|
|
140
154
|
// Don't advance if already at } — that's the block closer we need
|
|
141
155
|
if (!this.isAtEnd() && this.current().type !== TokenType.RBRACE) {
|
|
@@ -550,7 +564,7 @@ export class Parser {
|
|
|
550
564
|
return this.parseForStatement(null, true);
|
|
551
565
|
}
|
|
552
566
|
if (this.check(TokenType.ASYNC) && this.peek(1).type === TokenType.FN) return this.parseAsyncFunctionDeclaration();
|
|
553
|
-
if (this.check(TokenType.FN) && this.peek(1).type === TokenType.IDENTIFIER) return this.parseFunctionDeclaration();
|
|
567
|
+
if (this.check(TokenType.FN) && (this.peek(1).type === TokenType.IDENTIFIER || this._isContextualKeywordToken(this.peek(1)))) return this.parseFunctionDeclaration();
|
|
554
568
|
if (this.check(TokenType.TYPE)) return this.parseTypeDeclaration();
|
|
555
569
|
if (this.check(TokenType.MUT)) this.error("'mut' is not supported in Tova. Use 'var' for mutable variables");
|
|
556
570
|
if (this.check(TokenType.VAR)) return this.parseVarDeclaration();
|
|
@@ -712,7 +726,12 @@ export class Parser {
|
|
|
712
726
|
parseFunctionDeclaration() {
|
|
713
727
|
const l = this.loc();
|
|
714
728
|
this.expect(TokenType.FN);
|
|
715
|
-
|
|
729
|
+
let name;
|
|
730
|
+
if (this._isContextualKeyword()) {
|
|
731
|
+
name = this.advance().value;
|
|
732
|
+
} else {
|
|
733
|
+
name = this.expect(TokenType.IDENTIFIER, "Expected function name").value;
|
|
734
|
+
}
|
|
716
735
|
|
|
717
736
|
// Parse optional type parameters: fn name<T, U>(...)
|
|
718
737
|
let typeParams = [];
|
|
@@ -742,7 +761,12 @@ export class Parser {
|
|
|
742
761
|
const l = this.loc();
|
|
743
762
|
this.expect(TokenType.ASYNC);
|
|
744
763
|
this.expect(TokenType.FN);
|
|
745
|
-
|
|
764
|
+
let name;
|
|
765
|
+
if (this._isContextualKeyword()) {
|
|
766
|
+
name = this.advance().value;
|
|
767
|
+
} else {
|
|
768
|
+
name = this.expect(TokenType.IDENTIFIER, "Expected function name").value;
|
|
769
|
+
}
|
|
746
770
|
|
|
747
771
|
// Parse optional type parameters: async fn name<T, U>(...)
|
|
748
772
|
let typeParams = [];
|
|
@@ -877,7 +901,12 @@ export class Parser {
|
|
|
877
901
|
}
|
|
878
902
|
params.push(param);
|
|
879
903
|
} else {
|
|
880
|
-
|
|
904
|
+
let name;
|
|
905
|
+
if (this._isContextualKeyword()) {
|
|
906
|
+
name = this.advance().value;
|
|
907
|
+
} else {
|
|
908
|
+
name = this.expect(TokenType.IDENTIFIER, "Expected parameter name").value;
|
|
909
|
+
}
|
|
881
910
|
|
|
882
911
|
let typeAnnotation = null;
|
|
883
912
|
if (this.match(TokenType.COLON)) {
|
|
@@ -1947,7 +1976,8 @@ export class Parser {
|
|
|
1947
1976
|
}
|
|
1948
1977
|
|
|
1949
1978
|
// Keywords that can appear as identifiers in expression position
|
|
1950
|
-
if (this.check(TokenType.SERVER) || this.check(TokenType.CLIENT) || this.check(TokenType.SHARED) || this.check(TokenType.DERIVE)
|
|
1979
|
+
if (this.check(TokenType.SERVER) || this.check(TokenType.CLIENT) || this.check(TokenType.SHARED) || this.check(TokenType.DERIVE) ||
|
|
1980
|
+
this._isContextualKeyword()) {
|
|
1951
1981
|
const name = this.advance().value;
|
|
1952
1982
|
return new AST.Identifier(name, l);
|
|
1953
1983
|
}
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-runtime.js — do not edit
|
|
2
|
-
export const VERSION = "0.3.
|
|
2
|
+
export const VERSION = "0.3.3";
|