tova 0.5.1 → 0.8.2

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.
Files changed (60) hide show
  1. package/bin/tova.js +261 -60
  2. package/package.json +1 -1
  3. package/src/analyzer/analyzer.js +351 -11
  4. package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
  5. package/src/analyzer/deploy-analyzer.js +44 -0
  6. package/src/analyzer/form-analyzer.js +113 -0
  7. package/src/analyzer/scope.js +2 -2
  8. package/src/codegen/base-codegen.js +1160 -10
  9. package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
  10. package/src/codegen/codegen.js +119 -28
  11. package/src/codegen/deploy-codegen.js +49 -0
  12. package/src/codegen/edge-codegen.js +1351 -0
  13. package/src/codegen/form-codegen.js +553 -0
  14. package/src/codegen/security-codegen.js +5 -5
  15. package/src/codegen/server-codegen.js +88 -7
  16. package/src/codegen/shared-codegen.js +5 -0
  17. package/src/codegen/wasm-codegen.js +6 -0
  18. package/src/config/edit-toml.js +6 -2
  19. package/src/config/git-resolver.js +128 -0
  20. package/src/config/lock-file.js +57 -0
  21. package/src/config/module-cache.js +58 -0
  22. package/src/config/module-entry.js +37 -0
  23. package/src/config/module-path.js +31 -0
  24. package/src/config/pkg-errors.js +62 -0
  25. package/src/config/resolve.js +17 -0
  26. package/src/config/resolver.js +139 -0
  27. package/src/config/search.js +28 -0
  28. package/src/config/semver.js +72 -0
  29. package/src/config/toml.js +48 -5
  30. package/src/deploy/deploy.js +217 -0
  31. package/src/deploy/infer.js +218 -0
  32. package/src/deploy/provision.js +311 -0
  33. package/src/diagnostics/error-codes.js +1 -1
  34. package/src/docs/generator.js +1 -1
  35. package/src/formatter/formatter.js +4 -4
  36. package/src/lexer/tokens.js +12 -2
  37. package/src/lsp/server.js +483 -1
  38. package/src/parser/ast.js +60 -5
  39. package/src/parser/{client-ast.js → browser-ast.js} +3 -3
  40. package/src/parser/{client-parser.js → browser-parser.js} +42 -15
  41. package/src/parser/concurrency-ast.js +15 -0
  42. package/src/parser/concurrency-parser.js +236 -0
  43. package/src/parser/deploy-ast.js +37 -0
  44. package/src/parser/deploy-parser.js +132 -0
  45. package/src/parser/edge-ast.js +83 -0
  46. package/src/parser/edge-parser.js +262 -0
  47. package/src/parser/form-ast.js +80 -0
  48. package/src/parser/form-parser.js +206 -0
  49. package/src/parser/parser.js +82 -14
  50. package/src/parser/select-ast.js +39 -0
  51. package/src/registry/plugins/browser-plugin.js +30 -0
  52. package/src/registry/plugins/concurrency-plugin.js +32 -0
  53. package/src/registry/plugins/deploy-plugin.js +33 -0
  54. package/src/registry/plugins/edge-plugin.js +32 -0
  55. package/src/registry/register-all.js +8 -2
  56. package/src/runtime/ssr.js +2 -2
  57. package/src/stdlib/inline.js +38 -6
  58. package/src/stdlib/runtime-bridge.js +152 -0
  59. package/src/version.js +1 -1
  60. package/src/registry/plugins/client-plugin.js +0 -30
