tova 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tova.js +69 -50
- package/package.json +7 -2
- package/src/analyzer/analyzer.js +217 -9
- package/src/analyzer/{client-analyzer.js → browser-analyzer.js} +20 -17
- package/src/analyzer/form-analyzer.js +113 -0
- package/src/analyzer/scope.js +2 -2
- package/src/codegen/base-codegen.js +1 -0
- package/src/codegen/{client-codegen.js → browser-codegen.js} +444 -5
- package/src/codegen/codegen.js +99 -28
- package/src/codegen/edge-codegen.js +1351 -0
- package/src/codegen/form-codegen.js +553 -0
- package/src/codegen/security-codegen.js +5 -5
- package/src/codegen/server-codegen.js +88 -7
- package/src/diagnostics/error-codes.js +1 -1
- package/src/docs/generator.js +1 -1
- package/src/formatter/formatter.js +4 -4
- package/src/lexer/tokens.js +12 -2
- package/src/lsp/server.js +1 -1
- package/src/parser/ast.js +36 -5
- package/src/parser/{client-ast.js → browser-ast.js} +3 -3
- package/src/parser/{client-parser.js → browser-parser.js} +42 -15
- package/src/parser/edge-ast.js +83 -0
- package/src/parser/edge-parser.js +262 -0
- package/src/parser/form-ast.js +80 -0
- package/src/parser/form-parser.js +206 -0
- package/src/parser/parser.js +61 -11
- package/src/registry/plugins/browser-plugin.js +30 -0
- package/src/registry/plugins/edge-plugin.js +32 -0
- package/src/registry/register-all.js +4 -2
- package/src/runtime/ssr.js +2 -2
- package/src/stdlib/inline.js +3 -3
- package/src/version.js +1 -1
- package/src/registry/plugins/client-plugin.js +0 -30
package/bin/tova.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { resolve, basename, dirname, join, relative } from 'path';
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync, chmodSync, renameSync, watch as fsWatch } from 'fs';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
|
-
|
|
6
|
+
import { createHash as _cryptoHash } 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';
|
|
@@ -628,7 +628,7 @@ async function runFile(filePath, options = {}) {
|
|
|
628
628
|
}
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.
|
|
631
|
+
let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.browser || '');
|
|
632
632
|
// Strip 'export ' keywords — not valid inside AsyncFunction (used in tova build only)
|
|
633
633
|
code = code.replace(/^export /gm, '');
|
|
634
634
|
// Strip import lines for local modules (already inlined above)
|
|
@@ -833,11 +833,18 @@ async function buildProject(args) {
|
|
|
833
833
|
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
|
|
834
834
|
}
|
|
835
835
|
|
|
836
|
-
// Write default
|
|
837
|
-
if (output.
|
|
838
|
-
const
|
|
839
|
-
writeFileSync(
|
|
840
|
-
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.',
|
|
836
|
+
// Write default browser
|
|
837
|
+
if (output.browser) {
|
|
838
|
+
const browserPath = join(outDir, `${outBaseName}.browser.js`);
|
|
839
|
+
writeFileSync(browserPath, generateSourceMap(output.browser, browserPath));
|
|
840
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Write default edge
|
|
844
|
+
if (output.edge) {
|
|
845
|
+
const edgePath = join(outDir, `${outBaseName}.edge.js`);
|
|
846
|
+
writeFileSync(edgePath, generateSourceMap(output.edge, edgePath));
|
|
847
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
|
|
841
848
|
}
|
|
842
849
|
|
|
843
850
|
// Write named server blocks (multi-block)
|
|
@@ -850,13 +857,23 @@ async function buildProject(args) {
|
|
|
850
857
|
}
|
|
851
858
|
}
|
|
852
859
|
|
|
853
|
-
// Write named
|
|
854
|
-
if (output.multiBlock && output.
|
|
855
|
-
for (const [name, code] of Object.entries(output.
|
|
860
|
+
// Write named edge blocks (multi-block)
|
|
861
|
+
if (output.multiBlock && output.edges) {
|
|
862
|
+
for (const [name, code] of Object.entries(output.edges)) {
|
|
863
|
+
if (name === 'default') continue;
|
|
864
|
+
const path = join(outDir, `${outBaseName}.edge.${name}.js`);
|
|
865
|
+
writeFileSync(path, code);
|
|
866
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Write named browser blocks (multi-block)
|
|
871
|
+
if (output.multiBlock && output.browsers) {
|
|
872
|
+
for (const [name, code] of Object.entries(output.browsers)) {
|
|
856
873
|
if (name === 'default') continue;
|
|
857
|
-
const path = join(outDir, `${outBaseName}.
|
|
874
|
+
const path = join(outDir, `${outBaseName}.browser.${name}.js`);
|
|
858
875
|
writeFileSync(path, code);
|
|
859
|
-
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [
|
|
876
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
|
|
860
877
|
}
|
|
861
878
|
}
|
|
862
879
|
|
|
@@ -865,7 +882,7 @@ async function buildProject(args) {
|
|
|
865
882
|
const outputPaths = {};
|
|
866
883
|
if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
|
|
867
884
|
if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
|
|
868
|
-
if (output.
|
|
885
|
+
if (output.browser) outputPaths.browser = join(outDir, `${outBaseName}.browser.js`);
|
|
869
886
|
if (single) {
|
|
870
887
|
const absFile = files[0];
|
|
871
888
|
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
@@ -1116,10 +1133,10 @@ async function devServer(args) {
|
|
|
1116
1133
|
writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
|
|
1117
1134
|
}
|
|
1118
1135
|
|
|
1119
|
-
if (output.
|
|
1120
|
-
const p = join(outDir, `${outBaseName}.
|
|
1121
|
-
writeFileSync(p, output.
|
|
1122
|
-
clientHTML = await generateDevHTML(output.
|
|
1136
|
+
if (output.browser) {
|
|
1137
|
+
const p = join(outDir, `${outBaseName}.browser.js`);
|
|
1138
|
+
writeFileSync(p, output.browser);
|
|
1139
|
+
clientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
|
|
1123
1140
|
writeFileSync(join(outDir, 'index.html'), clientHTML);
|
|
1124
1141
|
hasClient = true;
|
|
1125
1142
|
}
|
|
@@ -1150,10 +1167,10 @@ async function devServer(args) {
|
|
|
1150
1167
|
}
|
|
1151
1168
|
}
|
|
1152
1169
|
|
|
1153
|
-
if (output.multiBlock && output.
|
|
1154
|
-
for (const [name, code] of Object.entries(output.
|
|
1170
|
+
if (output.multiBlock && output.browsers) {
|
|
1171
|
+
for (const [name, code] of Object.entries(output.browsers)) {
|
|
1155
1172
|
if (name === 'default') continue;
|
|
1156
|
-
const p = join(outDir, `${outBaseName}.
|
|
1173
|
+
const p = join(outDir, `${outBaseName}.browser.${name}.js`);
|
|
1157
1174
|
writeFileSync(p, code);
|
|
1158
1175
|
}
|
|
1159
1176
|
}
|
|
@@ -1264,9 +1281,9 @@ async function devServer(args) {
|
|
|
1264
1281
|
if (output.shared && output.shared.trim()) {
|
|
1265
1282
|
writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
|
|
1266
1283
|
}
|
|
1267
|
-
if (output.
|
|
1268
|
-
writeFileSync(join(outDir, `${outBaseName}.
|
|
1269
|
-
rebuildClientHTML = await generateDevHTML(output.
|
|
1284
|
+
if (output.browser) {
|
|
1285
|
+
writeFileSync(join(outDir, `${outBaseName}.browser.js`), output.browser);
|
|
1286
|
+
rebuildClientHTML = await generateDevHTML(output.browser, srcDir, actualReloadPort);
|
|
1270
1287
|
writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
|
|
1271
1288
|
}
|
|
1272
1289
|
if (output.server) {
|
|
@@ -2540,7 +2557,7 @@ async function startRepl() {
|
|
|
2540
2557
|
'in', 'return', 'match', 'type', 'import', 'from', 'and', 'or', 'not',
|
|
2541
2558
|
'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
|
|
2542
2559
|
'guard', 'interface', 'derive', 'pub', 'impl', 'trait', 'defer',
|
|
2543
|
-
'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'shared',
|
|
2560
|
+
'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'browser', 'shared',
|
|
2544
2561
|
]);
|
|
2545
2562
|
|
|
2546
2563
|
const TYPE_NAMES = new Set([
|
|
@@ -3040,7 +3057,7 @@ async function binaryBuild(srcDir, outputName, outDir) {
|
|
|
3040
3057
|
// Step 1: Compile all .tova files to JS
|
|
3041
3058
|
const sharedParts = [];
|
|
3042
3059
|
const serverParts = [];
|
|
3043
|
-
const
|
|
3060
|
+
const browserParts = [];
|
|
3044
3061
|
|
|
3045
3062
|
for (const file of tovaFiles) {
|
|
3046
3063
|
try {
|
|
@@ -3048,7 +3065,7 @@ async function binaryBuild(srcDir, outputName, outDir) {
|
|
|
3048
3065
|
const output = compileTova(source, file);
|
|
3049
3066
|
if (output.shared) sharedParts.push(output.shared);
|
|
3050
3067
|
if (output.server) serverParts.push(output.server);
|
|
3051
|
-
if (output.
|
|
3068
|
+
if (output.browser) browserParts.push(output.browser);
|
|
3052
3069
|
} catch (err) {
|
|
3053
3070
|
console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
|
|
3054
3071
|
process.exit(1);
|
|
@@ -3118,7 +3135,7 @@ async function productionBuild(srcDir, outDir) {
|
|
|
3118
3135
|
|
|
3119
3136
|
console.log(`\n Production build...\n`);
|
|
3120
3137
|
|
|
3121
|
-
const
|
|
3138
|
+
const browserParts = [];
|
|
3122
3139
|
const serverParts = [];
|
|
3123
3140
|
const sharedParts = [];
|
|
3124
3141
|
let cssContent = '';
|
|
@@ -3130,14 +3147,14 @@ async function productionBuild(srcDir, outDir) {
|
|
|
3130
3147
|
|
|
3131
3148
|
if (output.shared) sharedParts.push(output.shared);
|
|
3132
3149
|
if (output.server) serverParts.push(output.server);
|
|
3133
|
-
if (output.
|
|
3150
|
+
if (output.browser) browserParts.push(output.browser);
|
|
3134
3151
|
} catch (err) {
|
|
3135
3152
|
console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
|
|
3136
3153
|
process.exit(1);
|
|
3137
3154
|
}
|
|
3138
3155
|
}
|
|
3139
3156
|
|
|
3140
|
-
const allClientCode =
|
|
3157
|
+
const allClientCode = browserParts.join('\n');
|
|
3141
3158
|
const allServerCode = serverParts.join('\n');
|
|
3142
3159
|
const allSharedCode = sharedParts.join('\n');
|
|
3143
3160
|
|
|
@@ -3485,7 +3502,8 @@ class BuildCache {
|
|
|
3485
3502
|
}
|
|
3486
3503
|
|
|
3487
3504
|
_hashContent(content) {
|
|
3488
|
-
return Bun.hash(content).toString(16);
|
|
3505
|
+
if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(content).toString(16);
|
|
3506
|
+
return _cryptoHash('md5').update(content).digest('hex');
|
|
3489
3507
|
}
|
|
3490
3508
|
|
|
3491
3509
|
load() {
|
|
@@ -3526,7 +3544,8 @@ class BuildCache {
|
|
|
3526
3544
|
for (const f of files.slice().sort()) {
|
|
3527
3545
|
combined += f + readFileSync(f, 'utf-8');
|
|
3528
3546
|
}
|
|
3529
|
-
return Bun.hash(combined).toString(16);
|
|
3547
|
+
if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(combined).toString(16);
|
|
3548
|
+
return _cryptoHash('md5').update(combined).digest('hex');
|
|
3530
3549
|
}
|
|
3531
3550
|
|
|
3532
3551
|
// Store compiled output for a multi-file group
|
|
@@ -3598,7 +3617,7 @@ function getCompiledExtension(tovaPath) {
|
|
|
3598
3617
|
const lexer = new Lexer(src, tovaPath);
|
|
3599
3618
|
const tokens = lexer.tokenize();
|
|
3600
3619
|
// Check if any top-level token is a block keyword (shared/server/client/test/bench/data)
|
|
3601
|
-
const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'test', 'bench', 'data']);
|
|
3620
|
+
const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'browser', 'test', 'bench', 'data']);
|
|
3602
3621
|
let depth = 0;
|
|
3603
3622
|
for (const tok of tokens) {
|
|
3604
3623
|
if (tok.type === 'LBRACE') depth++;
|
|
@@ -3725,7 +3744,7 @@ function collectExports(ast, filename) {
|
|
|
3725
3744
|
|
|
3726
3745
|
for (const node of ast.body) {
|
|
3727
3746
|
// Also collect exports from inside shared/server/client blocks
|
|
3728
|
-
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === '
|
|
3747
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
|
|
3729
3748
|
if (node.body) {
|
|
3730
3749
|
for (const inner of node.body) {
|
|
3731
3750
|
collectFromNode(inner);
|
|
@@ -3755,7 +3774,7 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
3755
3774
|
const ast = parser.parse();
|
|
3756
3775
|
|
|
3757
3776
|
// Cache module type from AST (avoids regex heuristic on subsequent lookups)
|
|
3758
|
-
const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === '
|
|
3777
|
+
const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === 'BrowserBlock' || n.type === 'TestBlock' || n.type === 'BenchBlock' || n.type === 'DataBlock');
|
|
3759
3778
|
moduleTypeCache.set(filename, hasBlocks ? '.shared.js' : '.js');
|
|
3760
3779
|
|
|
3761
3780
|
// Collect this module's exports for validation
|
|
@@ -3853,32 +3872,32 @@ function validateMergedAST(mergedBlocks, sourceFiles) {
|
|
|
3853
3872
|
);
|
|
3854
3873
|
}
|
|
3855
3874
|
|
|
3856
|
-
// Check
|
|
3857
|
-
const
|
|
3858
|
-
for (const block of mergedBlocks.
|
|
3875
|
+
// Check browser blocks — top-level declarations only
|
|
3876
|
+
const browserDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
|
|
3877
|
+
for (const block of mergedBlocks.browserBlocks) {
|
|
3859
3878
|
for (const stmt of block.body) {
|
|
3860
3879
|
const loc = stmt.loc || block.loc;
|
|
3861
3880
|
if (stmt.type === 'ComponentDeclaration') {
|
|
3862
|
-
if (
|
|
3863
|
-
else
|
|
3881
|
+
if (browserDecls.component.has(stmt.name)) addDup('component', stmt.name, browserDecls.component.get(stmt.name), loc);
|
|
3882
|
+
else browserDecls.component.set(stmt.name, loc);
|
|
3864
3883
|
} else if (stmt.type === 'StateDeclaration') {
|
|
3865
3884
|
const name = stmt.name || (stmt.targets && stmt.targets[0]);
|
|
3866
3885
|
if (name) {
|
|
3867
|
-
if (
|
|
3868
|
-
else
|
|
3886
|
+
if (browserDecls.state.has(name)) addDup('state', name, browserDecls.state.get(name), loc);
|
|
3887
|
+
else browserDecls.state.set(name, loc);
|
|
3869
3888
|
}
|
|
3870
3889
|
} else if (stmt.type === 'ComputedDeclaration') {
|
|
3871
3890
|
const name = stmt.name;
|
|
3872
3891
|
if (name) {
|
|
3873
|
-
if (
|
|
3874
|
-
else
|
|
3892
|
+
if (browserDecls.computed.has(name)) addDup('computed', name, browserDecls.computed.get(name), loc);
|
|
3893
|
+
else browserDecls.computed.set(name, loc);
|
|
3875
3894
|
}
|
|
3876
3895
|
} else if (stmt.type === 'StoreDeclaration') {
|
|
3877
|
-
if (
|
|
3878
|
-
else
|
|
3896
|
+
if (browserDecls.store.has(stmt.name)) addDup('store', stmt.name, browserDecls.store.get(stmt.name), loc);
|
|
3897
|
+
else browserDecls.store.set(stmt.name, loc);
|
|
3879
3898
|
} else if (stmt.type === 'FunctionDeclaration') {
|
|
3880
|
-
if (
|
|
3881
|
-
else
|
|
3899
|
+
if (browserDecls.fn.has(stmt.name)) addDup('function', stmt.name, browserDecls.fn.get(stmt.name), loc);
|
|
3900
|
+
else browserDecls.fn.set(stmt.name, loc);
|
|
3882
3901
|
}
|
|
3883
3902
|
}
|
|
3884
3903
|
}
|
|
@@ -4022,7 +4041,7 @@ function mergeDirectory(dir, srcDir, options = {}) {
|
|
|
4022
4041
|
const mergedBody = [];
|
|
4023
4042
|
const sharedBlocks = [];
|
|
4024
4043
|
const serverBlocks = [];
|
|
4025
|
-
const
|
|
4044
|
+
const browserBlocks = [];
|
|
4026
4045
|
|
|
4027
4046
|
for (const { file, ast } of parsedFiles) {
|
|
4028
4047
|
for (const node of ast.body) {
|
|
@@ -4043,14 +4062,14 @@ function mergeDirectory(dir, srcDir, options = {}) {
|
|
|
4043
4062
|
|
|
4044
4063
|
if (node.type === 'SharedBlock') sharedBlocks.push(node);
|
|
4045
4064
|
else if (node.type === 'ServerBlock') serverBlocks.push(node);
|
|
4046
|
-
else if (node.type === '
|
|
4065
|
+
else if (node.type === 'BrowserBlock') browserBlocks.push(node);
|
|
4047
4066
|
|
|
4048
4067
|
mergedBody.push(node);
|
|
4049
4068
|
}
|
|
4050
4069
|
}
|
|
4051
4070
|
|
|
4052
4071
|
// Validate for duplicate declarations across files
|
|
4053
|
-
validateMergedAST({ sharedBlocks, serverBlocks,
|
|
4072
|
+
validateMergedAST({ sharedBlocks, serverBlocks, browserBlocks }, tovaFiles);
|
|
4054
4073
|
|
|
4055
4074
|
// Build merged Program AST
|
|
4056
4075
|
const mergedAST = new Program(mergedBody);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tova",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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,7 +36,12 @@
|
|
|
36
36
|
"url": "https://github.com/tova-lang/tova-lang/issues"
|
|
37
37
|
},
|
|
38
38
|
"author": "Enoch Kujem Abassey",
|
|
39
|
-
"keywords": [
|
|
39
|
+
"keywords": [
|
|
40
|
+
"language",
|
|
41
|
+
"transpiler",
|
|
42
|
+
"fullstack",
|
|
43
|
+
"javascript"
|
|
44
|
+
],
|
|
40
45
|
"license": "MIT",
|
|
41
46
|
"devDependencies": {
|
|
42
47
|
"@codemirror/autocomplete": "^6.20.0",
|
package/src/analyzer/analyzer.js
CHANGED
|
@@ -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
|
-
|
|
780
|
-
// Ensure
|
|
781
|
-
const plugin = BlockRegistry.get('
|
|
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
|
|
|
@@ -917,7 +917,7 @@ export class Analyzer {
|
|
|
917
917
|
return;
|
|
918
918
|
case 'JSXElement':
|
|
919
919
|
case 'JSXFragment':
|
|
920
|
-
return this.
|
|
920
|
+
return this._visitBrowserNode(node);
|
|
921
921
|
// Column expressions (for table operations) — no semantic analysis needed
|
|
922
922
|
case 'ColumnExpression':
|
|
923
923
|
return;
|
|
@@ -1049,6 +1049,214 @@ export class Analyzer {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
+
visitEdgeBlock(node) {
|
|
1053
|
+
const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
|
|
1054
|
+
const validConfigKeys = new Set(['target']);
|
|
1055
|
+
const bindingNames = new Set();
|
|
1056
|
+
|
|
1057
|
+
// Binding support matrix per target
|
|
1058
|
+
const BINDING_SUPPORT = {
|
|
1059
|
+
cloudflare: { kv: true, sql: true, storage: true, queue: true },
|
|
1060
|
+
deno: { kv: true, sql: false, storage: false, queue: false },
|
|
1061
|
+
vercel: { kv: false, sql: false, storage: false, queue: false },
|
|
1062
|
+
lambda: { kv: false, sql: false, storage: false, queue: false },
|
|
1063
|
+
bun: { kv: false, sql: true, storage: false, queue: false },
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// Targets that support schedule/consume/middleware
|
|
1067
|
+
const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
|
|
1068
|
+
const CONSUME_TARGETS = new Set(['cloudflare']);
|
|
1069
|
+
|
|
1070
|
+
// Determine target from config fields
|
|
1071
|
+
let target = 'cloudflare';
|
|
1072
|
+
for (const stmt of node.body) {
|
|
1073
|
+
if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
|
|
1074
|
+
target = stmt.value.value;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
this.pushScope('edge');
|
|
1079
|
+
|
|
1080
|
+
let kvCount = 0;
|
|
1081
|
+
const queueNames = new Set();
|
|
1082
|
+
const consumers = [];
|
|
1083
|
+
|
|
1084
|
+
for (const stmt of node.body) {
|
|
1085
|
+
// Validate config fields
|
|
1086
|
+
if (stmt.type === 'EdgeConfigField') {
|
|
1087
|
+
if (!validConfigKeys.has(stmt.key)) {
|
|
1088
|
+
this.warnings.push({
|
|
1089
|
+
message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
|
|
1090
|
+
loc: stmt.loc,
|
|
1091
|
+
code: 'W_UNKNOWN_EDGE_CONFIG',
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
|
|
1095
|
+
if (!validTargets.has(stmt.value.value)) {
|
|
1096
|
+
this.warnings.push({
|
|
1097
|
+
message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
|
|
1098
|
+
loc: stmt.loc,
|
|
1099
|
+
code: 'W_UNKNOWN_EDGE_TARGET',
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Check for duplicate binding names
|
|
1107
|
+
if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
|
|
1108
|
+
stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
|
|
1109
|
+
if (bindingNames.has(stmt.name)) {
|
|
1110
|
+
this.warnings.push({
|
|
1111
|
+
message: `Duplicate edge binding '${stmt.name}'`,
|
|
1112
|
+
loc: stmt.loc,
|
|
1113
|
+
code: 'W_DUPLICATE_EDGE_BINDING',
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
bindingNames.add(stmt.name);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Track queue names for consume validation
|
|
1120
|
+
if (stmt.type === 'EdgeQueueDeclaration') {
|
|
1121
|
+
queueNames.add(stmt.name);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Check for duplicate env/secret names
|
|
1125
|
+
if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
|
|
1126
|
+
if (bindingNames.has(stmt.name)) {
|
|
1127
|
+
this.warnings.push({
|
|
1128
|
+
message: `Duplicate edge binding '${stmt.name}'`,
|
|
1129
|
+
loc: stmt.loc,
|
|
1130
|
+
code: 'W_DUPLICATE_EDGE_BINDING',
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
bindingNames.add(stmt.name);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Unsupported binding warnings (per target)
|
|
1137
|
+
const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
|
|
1138
|
+
if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
|
|
1139
|
+
this.warnings.push({
|
|
1140
|
+
message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1141
|
+
loc: stmt.loc,
|
|
1142
|
+
code: 'W_UNSUPPORTED_KV',
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
|
|
1146
|
+
this.warnings.push({
|
|
1147
|
+
message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1148
|
+
loc: stmt.loc,
|
|
1149
|
+
code: 'W_UNSUPPORTED_SQL',
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
|
|
1153
|
+
this.warnings.push({
|
|
1154
|
+
message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1155
|
+
loc: stmt.loc,
|
|
1156
|
+
code: 'W_UNSUPPORTED_STORAGE',
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
|
|
1160
|
+
this.warnings.push({
|
|
1161
|
+
message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
|
|
1162
|
+
loc: stmt.loc,
|
|
1163
|
+
code: 'W_UNSUPPORTED_QUEUE',
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Deno multi-KV warning
|
|
1168
|
+
if (stmt.type === 'EdgeKVDeclaration') {
|
|
1169
|
+
kvCount++;
|
|
1170
|
+
if (kvCount > 1 && target === 'deno') {
|
|
1171
|
+
this.warnings.push({
|
|
1172
|
+
message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
|
|
1173
|
+
loc: stmt.loc,
|
|
1174
|
+
code: 'W_DENO_MULTI_KV',
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Validate schedule cron expressions + target support
|
|
1180
|
+
if (stmt.type === 'EdgeScheduleDeclaration') {
|
|
1181
|
+
const parts = stmt.cron.split(/\s+/);
|
|
1182
|
+
if (parts.length < 5 || parts.length > 6) {
|
|
1183
|
+
this.warnings.push({
|
|
1184
|
+
message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
|
|
1185
|
+
loc: stmt.loc,
|
|
1186
|
+
code: 'W_INVALID_CRON',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
if (!SCHEDULE_TARGETS.has(target)) {
|
|
1190
|
+
this.warnings.push({
|
|
1191
|
+
message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
|
|
1192
|
+
loc: stmt.loc,
|
|
1193
|
+
code: 'W_UNSUPPORTED_SCHEDULE',
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Collect consume declarations for post-loop validation
|
|
1199
|
+
if (stmt.type === 'EdgeConsumeDeclaration') {
|
|
1200
|
+
consumers.push(stmt);
|
|
1201
|
+
if (!CONSUME_TARGETS.has(target)) {
|
|
1202
|
+
this.warnings.push({
|
|
1203
|
+
message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
|
|
1204
|
+
loc: stmt.loc,
|
|
1205
|
+
code: 'W_UNSUPPORTED_CONSUME',
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Visit child nodes — edge-specific types are noop in the registry,
|
|
1211
|
+
// so explicitly visit bodies that contain statements
|
|
1212
|
+
if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
|
|
1213
|
+
for (const s of stmt.body.body || []) this.visitNode(s);
|
|
1214
|
+
} else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
|
|
1215
|
+
this.visitNode(stmt);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Post-loop: validate consume references a declared queue
|
|
1220
|
+
for (const consumer of consumers) {
|
|
1221
|
+
if (!queueNames.has(consumer.queue)) {
|
|
1222
|
+
this.warnings.push({
|
|
1223
|
+
message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
|
|
1224
|
+
loc: consumer.loc,
|
|
1225
|
+
code: 'W_CONSUME_UNKNOWN_QUEUE',
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Warn if edge block has no route or schedule handlers
|
|
1231
|
+
const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
|
|
1232
|
+
const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
|
|
1233
|
+
const hasConsumers = consumers.length > 0;
|
|
1234
|
+
if (!hasRoutes && !hasSchedules && !hasConsumers) {
|
|
1235
|
+
this.warnings.push({
|
|
1236
|
+
message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
|
|
1237
|
+
loc: node.loc,
|
|
1238
|
+
code: 'W_EDGE_NO_HANDLERS',
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
this.popScope();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
_validateEdgeCrossBlock() {
|
|
1246
|
+
const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
|
|
1247
|
+
if (edgeBlocks.length === 0) return;
|
|
1248
|
+
|
|
1249
|
+
// Warn if edge + cli coexist (cli takes over with earlyReturn)
|
|
1250
|
+
const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
|
|
1251
|
+
if (hasCli) {
|
|
1252
|
+
this.warnings.push({
|
|
1253
|
+
message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
|
|
1254
|
+
loc: edgeBlocks[0].loc,
|
|
1255
|
+
code: 'W_EDGE_WITH_CLI',
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1052
1260
|
_validateSecurityCrossBlock() {
|
|
1053
1261
|
// Collect ALL security declarations across ALL security blocks in the AST
|
|
1054
1262
|
const allRoles = new Set();
|
|
@@ -1287,7 +1495,7 @@ export class Analyzer {
|
|
|
1287
1495
|
}
|
|
1288
1496
|
}
|
|
1289
1497
|
|
|
1290
|
-
//
|
|
1498
|
+
// visitBrowserBlock and other browser visitors are in browser-analyzer.js (lazy-loaded)
|
|
1291
1499
|
|
|
1292
1500
|
visitSharedBlock(node) {
|
|
1293
1501
|
const prevScope = this.currentScope;
|
|
@@ -1987,7 +2195,7 @@ export class Analyzer {
|
|
|
1987
2195
|
this.visitExpression(node.value);
|
|
1988
2196
|
}
|
|
1989
2197
|
|
|
1990
|
-
//
|
|
2198
|
+
// Browser-specific visitors (visitState, visitComputed, etc.) are in browser-analyzer.js (lazy-loaded)
|
|
1991
2199
|
|
|
1992
2200
|
visitTestBlock(node) {
|
|
1993
2201
|
const prevScope = this.currentScope;
|
|
@@ -2352,7 +2560,7 @@ export class Analyzer {
|
|
|
2352
2560
|
candidates.push([node.name, typeVariants]);
|
|
2353
2561
|
}
|
|
2354
2562
|
}
|
|
2355
|
-
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === '
|
|
2563
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
|
|
2356
2564
|
this._collectTypeCandidates(node.body, coveredVariants, candidates);
|
|
2357
2565
|
}
|
|
2358
2566
|
}
|
|
@@ -2447,7 +2655,7 @@ export class Analyzer {
|
|
|
2447
2655
|
}
|
|
2448
2656
|
}
|
|
2449
2657
|
|
|
2450
|
-
// visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in
|
|
2658
|
+
// visitJSXElement, visitJSXFragment, visitJSXFor, visitJSXIf are in browser-analyzer.js (lazy-loaded)
|
|
2451
2659
|
|
|
2452
2660
|
// ─── New feature visitors ─────────────────────────────────
|
|
2453
2661
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Extracted from analyzer.js for lazy loading — only loaded when
|
|
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
|
|
7
|
-
if (AnalyzerClass.prototype.
|
|
8
|
-
AnalyzerClass.prototype.
|
|
7
|
+
export function installBrowserAnalyzer(AnalyzerClass) {
|
|
8
|
+
if (AnalyzerClass.prototype._browserAnalyzerInstalled) return;
|
|
9
|
+
AnalyzerClass.prototype._browserAnalyzerInstalled = true;
|
|
9
10
|
|
|
10
|
-
AnalyzerClass
|
|
11
|
+
installFormAnalyzer(AnalyzerClass);
|
|
12
|
+
|
|
13
|
+
AnalyzerClass.prototype.visitBrowserBlock = function(node) {
|
|
11
14
|
const prevScope = this.currentScope;
|
|
12
|
-
this.currentScope = this.currentScope.child('
|
|
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 !== '
|
|
25
|
-
this.error(`'state' can only be used inside a
|
|
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 !== '
|
|
39
|
-
this.error(`'computed' can only be used inside a
|
|
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 !== '
|
|
53
|
-
this.error(`'effect' can only be used inside a
|
|
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 !== '
|
|
61
|
-
this.error(`'component' can only be used inside a
|
|
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 !== '
|
|
93
|
-
this.error(`'store' can only be used inside a
|
|
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 {
|