tova 0.3.2 → 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 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
- // Check if file has .tova imports if so, compile dependencies and inline them
554
- const hasTovaImports = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.tova['"]/m.test(source);
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
- while ((match = importRegex.exec(source)) !== null) {
569
- const depPath = resolve(dirname(resolved), match[1]);
570
- if (compiled.has(depPath)) continue;
571
- compiled.add(depPath);
572
- if (existsSync(depPath)) {
573
- const depSource = readFileSync(depPath, 'utf-8');
574
- const dep = compileTova(depSource, depPath, { strict: options.strict });
575
- let depShared = dep.shared || '';
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 .tova files (already inlined above)
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
- // Use single-file basename for single-file dirs, directory name for multi-file
693
- const outBaseName = single ? basename(files[0], '.tova') : dirName;
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
- // If the last line looks like an expression (doesn't start with const/let/var/function/if/for/while/class)
2537
- if (!/^(const |let |var |function |if |for |while |class |try |switch )/.test(lastLine) && !lastLine.endsWith('{')) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tova",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Tova — a modern programming language that transpiles to JavaScript, unifying frontend and backend",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -36,6 +36,11 @@
36
36
  "url": "https://github.com/tova-lang/tova-lang/issues"
37
37
  },
38
38
  "author": "Enoch Kujem Abassey",
39
- "keywords": ["language", "transpiler", "fullstack", "javascript"],
39
+ "keywords": [
40
+ "language",
41
+ "transpiler",
42
+ "fullstack",
43
+ "javascript"
44
+ ],
40
45
  "license": "MIT"
41
46
  }
@@ -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
- events[eventName] = `(e) => { set${capitalize(exprName)}(e.target.value); }`;
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._usedBuiltins);
25
+ const selectiveStdlib = buildSelectiveStdlib(this.getUsedBuiltins());
26
26
  if (selectiveStdlib) helpers.push(selectiveStdlib);
27
27
  return helpers.join('\n');
28
28
  }
@@ -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
  // ============================================================
@@ -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.check(TokenType.TYPE) || this.check(TokenType.FOR) ||
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.check(TokenType.IN)) {
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
  }
@@ -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
- const name = this.expect(TokenType.IDENTIFIER, "Expected function name").value;
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
- const name = this.expect(TokenType.IDENTIFIER, "Expected function name").value;
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
- const name = this.expect(TokenType.IDENTIFIER, "Expected parameter name").value;
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";
2
+ export const VERSION = "0.3.3";