@@ -97,7 +97,7 @@ function levenshtein(a, b) {
97
97
 
98
98
  const _TOVA_RUNTIME = new Set([
99
99
  'Ok', 'Err', 'Some', 'None', 'Result', 'Option',
100
- 'db', 'server', 'client', 'shared',
100
+ 'db', 'server', 'browser', 'client', 'shared',
101
101
  ]);
102
102
 
103
103
  // Pre-built static candidate set for Levenshtein suggestions (N1 optimization)
@@ -776,9 +776,9 @@ export class Analyzer {
776
776
  return this[methodName](node);
777
777
  }
778
778
 
779
- _visitClientNode(node) {
780
- // Ensure client analyzer is installed (may be called from visitExpression for JSX)
781
- const plugin = BlockRegistry.get('client');
779
+ _visitBrowserNode(node) {
780
+ // Ensure browser analyzer is installed (may be called from visitExpression for JSX)
781
+ const plugin = BlockRegistry.get('browser');
782
782
  return plugin.analyzer.visit(this, node);
783
783
  }
784
784
 
@@ -900,6 +900,19 @@ export class Analyzer {
900
900
  }
901
901
  this.visitExpression(node.argument);
902
902
  return;
903
+ case 'SpawnExpression':
904
+ if (!this._concurrentDepth) {
905
+ this.warn("'spawn' should be used inside a 'concurrent' block", node.loc, null, {
906
+ code: 'W_SPAWN_OUTSIDE_CONCURRENT',
907
+ });
908
+ }
909
+ if (node.callee) this.visitExpression(node.callee);
910
+ if (node.arguments) {
911
+ for (const arg of node.arguments) {
912
+ this.visitExpression(arg);
913
+ }
914
+ }
915
+ return;
903
916
  case 'YieldExpression':
904
917
  if (node.argument) this.visitExpression(node.argument);
905
918
  return;
@@ -917,7 +930,7 @@ export class Analyzer {
917
930
  return;
918
931
  case 'JSXElement':
919
932
  case 'JSXFragment':
920
- return this._visitClientNode(node);
933
+ return this._visitBrowserNode(node);
921
934
  // Column expressions (for table operations) — no semantic analysis needed
922
935
  case 'ColumnExpression':
923
936
  return;
@@ -1019,6 +1032,123 @@ export class Analyzer {
1019
1032
  }
1020
1033
  }
1021
1034
 
1035
+ visitConcurrentBlock(node) {
1036
+ // Validate mode
1037
+ const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
1038
+ if (!validModes.has(node.mode)) {
1039
+ this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
1040
+ code: 'W_UNKNOWN_CONCURRENT_MODE',
1041
+ });
1042
+ }
1043
+
1044
+ // Validate timeout
1045
+ if (node.mode === 'timeout' && !node.timeout) {
1046
+ this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
1047
+ code: 'W_MISSING_TIMEOUT',
1048
+ });
1049
+ }
1050
+
1051
+ // Warn on empty block
1052
+ if (node.body.length === 0) {
1053
+ this.warn("Empty concurrent block", node.loc, null, {
1054
+ code: 'W_EMPTY_CONCURRENT',
1055
+ });
1056
+ }
1057
+
1058
+ // Track concurrent depth for spawn validation
1059
+ this._concurrentDepth = (this._concurrentDepth || 0) + 1;
1060
+
1061
+ // Visit body statements (concurrent block does NOT create a new scope —
1062
+ // variables assigned inside should be visible after the block)
1063
+ for (const stmt of node.body) {
1064
+ this.visitNode(stmt);
1065
+ }
1066
+
1067
+ // Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
1068
+ let hasWasm = false;
1069
+ let hasNonWasm = false;
1070
+ for (const stmt of node.body) {
1071
+ const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
1072
+ ? stmt.values[0]
1073
+ : (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
1074
+ ? stmt.expression
1075
+ : null;
1076
+ if (!spawn) continue;
1077
+ const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
1078
+ if (calleeName) {
1079
+ const sym = this.currentScope.lookup(calleeName);
1080
+ if (sym && sym.isWasm) {
1081
+ hasWasm = true;
1082
+ } else {
1083
+ hasNonWasm = true;
1084
+ }
1085
+ } else {
1086
+ // Lambda or complex expression — always non-WASM
1087
+ hasNonWasm = true;
1088
+ }
1089
+ }
1090
+ if (hasWasm && hasNonWasm) {
1091
+ this.warn(
1092
+ "concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
1093
+ node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
1094
+ );
1095
+ }
1096
+
1097
+ this._concurrentDepth--;
1098
+ }
1099
+
1100
+ visitSelectStatement(node) {
1101
+ if (node.cases.length === 0) {
1102
+ this.warn("Empty select block", node.loc, null, {
1103
+ code: 'W_EMPTY_SELECT',
1104
+ });
1105
+ }
1106
+
1107
+ let defaultCount = 0;
1108
+ let timeoutCount = 0;
1109
+ for (const c of node.cases) {
1110
+ if (c.kind === 'default') defaultCount++;
1111
+ if (c.kind === 'timeout') timeoutCount++;
1112
+ }
1113
+
1114
+ if (defaultCount > 1) {
1115
+ this.warn("select block has multiple default cases", node.loc, null, {
1116
+ code: 'W_DUPLICATE_SELECT_DEFAULT',
1117
+ });
1118
+ }
1119
+ if (timeoutCount > 1) {
1120
+ this.warn("select block has multiple timeout cases", node.loc, null, {
1121
+ code: 'W_DUPLICATE_SELECT_TIMEOUT',
1122
+ });
1123
+ }
1124
+ if (defaultCount > 0 && timeoutCount > 0) {
1125
+ this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
1126
+ code: 'W_SELECT_DEFAULT_TIMEOUT',
1127
+ });
1128
+ }
1129
+
1130
+ // Visit each case's expressions and body
1131
+ for (const c of node.cases) {
1132
+ if (c.channel) this.visitNode(c.channel);
1133
+ if (c.value) this.visitNode(c.value);
1134
+
1135
+ if (c.kind === 'receive' && c.binding) {
1136
+ // Create scope for the binding variable
1137
+ this.pushScope('select-case');
1138
+ this.currentScope.define(c.binding,
1139
+ new Symbol(c.binding, 'variable', null, false, c.loc));
1140
+ for (const stmt of c.body) {
1141
+ this.visitNode(stmt);
1142
+ }
1143
+ this.popScope();
1144
+ } else {
1145
+ for (const stmt of c.body) {
1146
+ this.visitNode(stmt);
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+
1022
1152
  _validateCliCrossBlock() {
1023
1153
  const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
1024
1154
  if (cliBlocks.length === 0) return;
@@ -1049,6 +1179,214 @@ export class Analyzer {
1049
1179
  }
1050
1180
  }
1051
1181
 
1182
+ visitEdgeBlock(node) {
1183
+ const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
1184
+ const validConfigKeys = new Set(['target']);
1185
+ const bindingNames = new Set();
1186
+
1187
+ // Binding support matrix per target
1188
+ const BINDING_SUPPORT = {
1189
+ cloudflare: { kv: true, sql: true, storage: true, queue: true },
1190
+ deno: { kv: true, sql: false, storage: false, queue: false },
1191
+ vercel: { kv: false, sql: false, storage: false, queue: false },
1192
+ lambda: { kv: false, sql: false, storage: false, queue: false },
1193
+ bun: { kv: false, sql: true, storage: false, queue: false },
1194
+ };
1195
+
1196
+ // Targets that support schedule/consume/middleware
1197
+ const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
1198
+ const CONSUME_TARGETS = new Set(['cloudflare']);
1199
+
1200
+ // Determine target from config fields
1201
+ let target = 'cloudflare';
1202
+ for (const stmt of node.body) {
1203
+ if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1204
+ target = stmt.value.value;
1205
+ }
1206
+ }
1207
+
1208
+ this.pushScope('edge');
1209
+
1210
+ let kvCount = 0;
1211
+ const queueNames = new Set();
1212
+ const consumers = [];
1213
+
1214
+ for (const stmt of node.body) {
1215
+ // Validate config fields
1216
+ if (stmt.type === 'EdgeConfigField') {
1217
+ if (!validConfigKeys.has(stmt.key)) {
1218
+ this.warnings.push({
1219
+ message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
1220
+ loc: stmt.loc,
1221
+ code: 'W_UNKNOWN_EDGE_CONFIG',
1222
+ });
1223
+ }
1224
+ if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
1225
+ if (!validTargets.has(stmt.value.value)) {
1226
+ this.warnings.push({
1227
+ message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
1228
+ loc: stmt.loc,
1229
+ code: 'W_UNKNOWN_EDGE_TARGET',
1230
+ });
1231
+ }
1232
+ }
1233
+ continue;
1234
+ }
1235
+
1236
+ // Check for duplicate binding names
1237
+ if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
1238
+ stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
1239
+ if (bindingNames.has(stmt.name)) {
1240
+ this.warnings.push({
1241
+ message: `Duplicate edge binding '${stmt.name}'`,
1242
+ loc: stmt.loc,
1243
+ code: 'W_DUPLICATE_EDGE_BINDING',
1244
+ });
1245
+ }
1246
+ bindingNames.add(stmt.name);
1247
+ }
1248
+
1249
+ // Track queue names for consume validation
1250
+ if (stmt.type === 'EdgeQueueDeclaration') {
1251
+ queueNames.add(stmt.name);
1252
+ }
1253
+
1254
+ // Check for duplicate env/secret names
1255
+ if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
1256
+ if (bindingNames.has(stmt.name)) {
1257
+ this.warnings.push({
1258
+ message: `Duplicate edge binding '${stmt.name}'`,
1259
+ loc: stmt.loc,
1260
+ code: 'W_DUPLICATE_EDGE_BINDING',
1261
+ });
1262
+ }
1263
+ bindingNames.add(stmt.name);
1264
+ }
1265
+
1266
+ // Unsupported binding warnings (per target)
1267
+ const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
1268
+ if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
1269
+ this.warnings.push({
1270
+ message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1271
+ loc: stmt.loc,
1272
+ code: 'W_UNSUPPORTED_KV',
1273
+ });
1274
+ }
1275
+ if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
1276
+ this.warnings.push({
1277
+ message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1278
+ loc: stmt.loc,
1279
+ code: 'W_UNSUPPORTED_SQL',
1280
+ });
1281
+ }
1282
+ if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
1283
+ this.warnings.push({
1284
+ message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1285
+ loc: stmt.loc,
1286
+ code: 'W_UNSUPPORTED_STORAGE',
1287
+ });
1288
+ }
1289
+ if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
1290
+ this.warnings.push({
1291
+ message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
1292
+ loc: stmt.loc,
1293
+ code: 'W_UNSUPPORTED_QUEUE',
1294
+ });
1295
+ }
1296
+
1297
+ // Deno multi-KV warning
1298
+ if (stmt.type === 'EdgeKVDeclaration') {
1299
+ kvCount++;
1300
+ if (kvCount > 1 && target === 'deno') {
1301
+ this.warnings.push({
1302
+ message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
1303
+ loc: stmt.loc,
1304
+ code: 'W_DENO_MULTI_KV',
1305
+ });
1306
+ }
1307
+ }
1308
+
1309
+ // Validate schedule cron expressions + target support
1310
+ if (stmt.type === 'EdgeScheduleDeclaration') {
1311
+ const parts = stmt.cron.split(/\s+/);
1312
+ if (parts.length < 5 || parts.length > 6) {
1313
+ this.warnings.push({
1314
+ message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
1315
+ loc: stmt.loc,
1316
+ code: 'W_INVALID_CRON',
1317
+ });
1318
+ }
1319
+ if (!SCHEDULE_TARGETS.has(target)) {
1320
+ this.warnings.push({
1321
+ message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
1322
+ loc: stmt.loc,
1323
+ code: 'W_UNSUPPORTED_SCHEDULE',
1324
+ });
1325
+ }
1326
+ }
1327
+
1328
+ // Collect consume declarations for post-loop validation
1329
+ if (stmt.type === 'EdgeConsumeDeclaration') {
1330
+ consumers.push(stmt);
1331
+ if (!CONSUME_TARGETS.has(target)) {
1332
+ this.warnings.push({
1333
+ message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
1334
+ loc: stmt.loc,
1335
+ code: 'W_UNSUPPORTED_CONSUME',
1336
+ });
1337
+ }
1338
+ }
1339
+
1340
+ // Visit child nodes — edge-specific types are noop in the registry,
1341
+ // so explicitly visit bodies that contain statements
1342
+ if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
1343
+ for (const s of stmt.body.body || []) this.visitNode(s);
1344
+ } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
1345
+ this.visitNode(stmt);
1346
+ }
1347
+ }
1348
+
1349
+ // Post-loop: validate consume references a declared queue
1350
+ for (const consumer of consumers) {
1351
+ if (!queueNames.has(consumer.queue)) {
1352
+ this.warnings.push({
1353
+ message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
1354
+ loc: consumer.loc,
1355
+ code: 'W_CONSUME_UNKNOWN_QUEUE',
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ // Warn if edge block has no route or schedule handlers
1361
+ const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
1362
+ const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
1363
+ const hasConsumers = consumers.length > 0;
1364
+ if (!hasRoutes && !hasSchedules && !hasConsumers) {
1365
+ this.warnings.push({
1366
+ message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
1367
+ loc: node.loc,
1368
+ code: 'W_EDGE_NO_HANDLERS',
1369
+ });
1370
+ }
1371
+
1372
+ this.popScope();
1373
+ }
1374
+
1375
+ _validateEdgeCrossBlock() {
1376
+ const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
1377
+ if (edgeBlocks.length === 0) return;
1378
+
1379
+ // Warn if edge + cli coexist (cli takes over with earlyReturn)
1380
+ const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
1381
+ if (hasCli) {
1382
+ this.warnings.push({
1383
+ message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
1384
+ loc: edgeBlocks[0].loc,
1385
+ code: 'W_EDGE_WITH_CLI',
1386
+ });
1387
+ }
1388
+ }
1389
+
1052
1390
  _validateSecurityCrossBlock() {
1053
1391
  // Collect ALL security declarations across ALL security blocks in the AST
1054
1392
  const allRoles = new Set();
@@ -1287,7 +1625,7 @@ export class Analyzer {
1287
1625
  }
1288
1626
  }
1289
1627
 
1290
- // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
1628
+ // visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
1291
1629
 
1292
1630
  visitSharedBlock(node) {
1293
1631
  const prevScope = this.currentScope;
@@ -1406,9 +1744,10 @@ export class Analyzer {
1406
1744
  } else if (node.pattern.type === 'ArrayPattern' || node.pattern.type === 'TuplePattern') {
1407
1745
  for (const el of node.pattern.elements) {
1408
1746
  if (el) {
1747
+ const varName = el.startsWith('...') ? el.slice(3) : el;
1409
1748
  try {
1410
- this.currentScope.define(el,
1411
- new Symbol(el, 'variable', null, false, node.loc));
1749
+ this.currentScope.define(varName,
1750
+ new Symbol(varName, 'variable', null, false, node.loc));
1412
1751
  } catch (e) {
1413
1752
  this.error(e.message);
1414
1753
  }
@@ -1426,6 +1765,7 @@ export class Analyzer {
1426
1765
  sym._paramTypes = node.params.map(p => p.typeAnnotation || null);
1427
1766
  sym._typeParams = node.typeParams || [];
1428
1767
  sym.isPublic = node.isPublic || false;
1768
+ sym.isWasm = !!(node.decorators && node.decorators.some(d => d.name === 'wasm'));
1429
1769
  this.currentScope.define(node.name, sym);
1430
1770
  } catch (e) {
1431
1771
  this.error(e.message);
@@ -1987,7 +2327,7 @@ export class Analyzer {
1987
2327
  this.visitExpression(node.value);
1988
2328
  }
1989
2329
 
1990
- // Client-specific visitors (visitState, visitComputed, etc.) are in client-analyzer.js (lazy-loaded)
2330
+ // Browser-specific visitors (visitState, visitComputed, etc.) are in browser-analyzer.js (lazy-loaded)
1991
2331
 
1992
2332
  visitTestBlock(node) {
1993
2333
  const prevScope = this.currentScope;
@@ -2352,7 +2692,7 @@ export class Analyzer {
2352
2692
  candidates.push([node.name, typeVariants]);
2353
2693
  }
2354
2694
  }
2355
- if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
2695
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
2356
2696
  this._collectTypeCandidates(node.body, coveredVariants, candidates);
2357
2697
  }
2358
2698
  }
@@ -2447,7 +2787,7 @@ export class Analyzer {
2447
2787
  }
2448
2788
  }
2449
2789
 
2450
- // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in client-analyzer.js (lazy-loaded)
2790
+ // visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in browser-analyzer.js (lazy-loaded)
2451
2791
 
2452
2792
  // ─── New feature visitors ─────────────────────────────────
2453
2793
 
@@ -1,15 +1,18 @@
1
- // Client-specific analyzer methods for the Tova language
2
- // Extracted from analyzer.js for lazy loading — only loaded when client { } blocks are encountered.
1
+ // Browser-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when browser { } blocks are encountered.
3
3
 
4
4
  import { Symbol } from './scope.js';
5
+ import { installFormAnalyzer } from './form-analyzer.js';
5
6
 
6
- export function installClientAnalyzer(AnalyzerClass) {
7
- if (AnalyzerClass.prototype._clientAnalyzerInstalled) return;
8
- AnalyzerClass.prototype._clientAnalyzerInstalled = true;
7
+ export function installBrowserAnalyzer(AnalyzerClass) {
8
+ if (AnalyzerClass.prototype._browserAnalyzerInstalled) return;
9
+ AnalyzerClass.prototype._browserAnalyzerInstalled = true;
9
10
 
10
- AnalyzerClass.prototype.visitClientBlock = function(node) {
11
+ installFormAnalyzer(AnalyzerClass);
12
+
13
+ AnalyzerClass.prototype.visitBrowserBlock = function(node) {
11
14
  const prevScope = this.currentScope;
12
- this.currentScope = this.currentScope.child('client');
15
+ this.currentScope = this.currentScope.child('browser');
13
16
  try {
14
17
  for (const stmt of node.body) {
15
18
  this.visitNode(stmt);
@@ -21,8 +24,8 @@ export function installClientAnalyzer(AnalyzerClass) {
21
24
 
22
25
  AnalyzerClass.prototype.visitStateDeclaration = function(node) {
23
26
  const ctx = this.currentScope.getContext();
24
- if (ctx !== 'client') {
25
- this.error(`'state' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
27
+ if (ctx !== 'browser') {
28
+ this.error(`'state' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
26
29
  }
27
30
  try {
28
31
  this.currentScope.define(node.name,
@@ -35,8 +38,8 @@ export function installClientAnalyzer(AnalyzerClass) {
35
38
 
36
39
  AnalyzerClass.prototype.visitComputedDeclaration = function(node) {
37
40
  const ctx = this.currentScope.getContext();
38
- if (ctx !== 'client') {
39
- this.error(`'computed' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
41
+ if (ctx !== 'browser') {
42
+ this.error(`'computed' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
40
43
  }
41
44
  try {
42
45
  this.currentScope.define(node.name,
@@ -49,16 +52,16 @@ export function installClientAnalyzer(AnalyzerClass) {
49
52
 
50
53
  AnalyzerClass.prototype.visitEffectDeclaration = function(node) {
51
54
  const ctx = this.currentScope.getContext();
52
- if (ctx !== 'client') {
53
- this.error(`'effect' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
55
+ if (ctx !== 'browser') {
56
+ this.error(`'effect' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
54
57
  }
55
58
  this.visitNode(node.body);
56
59
  };
57
60
 
58
61
  AnalyzerClass.prototype.visitComponentDeclaration = function(node) {
59
62
  const ctx = this.currentScope.getContext();
60
- if (ctx !== 'client') {
61
- this.error(`'component' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
63
+ if (ctx !== 'browser') {
64
+ this.error(`'component' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
62
65
  }
63
66
  this._checkNamingConvention(node.name, 'component', node.loc);
64
67
  try {
@@ -89,8 +92,8 @@ export function installClientAnalyzer(AnalyzerClass) {
89
92
 
90
93
  AnalyzerClass.prototype.visitStoreDeclaration = function(node) {
91
94
  const ctx = this.currentScope.getContext();
92
- if (ctx !== 'client') {
93
- this.error(`'store' can only be used inside a client block`, node.loc, "move this inside a client { } block", { code: 'E302' });
95
+ if (ctx !== 'browser') {
96
+ this.error(`'store' can only be used inside a browser block`, node.loc, "move this inside a browser { } block", { code: 'E302' });
94
97
  }
95
98
  this._checkNamingConvention(node.name, 'store', node.loc);
96
99
  try {
@@ -0,0 +1,44 @@
1
+ // Deploy-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when deploy { } blocks are encountered.
3
+
4
+ const KNOWN_DEPLOY_FIELDS = new Set([
5
+ 'server', 'domain', 'instances', 'memory', 'branch',
6
+ 'health', 'health_interval', 'health_timeout',
7
+ 'restart_on_failure', 'keep_releases',
8
+ ]);
9
+
10
+ const REQUIRED_DEPLOY_FIELDS = ['server', 'domain'];
11
+
12
+ export function installDeployAnalyzer(AnalyzerClass) {
13
+ if (AnalyzerClass.prototype._deployAnalyzerInstalled) return;
14
+ AnalyzerClass.prototype._deployAnalyzerInstalled = true;
15
+
16
+ AnalyzerClass.prototype.visitDeployBlock = function(node) {
17
+ // Collect config field keys present in the deploy block body
18
+ const presentFields = new Set();
19
+ for (const stmt of node.body) {
20
+ if (stmt.type === 'DeployConfigField') {
21
+ // Validate unknown fields
22
+ if (!KNOWN_DEPLOY_FIELDS.has(stmt.key)) {
23
+ this.error(
24
+ `Unknown deploy config field "${stmt.key}"`,
25
+ stmt.loc,
26
+ `Known fields: ${[...KNOWN_DEPLOY_FIELDS].join(', ')}`
27
+ );
28
+ }
29
+ presentFields.add(stmt.key);
30
+ }
31
+ // DeployEnvBlock and DeployDbBlock are valid sub-blocks — no additional validation needed
32
+ }
33
+
34
+ // Validate required fields
35
+ for (const required of REQUIRED_DEPLOY_FIELDS) {
36
+ if (!presentFields.has(required)) {
37
+ this.error(
38
+ `Deploy block "${node.name}" is missing required field "${required}"`,
39
+ node.loc
40
+ );
41
+ }
42
+ }
43
+ };
44
+ }
@@ -0,0 +1,113 @@
1
+ // Form-specific analyzer methods for the Tova language
2
+ // Extracted for lazy loading — only loaded when form { } blocks are encountered.
3
+
4
+ import { Symbol } from './scope.js';
5
+
6
+ export function installFormAnalyzer(AnalyzerClass) {
7
+ if (AnalyzerClass.prototype._formAnalyzerInstalled) return;
8
+ AnalyzerClass.prototype._formAnalyzerInstalled = true;
9
+
10
+ const KNOWN_VALIDATORS = new Set([
11
+ 'required', 'minLength', 'maxLength', 'min', 'max',
12
+ 'pattern', 'email', 'matches', 'oneOf', 'validate',
13
+ ]);
14
+
15
+ AnalyzerClass.prototype.visitFormDeclaration = function(node) {
16
+ const ctx = this.currentScope.getContext();
17
+ if (ctx !== 'browser') {
18
+ this.error(`'form' can only be used inside a browser block or component`, node.loc,
19
+ "move this inside a browser { } block", { code: 'E310' });
20
+ }
21
+
22
+ try {
23
+ this.currentScope.define(node.name,
24
+ new Symbol(node.name, 'form', node.typeAnnotation, false, node.loc));
25
+ } catch (e) {
26
+ this.error(e.message);
27
+ }
28
+
29
+ const prevScope = this.currentScope;
30
+ this.currentScope = this.currentScope.child('form');
31
+ try {
32
+ for (const field of node.fields) { this._visitFormField(field); }
33
+ for (const group of node.groups) { this._visitFormGroup(group); }
34
+ for (const arr of node.arrays) { this._visitFormArray(arr); }
35
+ for (const comp of node.computeds) { this.visitNode(comp); }
36
+ if (node.steps) { this._visitFormSteps(node, node.steps); }
37
+ if (node.onSubmit) { this.visitNode(node.onSubmit); }
38
+ } finally {
39
+ this.currentScope = prevScope;
40
+ }
41
+ };
42
+
43
+ AnalyzerClass.prototype._visitFormField = function(node) {
44
+ try {
45
+ this.currentScope.define(node.name,
46
+ new Symbol(node.name, 'formField', node.typeAnnotation, false, node.loc));
47
+ } catch (e) {
48
+ this.error(e.message);
49
+ }
50
+ if (node.initialValue) {
51
+ this.visitExpression(node.initialValue);
52
+ }
53
+ for (const v of node.validators) {
54
+ if (!KNOWN_VALIDATORS.has(v.name)) {
55
+ this.warn(`Unknown validator '${v.name}'`, v.loc, null, { code: 'W_UNKNOWN_VALIDATOR' });
56
+ }
57
+ for (const arg of v.args) {
58
+ this.visitExpression(arg);
59
+ }
60
+ }
61
+ };
62
+
63
+ AnalyzerClass.prototype._visitFormGroup = function(node) {
64
+ try {
65
+ this.currentScope.define(node.name,
66
+ new Symbol(node.name, 'formGroup', null, false, node.loc));
67
+ } catch (e) {
68
+ this.error(e.message);
69
+ }
70
+ if (node.condition) {
71
+ this.visitExpression(node.condition);
72
+ }
73
+ const prevScope = this.currentScope;
74
+ this.currentScope = this.currentScope.child('block');
75
+ try {
76
+ for (const field of node.fields) { this._visitFormField(field); }
77
+ for (const group of node.groups) { this._visitFormGroup(group); }
78
+ } finally {
79
+ this.currentScope = prevScope;
80
+ }
81
+ };
82
+
83
+ AnalyzerClass.prototype._visitFormArray = function(node) {
84
+ try {
85
+ this.currentScope.define(node.name,
86
+ new Symbol(node.name, 'formArray', null, false, node.loc));
87
+ } catch (e) {
88
+ this.error(e.message);
89
+ }
90
+ const prevScope = this.currentScope;
91
+ this.currentScope = this.currentScope.child('block');
92
+ try {
93
+ for (const field of node.fields) { this._visitFormField(field); }
94
+ } finally {
95
+ this.currentScope = prevScope;
96
+ }
97
+ };
98
+
99
+ AnalyzerClass.prototype._visitFormSteps = function(formNode, stepsNode) {
100
+ const knownMembers = new Set();
101
+ for (const f of formNode.fields) knownMembers.add(f.name);
102
+ for (const g of formNode.groups) knownMembers.add(g.name);
103
+ for (const a of formNode.arrays) knownMembers.add(a.name);
104
+
105
+ for (const step of stepsNode.steps) {
106
+ for (const member of step.members) {
107
+ if (!knownMembers.has(member)) {
108
+ this.warn(`Step '${step.label}' references unknown member '${member}'`, step.loc, null, { code: 'W_STEP_UNKNOWN_MEMBER' });
109
+ }
110
+ }
111
+ }
112
+ };
113
+ }