tova 0.1.1 → 0.2.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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/tova.js +811 -154
- package/package.json +8 -2
- package/src/analyzer/analyzer.js +297 -58
- package/src/analyzer/scope.js +38 -1
- package/src/analyzer/type-registry.js +72 -0
- package/src/analyzer/types.js +478 -0
- package/src/codegen/base-codegen.js +371 -0
- package/src/codegen/client-codegen.js +62 -10
- package/src/codegen/codegen.js +111 -2
- package/src/codegen/server-codegen.js +175 -3
- package/src/config/edit-toml.js +100 -0
- package/src/config/package-json.js +52 -0
- package/src/config/resolve.js +100 -0
- package/src/config/toml.js +209 -0
- package/src/lexer/lexer.js +2 -2
- package/src/lsp/server.js +284 -30
- package/src/parser/ast.js +105 -0
- package/src/parser/parser.js +202 -2
- package/src/runtime/ai.js +305 -0
- package/src/runtime/devtools.js +228 -0
- package/src/runtime/embedded.js +3 -1
- package/src/runtime/io.js +240 -0
- package/src/runtime/reactivity.js +264 -19
- package/src/runtime/ssr.js +196 -24
- package/src/runtime/table.js +522 -0
- package/src/stdlib/collections.js +245 -0
- package/src/stdlib/core.js +87 -0
- package/src/stdlib/datetime.js +88 -0
- package/src/stdlib/encoding.js +35 -0
- package/src/stdlib/functional.js +82 -0
- package/src/stdlib/inline.js +334 -67
- package/src/stdlib/math.js +93 -0
- package/src/stdlib/string.js +95 -0
- package/src/stdlib/url.js +33 -0
- package/src/stdlib/validation.js +29 -0
package/bin/tova.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { resolve, basename, dirname, join, relative } from 'path';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, watch as fsWatch } from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync, watch as fsWatch } from 'fs';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
import { createRequire } from 'module';
|
|
8
8
|
import { Lexer } from '../src/lexer/lexer.js';
|
|
9
9
|
import { Parser } from '../src/parser/parser.js';
|
|
10
10
|
import { Analyzer } from '../src/analyzer/analyzer.js';
|
|
11
|
+
import { Program } from '../src/parser/ast.js';
|
|
11
12
|
import { CodeGenerator } from '../src/codegen/codegen.js';
|
|
12
13
|
import { richError, formatDiagnostics, DiagnosticFormatter } from '../src/diagnostics/formatter.js';
|
|
13
|
-
import { getFullStdlib,
|
|
14
|
+
import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '../src/stdlib/inline.js';
|
|
14
15
|
import { Formatter } from '../src/formatter/formatter.js';
|
|
15
16
|
import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
|
|
16
17
|
import '../src/runtime/string-proto.js';
|
|
18
|
+
import { resolveConfig } from '../src/config/resolve.js';
|
|
19
|
+
import { writePackageJson } from '../src/config/package-json.js';
|
|
20
|
+
import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
|
|
21
|
+
import { stringifyTOML } from '../src/config/toml.js';
|
|
17
22
|
|
|
18
23
|
const require = createRequire(import.meta.url);
|
|
19
24
|
const { version: VERSION } = require('../package.json');
|
|
@@ -23,6 +28,7 @@ const HELP = `
|
|
|
23
28
|
║ ║ ║ ║ ╠╣
|
|
24
29
|
╩═╝╚═╝╩═╝╩ v${VERSION}
|
|
25
30
|
|
|
31
|
+
Created by Enoch Kujem Abassey
|
|
26
32
|
A modern full-stack language that transpiles to JavaScript
|
|
27
33
|
|
|
28
34
|
Usage:
|
|
@@ -31,10 +37,13 @@ Usage:
|
|
|
31
37
|
Commands:
|
|
32
38
|
run <file> Compile and execute a .tova file
|
|
33
39
|
build [dir] Compile .tova files to JavaScript (default: current dir)
|
|
34
|
-
dev Start development server with
|
|
40
|
+
dev Start development server with live reload
|
|
41
|
+
new <name> Create a new Tova project
|
|
42
|
+
install Install npm dependencies from tova.toml
|
|
43
|
+
add <pkg> Add an npm dependency (--dev for dev dependency)
|
|
44
|
+
remove <pkg> Remove an npm dependency
|
|
35
45
|
repl Start interactive Tova REPL
|
|
36
46
|
lsp Start Language Server Protocol server
|
|
37
|
-
new <name> Create a new Tova project
|
|
38
47
|
fmt <file> Format a .tova file (--check to verify only)
|
|
39
48
|
test [dir] Run test blocks in .tova files (--filter, --watch)
|
|
40
49
|
migrate:create <name> Create a new migration file
|
|
@@ -48,6 +57,7 @@ Options:
|
|
|
48
57
|
--production Production build (minify, bundle, hash)
|
|
49
58
|
--watch Watch for file changes
|
|
50
59
|
--debug Show verbose error output
|
|
60
|
+
--strict Enable strict type checking
|
|
51
61
|
`;
|
|
52
62
|
|
|
53
63
|
async function main() {
|
|
@@ -65,9 +75,10 @@ async function main() {
|
|
|
65
75
|
|
|
66
76
|
const command = args[0];
|
|
67
77
|
|
|
78
|
+
const isStrict = args.includes('--strict');
|
|
68
79
|
switch (command) {
|
|
69
80
|
case 'run':
|
|
70
|
-
await runFile(args[1]);
|
|
81
|
+
await runFile(args.filter(a => a !== '--strict')[1], { strict: isStrict });
|
|
71
82
|
break;
|
|
72
83
|
case 'build':
|
|
73
84
|
buildProject(args.slice(1));
|
|
@@ -84,6 +95,15 @@ async function main() {
|
|
|
84
95
|
case 'new':
|
|
85
96
|
newProject(args[1]);
|
|
86
97
|
break;
|
|
98
|
+
case 'install':
|
|
99
|
+
await installDeps();
|
|
100
|
+
break;
|
|
101
|
+
case 'add':
|
|
102
|
+
await addDep(args.slice(1));
|
|
103
|
+
break;
|
|
104
|
+
case 'remove':
|
|
105
|
+
await removeDep(args[1]);
|
|
106
|
+
break;
|
|
87
107
|
case 'fmt':
|
|
88
108
|
formatFile(args.slice(1));
|
|
89
109
|
break;
|
|
@@ -101,7 +121,7 @@ async function main() {
|
|
|
101
121
|
break;
|
|
102
122
|
default:
|
|
103
123
|
if (command.endsWith('.tova')) {
|
|
104
|
-
await runFile(command);
|
|
124
|
+
await runFile(command, { strict: isStrict });
|
|
105
125
|
} else {
|
|
106
126
|
console.error(`Unknown command: ${command}`);
|
|
107
127
|
console.log(HELP);
|
|
@@ -112,14 +132,14 @@ async function main() {
|
|
|
112
132
|
|
|
113
133
|
// ─── Compile a .tova source string ───────────────────────────
|
|
114
134
|
|
|
115
|
-
function compileTova(source, filename) {
|
|
135
|
+
function compileTova(source, filename, options = {}) {
|
|
116
136
|
const lexer = new Lexer(source, filename);
|
|
117
137
|
const tokens = lexer.tokenize();
|
|
118
138
|
|
|
119
139
|
const parser = new Parser(tokens, filename);
|
|
120
140
|
const ast = parser.parse();
|
|
121
141
|
|
|
122
|
-
const analyzer = new Analyzer(ast, filename);
|
|
142
|
+
const analyzer = new Analyzer(ast, filename, { strict: options.strict || false });
|
|
123
143
|
const { warnings } = analyzer.analyze();
|
|
124
144
|
|
|
125
145
|
if (warnings.length > 0) {
|
|
@@ -299,11 +319,25 @@ function findTovaFiles(dir) {
|
|
|
299
319
|
|
|
300
320
|
// ─── Run ────────────────────────────────────────────────────
|
|
301
321
|
|
|
302
|
-
async function runFile(filePath) {
|
|
322
|
+
async function runFile(filePath, options = {}) {
|
|
303
323
|
if (!filePath) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
324
|
+
// If tova.toml exists, try to find a main file in the entry directory
|
|
325
|
+
const config = resolveConfig(process.cwd());
|
|
326
|
+
if (config._source === 'tova.toml') {
|
|
327
|
+
const entryDir = resolve(config.project.entry || '.');
|
|
328
|
+
for (const name of ['main.tova', 'app.tova']) {
|
|
329
|
+
const candidate = join(entryDir, name);
|
|
330
|
+
if (existsSync(candidate)) {
|
|
331
|
+
filePath = candidate;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!filePath) {
|
|
337
|
+
console.error('Error: No file specified');
|
|
338
|
+
console.error('Usage: tova run <file.tova>');
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
307
341
|
}
|
|
308
342
|
|
|
309
343
|
const resolved = resolve(filePath);
|
|
@@ -315,7 +349,7 @@ async function runFile(filePath) {
|
|
|
315
349
|
const source = readFileSync(resolved, 'utf-8');
|
|
316
350
|
|
|
317
351
|
try {
|
|
318
|
-
const output = compileTova(source, filePath);
|
|
352
|
+
const output = compileTova(source, filePath, { strict: options.strict });
|
|
319
353
|
|
|
320
354
|
// Execute the generated JavaScript (with stdlib)
|
|
321
355
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
@@ -335,10 +369,13 @@ async function runFile(filePath) {
|
|
|
335
369
|
// ─── Build ──────────────────────────────────────────────────
|
|
336
370
|
|
|
337
371
|
async function buildProject(args) {
|
|
372
|
+
const config = resolveConfig(process.cwd());
|
|
338
373
|
const isProduction = args.includes('--production');
|
|
339
|
-
const
|
|
374
|
+
const buildStrict = args.includes('--strict');
|
|
375
|
+
const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
|
|
376
|
+
const srcDir = resolve(explicitSrc || config.project.entry || '.');
|
|
340
377
|
const outIdx = args.indexOf('--output');
|
|
341
|
-
const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : '.tova-out');
|
|
378
|
+
const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
|
|
342
379
|
|
|
343
380
|
// Production build uses a separate optimized pipeline
|
|
344
381
|
if (isProduction) {
|
|
@@ -364,22 +401,33 @@ async function buildProject(args) {
|
|
|
364
401
|
|
|
365
402
|
let errorCount = 0;
|
|
366
403
|
compilationCache.clear();
|
|
367
|
-
|
|
368
|
-
|
|
404
|
+
|
|
405
|
+
// Group files by directory for multi-file merging
|
|
406
|
+
const dirGroups = groupFilesByDirectory(tovaFiles);
|
|
407
|
+
|
|
408
|
+
for (const [dir, files] of dirGroups) {
|
|
409
|
+
const dirName = basename(dir) === '.' ? 'app' : basename(dir);
|
|
410
|
+
const relDir = relative(srcDir, dir) || '.';
|
|
369
411
|
try {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
412
|
+
const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
|
|
413
|
+
if (!result) continue;
|
|
414
|
+
|
|
415
|
+
const { output, single } = result;
|
|
416
|
+
// Use single-file basename for single-file dirs, directory name for multi-file
|
|
417
|
+
const outBaseName = single ? basename(files[0], '.tova') : dirName;
|
|
418
|
+
const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
|
|
373
419
|
|
|
374
|
-
//
|
|
420
|
+
// Helper to generate source maps
|
|
375
421
|
const generateSourceMap = (code, jsFile) => {
|
|
376
422
|
if (output.sourceMappings && output.sourceMappings.length > 0) {
|
|
377
|
-
const
|
|
423
|
+
const sourceFile = single ? relative(srcDir, files[0]) : relDir;
|
|
424
|
+
const smb = new SourceMapBuilder(sourceFile, output._sourceFiles);
|
|
378
425
|
for (const m of output.sourceMappings) {
|
|
379
|
-
smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol);
|
|
426
|
+
smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol, m.sourceFile);
|
|
380
427
|
}
|
|
428
|
+
const sourceContent = single ? readFileSync(files[0], 'utf-8') : null;
|
|
381
429
|
const mapFile = jsFile + '.map';
|
|
382
|
-
writeFileSync(mapFile, smb.generate(
|
|
430
|
+
writeFileSync(mapFile, smb.generate(sourceContent, output._sourceContents));
|
|
383
431
|
return code + `\n//# sourceMappingURL=${basename(mapFile)}`;
|
|
384
432
|
}
|
|
385
433
|
return code;
|
|
@@ -387,32 +435,32 @@ async function buildProject(args) {
|
|
|
387
435
|
|
|
388
436
|
// Write shared
|
|
389
437
|
if (output.shared && output.shared.trim()) {
|
|
390
|
-
const sharedPath = join(outDir, `${
|
|
438
|
+
const sharedPath = join(outDir, `${outBaseName}.shared.js`);
|
|
391
439
|
writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
|
|
392
|
-
console.log(` ✓ ${
|
|
440
|
+
console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}`);
|
|
393
441
|
}
|
|
394
442
|
|
|
395
443
|
// Write default server
|
|
396
444
|
if (output.server) {
|
|
397
|
-
const serverPath = join(outDir, `${
|
|
445
|
+
const serverPath = join(outDir, `${outBaseName}.server.js`);
|
|
398
446
|
writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
|
|
399
|
-
console.log(` ✓ ${
|
|
447
|
+
console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}`);
|
|
400
448
|
}
|
|
401
449
|
|
|
402
450
|
// Write default client
|
|
403
451
|
if (output.client) {
|
|
404
|
-
const clientPath = join(outDir, `${
|
|
452
|
+
const clientPath = join(outDir, `${outBaseName}.client.js`);
|
|
405
453
|
writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
|
|
406
|
-
console.log(` ✓ ${
|
|
454
|
+
console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}`);
|
|
407
455
|
}
|
|
408
456
|
|
|
409
457
|
// Write named server blocks (multi-block)
|
|
410
458
|
if (output.multiBlock && output.servers) {
|
|
411
459
|
for (const [name, code] of Object.entries(output.servers)) {
|
|
412
|
-
if (name === 'default') continue;
|
|
413
|
-
const path = join(outDir, `${
|
|
460
|
+
if (name === 'default') continue;
|
|
461
|
+
const path = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
414
462
|
writeFileSync(path, code);
|
|
415
|
-
console.log(` ✓ ${
|
|
463
|
+
console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]`);
|
|
416
464
|
}
|
|
417
465
|
}
|
|
418
466
|
|
|
@@ -420,26 +468,31 @@ async function buildProject(args) {
|
|
|
420
468
|
if (output.multiBlock && output.clients) {
|
|
421
469
|
for (const [name, code] of Object.entries(output.clients)) {
|
|
422
470
|
if (name === 'default') continue;
|
|
423
|
-
const path = join(outDir, `${
|
|
471
|
+
const path = join(outDir, `${outBaseName}.client.${name}.js`);
|
|
424
472
|
writeFileSync(path, code);
|
|
425
|
-
console.log(` ✓ ${
|
|
473
|
+
console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]`);
|
|
426
474
|
}
|
|
427
475
|
}
|
|
428
476
|
} catch (err) {
|
|
429
|
-
console.error(` ✗ ${
|
|
477
|
+
console.error(` ✗ ${relDir}: ${err.message}`);
|
|
430
478
|
errorCount++;
|
|
431
479
|
}
|
|
432
480
|
}
|
|
433
481
|
|
|
434
|
-
|
|
482
|
+
const dirCount = dirGroups.size;
|
|
483
|
+
console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded.\n`);
|
|
435
484
|
if (errorCount > 0) process.exit(1);
|
|
436
485
|
}
|
|
437
486
|
|
|
438
487
|
// ─── Dev Server ─────────────────────────────────────────────
|
|
439
488
|
|
|
440
489
|
async function devServer(args) {
|
|
441
|
-
const
|
|
442
|
-
const
|
|
490
|
+
const config = resolveConfig(process.cwd());
|
|
491
|
+
const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
|
|
492
|
+
const srcDir = resolve(explicitSrc || config.project.entry || '.');
|
|
493
|
+
const explicitPort = args.find((_, i, a) => a[i - 1] === '--port');
|
|
494
|
+
const basePort = parseInt(explicitPort || config.dev.port || '3000');
|
|
495
|
+
const buildStrict = args.includes('--strict');
|
|
443
496
|
|
|
444
497
|
const tovaFiles = findFiles(srcDir, '.tova');
|
|
445
498
|
if (tovaFiles.length === 0) {
|
|
@@ -447,6 +500,8 @@ async function devServer(args) {
|
|
|
447
500
|
process.exit(1);
|
|
448
501
|
}
|
|
449
502
|
|
|
503
|
+
const reloadPort = basePort + 100;
|
|
504
|
+
|
|
450
505
|
console.log(`\n Tova dev server starting...\n`);
|
|
451
506
|
|
|
452
507
|
// Compile all files
|
|
@@ -463,63 +518,75 @@ async function devServer(args) {
|
|
|
463
518
|
const serverFiles = [];
|
|
464
519
|
let hasClient = false;
|
|
465
520
|
|
|
466
|
-
|
|
521
|
+
// Clear import caches for fresh compilation
|
|
522
|
+
compilationCache.clear();
|
|
523
|
+
compilationInProgress.clear();
|
|
524
|
+
moduleExports.clear();
|
|
525
|
+
|
|
526
|
+
// Compile via directory merging
|
|
527
|
+
const dirGroups = groupFilesByDirectory(tovaFiles);
|
|
528
|
+
let clientHTML = '';
|
|
529
|
+
|
|
530
|
+
// Pass 1: Merge each directory, write shared/client outputs, collect clientHTML
|
|
531
|
+
const dirResults = [];
|
|
532
|
+
for (const [dir, files] of dirGroups) {
|
|
533
|
+
const dirName = basename(dir) === '.' ? 'app' : basename(dir);
|
|
467
534
|
try {
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
535
|
+
const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
|
|
536
|
+
if (!result) continue;
|
|
537
|
+
|
|
538
|
+
const { output, single } = result;
|
|
539
|
+
const outBaseName = single ? basename(files[0], '.tova') : dirName;
|
|
540
|
+
dirResults.push({ dir, output, outBaseName, single, files });
|
|
471
541
|
|
|
472
542
|
if (output.shared && output.shared.trim()) {
|
|
473
|
-
writeFileSync(join(outDir, `${
|
|
543
|
+
writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
|
|
474
544
|
}
|
|
475
545
|
|
|
476
|
-
// Default client (generate HTML first so server can embed it)
|
|
477
|
-
let clientHTML = '';
|
|
478
546
|
if (output.client) {
|
|
479
|
-
const p = join(outDir, `${
|
|
547
|
+
const p = join(outDir, `${outBaseName}.client.js`);
|
|
480
548
|
writeFileSync(p, output.client);
|
|
481
|
-
clientHTML = generateDevHTML(output.client);
|
|
549
|
+
clientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
|
|
482
550
|
writeFileSync(join(outDir, 'index.html'), clientHTML);
|
|
483
551
|
hasClient = true;
|
|
484
552
|
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error(` ✗ ${relative(srcDir, dir)}: ${err.message}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
485
557
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
const p = join(outDir, `${baseName}.server.js`);
|
|
495
|
-
writeFileSync(p, serverCode);
|
|
496
|
-
serverFiles.push({ path: p, name: 'default', baseName });
|
|
558
|
+
// Pass 2: Write server files with clientHTML injected
|
|
559
|
+
for (const { output, outBaseName } of dirResults) {
|
|
560
|
+
if (output.server) {
|
|
561
|
+
let serverCode = output.server;
|
|
562
|
+
if (clientHTML) {
|
|
563
|
+
const htmlConst = `const __clientHTML = ${JSON.stringify(clientHTML)};\n`;
|
|
564
|
+
serverCode = htmlConst + serverCode;
|
|
497
565
|
}
|
|
566
|
+
const p = join(outDir, `${outBaseName}.server.js`);
|
|
567
|
+
writeFileSync(p, serverCode);
|
|
568
|
+
serverFiles.push({ path: p, name: 'default', baseName: outBaseName });
|
|
569
|
+
}
|
|
498
570
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
serverFiles.push({ path: p, name, baseName });
|
|
506
|
-
}
|
|
571
|
+
if (output.multiBlock && output.servers) {
|
|
572
|
+
for (const [name, code] of Object.entries(output.servers)) {
|
|
573
|
+
if (name === 'default') continue;
|
|
574
|
+
const p = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
575
|
+
writeFileSync(p, code);
|
|
576
|
+
serverFiles.push({ path: p, name, baseName: outBaseName });
|
|
507
577
|
}
|
|
578
|
+
}
|
|
508
579
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
writeFileSync(p, code);
|
|
515
|
-
}
|
|
580
|
+
if (output.multiBlock && output.clients) {
|
|
581
|
+
for (const [name, code] of Object.entries(output.clients)) {
|
|
582
|
+
if (name === 'default') continue;
|
|
583
|
+
const p = join(outDir, `${outBaseName}.client.${name}.js`);
|
|
584
|
+
writeFileSync(p, code);
|
|
516
585
|
}
|
|
517
|
-
} catch (err) {
|
|
518
|
-
console.error(` ✗ ${relative(srcDir, file)}: ${err.message}`);
|
|
519
586
|
}
|
|
520
587
|
}
|
|
521
588
|
|
|
522
|
-
console.log(` ✓ Compiled ${tovaFiles.length} file(s)`);
|
|
589
|
+
console.log(` ✓ Compiled ${tovaFiles.length} file(s) from ${dirGroups.size} directory group(s)`);
|
|
523
590
|
console.log(` ✓ Output: ${relative('.', outDir)}/`);
|
|
524
591
|
|
|
525
592
|
// Orchestrate: spawn each server block as a separate Bun process
|
|
@@ -559,6 +626,49 @@ async function devServer(args) {
|
|
|
559
626
|
console.log(` ✓ Client: ${relative('.', outDir)}/index.html`);
|
|
560
627
|
}
|
|
561
628
|
|
|
629
|
+
// Start live-reload SSE server
|
|
630
|
+
const reloadClients = new Set();
|
|
631
|
+
const reloadServer = Bun.serve({
|
|
632
|
+
port: reloadPort,
|
|
633
|
+
fetch(req) {
|
|
634
|
+
const url = new URL(req.url);
|
|
635
|
+
if (url.pathname === '/__tova_reload') {
|
|
636
|
+
const stream = new ReadableStream({
|
|
637
|
+
start(controller) {
|
|
638
|
+
const client = { controller };
|
|
639
|
+
reloadClients.add(client);
|
|
640
|
+
// Send heartbeat to keep connection alive
|
|
641
|
+
const heartbeat = setInterval(() => {
|
|
642
|
+
try { controller.enqueue(new TextEncoder().encode(': heartbeat\n\n')); } catch { clearInterval(heartbeat); }
|
|
643
|
+
}, 15000);
|
|
644
|
+
req.signal.addEventListener('abort', () => {
|
|
645
|
+
clearInterval(heartbeat);
|
|
646
|
+
reloadClients.delete(client);
|
|
647
|
+
});
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
return new Response(stream, {
|
|
651
|
+
headers: {
|
|
652
|
+
'Content-Type': 'text/event-stream',
|
|
653
|
+
'Cache-Control': 'no-cache',
|
|
654
|
+
'Connection': 'keep-alive',
|
|
655
|
+
'Access-Control-Allow-Origin': '*',
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return new Response('Not Found', { status: 404 });
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
function notifyReload() {
|
|
664
|
+
const msg = new TextEncoder().encode('data: reload\n\n');
|
|
665
|
+
for (const client of reloadClients) {
|
|
666
|
+
try { client.controller.enqueue(msg); } catch { reloadClients.delete(client); }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log(` ✓ Live reload on port ${reloadPort}`);
|
|
671
|
+
|
|
562
672
|
// Start file watcher for auto-rebuild
|
|
563
673
|
const watcher = startWatcher(srcDir, async () => {
|
|
564
674
|
console.log(' Rebuilding...');
|
|
@@ -566,30 +676,50 @@ async function devServer(args) {
|
|
|
566
676
|
// Recompile first — keep old processes alive until success
|
|
567
677
|
const currentFiles = findFiles(srcDir, '.tova');
|
|
568
678
|
const newServerFiles = [];
|
|
679
|
+
|
|
680
|
+
// Clear import caches for fresh compilation
|
|
681
|
+
compilationCache.clear();
|
|
682
|
+
compilationInProgress.clear();
|
|
683
|
+
moduleExports.clear();
|
|
684
|
+
|
|
569
685
|
try {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
686
|
+
// Merge each directory group, collect client HTML
|
|
687
|
+
const rebuildDirGroups = groupFilesByDirectory(currentFiles);
|
|
688
|
+
let rebuildClientHTML = '';
|
|
689
|
+
|
|
690
|
+
for (const [dir, files] of rebuildDirGroups) {
|
|
691
|
+
const dirName = basename(dir) === '.' ? 'app' : basename(dir);
|
|
692
|
+
const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
|
|
693
|
+
if (!result) continue;
|
|
694
|
+
|
|
695
|
+
const { output, single } = result;
|
|
696
|
+
const outBaseName = single ? basename(files[0], '.tova') : dirName;
|
|
574
697
|
|
|
575
698
|
if (output.shared && output.shared.trim()) {
|
|
576
|
-
writeFileSync(join(outDir, `${
|
|
699
|
+
writeFileSync(join(outDir, `${outBaseName}.shared.js`), output.shared);
|
|
577
700
|
}
|
|
578
701
|
if (output.client) {
|
|
579
|
-
writeFileSync(join(outDir, `${
|
|
580
|
-
|
|
581
|
-
writeFileSync(join(outDir, 'index.html'),
|
|
702
|
+
writeFileSync(join(outDir, `${outBaseName}.client.js`), output.client);
|
|
703
|
+
rebuildClientHTML = await generateDevHTML(output.client, srcDir, reloadPort);
|
|
704
|
+
writeFileSync(join(outDir, 'index.html'), rebuildClientHTML);
|
|
582
705
|
}
|
|
583
706
|
if (output.server) {
|
|
584
707
|
let serverCode = output.server;
|
|
585
|
-
if (
|
|
586
|
-
const
|
|
587
|
-
serverCode = `const __clientHTML = ${JSON.stringify(html)};\n` + serverCode;
|
|
708
|
+
if (rebuildClientHTML) {
|
|
709
|
+
serverCode = `const __clientHTML = ${JSON.stringify(rebuildClientHTML)};\n` + serverCode;
|
|
588
710
|
}
|
|
589
|
-
const p = join(outDir, `${
|
|
711
|
+
const p = join(outDir, `${outBaseName}.server.js`);
|
|
590
712
|
writeFileSync(p, serverCode);
|
|
591
713
|
newServerFiles.push(p);
|
|
592
714
|
}
|
|
715
|
+
if (output.multiBlock && output.servers) {
|
|
716
|
+
for (const [name, code] of Object.entries(output.servers)) {
|
|
717
|
+
if (name === 'default') continue;
|
|
718
|
+
const p = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
719
|
+
writeFileSync(p, code);
|
|
720
|
+
newServerFiles.push(p);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
593
723
|
}
|
|
594
724
|
} catch (err) {
|
|
595
725
|
console.error(` ✗ Rebuild failed: ${err.message}`);
|
|
@@ -620,6 +750,7 @@ async function devServer(args) {
|
|
|
620
750
|
rebuildPortOffset++;
|
|
621
751
|
}
|
|
622
752
|
console.log(' ✓ Rebuild complete');
|
|
753
|
+
notifyReload();
|
|
623
754
|
});
|
|
624
755
|
|
|
625
756
|
console.log(`\n Watching for changes. Press Ctrl+C to stop\n`);
|
|
@@ -628,6 +759,7 @@ async function devServer(args) {
|
|
|
628
759
|
process.on('SIGINT', () => {
|
|
629
760
|
console.log('\n Shutting down...');
|
|
630
761
|
watcher.close();
|
|
762
|
+
reloadServer.stop();
|
|
631
763
|
for (const p of processes) {
|
|
632
764
|
p.child.kill('SIGTERM');
|
|
633
765
|
}
|
|
@@ -638,14 +770,50 @@ async function devServer(args) {
|
|
|
638
770
|
await new Promise(() => {});
|
|
639
771
|
}
|
|
640
772
|
|
|
641
|
-
function generateDevHTML(clientCode) {
|
|
773
|
+
async function generateDevHTML(clientCode, srcDir, reloadPort = 0) {
|
|
774
|
+
const liveReloadScript = reloadPort ? `
|
|
775
|
+
<script>
|
|
776
|
+
(function() {
|
|
777
|
+
var es = new EventSource("http://localhost:${reloadPort}/__tova_reload");
|
|
778
|
+
es.onmessage = function(e) { if (e.data === "reload") window.location.reload(); };
|
|
779
|
+
es.onerror = function() { setTimeout(function() { window.location.reload(); }, 1000); };
|
|
780
|
+
})();
|
|
781
|
+
</script>` : '';
|
|
782
|
+
|
|
783
|
+
// Check if client code uses npm packages — if so, bundle with Bun.build
|
|
784
|
+
if (srcDir && hasNpmImports(clientCode)) {
|
|
785
|
+
const bundled = await bundleClientCode(clientCode, srcDir);
|
|
786
|
+
return `<!DOCTYPE html>
|
|
787
|
+
<html lang="en">
|
|
788
|
+
<head>
|
|
789
|
+
<meta charset="UTF-8">
|
|
790
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
791
|
+
<title>Tova App</title>
|
|
792
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
793
|
+
<style>
|
|
794
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
795
|
+
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; }
|
|
796
|
+
ul { list-style: none; }
|
|
797
|
+
.done { text-decoration: line-through; opacity: 0.5; }
|
|
798
|
+
</style>
|
|
799
|
+
</head>
|
|
800
|
+
<body>
|
|
801
|
+
<div id="app"></div>
|
|
802
|
+
<script type="module">
|
|
803
|
+
${bundled}
|
|
804
|
+
</script>${liveReloadScript}
|
|
805
|
+
</body>
|
|
806
|
+
</html>`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Original path: no npm imports — inline runtime, no bundling overhead
|
|
642
810
|
// Use embedded runtime sources (no disk reads needed)
|
|
643
811
|
const inlineReactivity = REACTIVITY_SOURCE.replace(/^export /gm, '');
|
|
644
812
|
const inlineRpc = RPC_SOURCE.replace(/^export /gm, '');
|
|
645
813
|
|
|
646
|
-
// Strip import lines from client code (we inline the runtime instead)
|
|
814
|
+
// Strip all import lines from client code (we inline the runtime instead)
|
|
647
815
|
const inlineClient = clientCode
|
|
648
|
-
.replace(
|
|
816
|
+
.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '')
|
|
649
817
|
.trim();
|
|
650
818
|
|
|
651
819
|
return `<!DOCTYPE html>
|
|
@@ -654,20 +822,10 @@ function generateDevHTML(clientCode) {
|
|
|
654
822
|
<meta charset="UTF-8">
|
|
655
823
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
656
824
|
<title>Tova App</title>
|
|
825
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
657
826
|
<style>
|
|
658
827
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
659
|
-
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a;
|
|
660
|
-
#app { max-width: 520px; margin: 0 auto; padding: 2rem 1rem; }
|
|
661
|
-
.app { background: white; border-radius: 16px; padding: 2rem; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
|
662
|
-
header { text-align: center; margin-bottom: 1.5rem; }
|
|
663
|
-
h1 { font-size: 2rem; margin-bottom: 0.25rem; color: #333; }
|
|
664
|
-
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; color: #555; }
|
|
665
|
-
.subtitle { font-size: 0.9rem; color: #888; letter-spacing: 0.1em; text-transform: uppercase; }
|
|
666
|
-
button { cursor: pointer; padding: 0.5rem 1rem; border: 1px solid #ddd; border-radius: 8px; background: white; font-size: 0.9rem; transition: all 0.15s; }
|
|
667
|
-
button:hover { background: #f0f0f0; transform: translateY(-1px); }
|
|
668
|
-
button:active { transform: translateY(0); }
|
|
669
|
-
input[type="text"] { padding: 0.6rem 0.75rem; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 0.9rem; width: 100%; outline: none; transition: border-color 0.2s; }
|
|
670
|
-
input[type="text"]:focus { border-color: #667eea; }
|
|
828
|
+
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; }
|
|
671
829
|
ul { list-style: none; }
|
|
672
830
|
.done { text-decoration: line-through; opacity: 0.5; }
|
|
673
831
|
.timer-section { text-align: center; padding: 1.5rem; margin-bottom: 1.5rem; background: #f8f9ff; border-radius: 12px; }
|
|
@@ -705,7 +863,7 @@ ${inlineRpc}
|
|
|
705
863
|
|
|
706
864
|
// ── App ──
|
|
707
865
|
${inlineClient}
|
|
708
|
-
</script
|
|
866
|
+
</script>${liveReloadScript}
|
|
709
867
|
</body>
|
|
710
868
|
</html>`;
|
|
711
869
|
}
|
|
@@ -730,19 +888,34 @@ function newProject(name) {
|
|
|
730
888
|
mkdirSync(projectDir, { recursive: true });
|
|
731
889
|
mkdirSync(join(projectDir, 'src'));
|
|
732
890
|
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
build: 'tova build src',
|
|
891
|
+
// tova.toml
|
|
892
|
+
const tomlContent = stringifyTOML({
|
|
893
|
+
project: {
|
|
894
|
+
name,
|
|
895
|
+
version: '0.1.0',
|
|
896
|
+
description: 'A full-stack Tova application',
|
|
897
|
+
entry: 'src',
|
|
741
898
|
},
|
|
742
|
-
|
|
743
|
-
|
|
899
|
+
build: {
|
|
900
|
+
output: '.tova-out',
|
|
744
901
|
},
|
|
745
|
-
|
|
902
|
+
dev: {
|
|
903
|
+
port: 3000,
|
|
904
|
+
},
|
|
905
|
+
dependencies: {},
|
|
906
|
+
npm: {},
|
|
907
|
+
});
|
|
908
|
+
writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
|
|
909
|
+
|
|
910
|
+
// .gitignore
|
|
911
|
+
writeFileSync(join(projectDir, '.gitignore'), `node_modules/
|
|
912
|
+
.tova-out/
|
|
913
|
+
package.json
|
|
914
|
+
bun.lock
|
|
915
|
+
*.db
|
|
916
|
+
*.db-shm
|
|
917
|
+
*.db-wal
|
|
918
|
+
`);
|
|
746
919
|
|
|
747
920
|
// Main app file
|
|
748
921
|
writeFileSync(join(projectDir, 'src', 'app.tova'), `// ${name} — Built with Tova
|
|
@@ -786,24 +959,146 @@ Built with [Tova](https://github.com/tova-lang/tova-lang) — a modern full-stac
|
|
|
786
959
|
## Development
|
|
787
960
|
|
|
788
961
|
\`\`\`bash
|
|
789
|
-
|
|
790
|
-
|
|
962
|
+
tova install
|
|
963
|
+
tova dev
|
|
791
964
|
\`\`\`
|
|
792
965
|
|
|
793
966
|
## Build
|
|
794
967
|
|
|
795
968
|
\`\`\`bash
|
|
796
|
-
|
|
969
|
+
tova build
|
|
970
|
+
\`\`\`
|
|
971
|
+
|
|
972
|
+
## Add npm packages
|
|
973
|
+
|
|
974
|
+
\`\`\`bash
|
|
975
|
+
tova add htmx
|
|
976
|
+
tova add prettier --dev
|
|
797
977
|
\`\`\`
|
|
798
978
|
`);
|
|
799
979
|
|
|
800
|
-
console.log(` ✓ Created ${name}/
|
|
980
|
+
console.log(` ✓ Created ${name}/tova.toml`);
|
|
981
|
+
console.log(` ✓ Created ${name}/.gitignore`);
|
|
801
982
|
console.log(` ✓ Created ${name}/src/app.tova`);
|
|
802
983
|
console.log(` ✓ Created ${name}/README.md`);
|
|
803
984
|
console.log(`\n Get started:\n`);
|
|
804
985
|
console.log(` cd ${name}`);
|
|
805
|
-
console.log(`
|
|
806
|
-
console.log(`
|
|
986
|
+
console.log(` tova install`);
|
|
987
|
+
console.log(` tova dev\n`);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ─── Package Management ─────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
async function installDeps() {
|
|
993
|
+
const cwd = process.cwd();
|
|
994
|
+
const config = resolveConfig(cwd);
|
|
995
|
+
|
|
996
|
+
if (config._source !== 'tova.toml') {
|
|
997
|
+
// No tova.toml — just run bun install as normal
|
|
998
|
+
console.log(' No tova.toml found, running bun install...\n');
|
|
999
|
+
const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
|
|
1000
|
+
const code = await new Promise(res => proc.on('close', res));
|
|
1001
|
+
process.exit(code);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Generate shadow package.json from tova.toml
|
|
1006
|
+
const wrote = writePackageJson(config, cwd);
|
|
1007
|
+
if (wrote) {
|
|
1008
|
+
console.log(' Generated package.json from tova.toml');
|
|
1009
|
+
const proc = spawn('bun', ['install'], { stdio: 'inherit', cwd });
|
|
1010
|
+
const code = await new Promise(res => proc.on('close', res));
|
|
1011
|
+
process.exit(code);
|
|
1012
|
+
} else {
|
|
1013
|
+
console.log(' No npm dependencies in tova.toml. Nothing to install.\n');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function addDep(args) {
|
|
1018
|
+
const isDev = args.includes('--dev');
|
|
1019
|
+
const pkg = args.find(a => !a.startsWith('--'));
|
|
1020
|
+
|
|
1021
|
+
if (!pkg) {
|
|
1022
|
+
console.error('Error: No package specified');
|
|
1023
|
+
console.error('Usage: tova add <package> [--dev]');
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const cwd = process.cwd();
|
|
1028
|
+
const tomlPath = join(cwd, 'tova.toml');
|
|
1029
|
+
|
|
1030
|
+
if (!existsSync(tomlPath)) {
|
|
1031
|
+
console.error('Error: No tova.toml found in current directory');
|
|
1032
|
+
console.error('Run `tova new <name>` to create a new project, or create tova.toml manually.');
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Parse package name and version
|
|
1037
|
+
let name = pkg;
|
|
1038
|
+
let version = 'latest';
|
|
1039
|
+
if (pkg.includes('@') && !pkg.startsWith('@')) {
|
|
1040
|
+
const atIdx = pkg.lastIndexOf('@');
|
|
1041
|
+
name = pkg.slice(0, atIdx);
|
|
1042
|
+
version = pkg.slice(atIdx + 1);
|
|
1043
|
+
} else if (pkg.startsWith('@') && pkg.includes('@', 1)) {
|
|
1044
|
+
// Scoped package with version: @scope/name@version
|
|
1045
|
+
const atIdx = pkg.lastIndexOf('@');
|
|
1046
|
+
name = pkg.slice(0, atIdx);
|
|
1047
|
+
version = pkg.slice(atIdx + 1);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// If version is 'latest', resolve it via npm registry
|
|
1051
|
+
if (version === 'latest') {
|
|
1052
|
+
try {
|
|
1053
|
+
const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1054
|
+
let out = '';
|
|
1055
|
+
proc.stdout.on('data', d => out += d);
|
|
1056
|
+
const code = await new Promise(res => proc.on('close', res));
|
|
1057
|
+
if (code === 0 && out.trim()) {
|
|
1058
|
+
version = `^${out.trim()}`;
|
|
1059
|
+
} else {
|
|
1060
|
+
version = '*';
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
version = '*';
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const section = isDev ? 'npm.dev' : 'npm';
|
|
1068
|
+
addToSection(tomlPath, section, name, version);
|
|
1069
|
+
|
|
1070
|
+
console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
|
|
1071
|
+
|
|
1072
|
+
// Run install
|
|
1073
|
+
await installDeps();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function removeDep(pkg) {
|
|
1077
|
+
if (!pkg) {
|
|
1078
|
+
console.error('Error: No package specified');
|
|
1079
|
+
console.error('Usage: tova remove <package>');
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const cwd = process.cwd();
|
|
1084
|
+
const tomlPath = join(cwd, 'tova.toml');
|
|
1085
|
+
|
|
1086
|
+
if (!existsSync(tomlPath)) {
|
|
1087
|
+
console.error('Error: No tova.toml found in current directory');
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Try removing from [npm] first, then [npm.dev]
|
|
1092
|
+
const removed = removeFromSection(tomlPath, 'npm', pkg) ||
|
|
1093
|
+
removeFromSection(tomlPath, 'npm.dev', pkg);
|
|
1094
|
+
|
|
1095
|
+
if (removed) {
|
|
1096
|
+
console.log(` Removed ${pkg} from tova.toml`);
|
|
1097
|
+
await installDeps();
|
|
1098
|
+
} else {
|
|
1099
|
+
console.error(` Package '${pkg}' not found in tova.toml`);
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
807
1102
|
}
|
|
808
1103
|
|
|
809
1104
|
// ─── Migrations ─────────────────────────────────────────────
|
|
@@ -1026,10 +1321,74 @@ async function migrateStatus(args) {
|
|
|
1026
1321
|
|
|
1027
1322
|
function getStdlibForRuntime() {
|
|
1028
1323
|
return getFullStdlib(); // Full stdlib for REPL
|
|
1029
|
-
}
|
|
1030
|
-
function getRunStdlib() { //
|
|
1031
|
-
return
|
|
1032
|
-
|
|
1324
|
+
}
|
|
1325
|
+
function getRunStdlib() { // Only PROPAGATE — codegen tree-shakes stdlib into output.shared
|
|
1326
|
+
return PROPAGATE;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ─── npm Interop Utilities ───────────────────────────────────
|
|
1330
|
+
|
|
1331
|
+
function hasNpmImports(code) {
|
|
1332
|
+
// Match import statements with bare specifiers (not relative paths or runtime imports)
|
|
1333
|
+
const importRegex = /^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]+)['"];?\s*$/gm;
|
|
1334
|
+
let match;
|
|
1335
|
+
while ((match = importRegex.exec(code)) !== null) {
|
|
1336
|
+
const source = match[1];
|
|
1337
|
+
// Skip relative imports and runtime imports
|
|
1338
|
+
if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/') || source.startsWith('./runtime/')) {
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function bundleClientCode(clientCode, srcDir) {
|
|
1347
|
+
const tmpDir = join(srcDir, '.tova-out', '.tmp-bundle');
|
|
1348
|
+
try {
|
|
1349
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
1350
|
+
mkdirSync(join(tmpDir, 'runtime'), { recursive: true });
|
|
1351
|
+
|
|
1352
|
+
// Write runtime files so Bun.build can resolve ./runtime/ imports
|
|
1353
|
+
writeFileSync(join(tmpDir, 'runtime', 'reactivity.js'), REACTIVITY_SOURCE);
|
|
1354
|
+
writeFileSync(join(tmpDir, 'runtime', 'rpc.js'), RPC_SOURCE);
|
|
1355
|
+
writeFileSync(join(tmpDir, 'runtime', 'router.js'), ROUTER_SOURCE);
|
|
1356
|
+
|
|
1357
|
+
// Write client code as entrypoint
|
|
1358
|
+
const entryPath = join(tmpDir, '__entry.js');
|
|
1359
|
+
writeFileSync(entryPath, clientCode);
|
|
1360
|
+
|
|
1361
|
+
const result = await Bun.build({
|
|
1362
|
+
entrypoints: [entryPath],
|
|
1363
|
+
bundle: true,
|
|
1364
|
+
format: 'esm',
|
|
1365
|
+
target: 'browser',
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
if (!result.success) {
|
|
1369
|
+
const errors = result.logs.filter(l => l.level === 'error').map(l => l.message);
|
|
1370
|
+
// Check for missing package errors and provide actionable message
|
|
1371
|
+
const missingPkgs = errors
|
|
1372
|
+
.map(e => {
|
|
1373
|
+
const m = e.match(/Could not resolve ["']([^"']+)["']/);
|
|
1374
|
+
return m ? m[1] : null;
|
|
1375
|
+
})
|
|
1376
|
+
.filter(Boolean);
|
|
1377
|
+
if (missingPkgs.length > 0) {
|
|
1378
|
+
throw new Error(`Missing npm packages in client block. Run: bun install ${missingPkgs.join(' ')}`);
|
|
1379
|
+
}
|
|
1380
|
+
throw new Error(`Client bundling failed:\n${errors.join('\n')}`);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Read the bundled output
|
|
1384
|
+
const bundled = await result.outputs[0].text();
|
|
1385
|
+
return bundled;
|
|
1386
|
+
} finally {
|
|
1387
|
+
// Clean up temp files
|
|
1388
|
+
try {
|
|
1389
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
1390
|
+
} catch {}
|
|
1391
|
+
}
|
|
1033
1392
|
}
|
|
1034
1393
|
|
|
1035
1394
|
// ─── LSP Server ──────────────────────────────────────────────
|
|
@@ -1054,14 +1413,8 @@ async function startRepl() {
|
|
|
1054
1413
|
|
|
1055
1414
|
const context = {};
|
|
1056
1415
|
const stdlib = getStdlibForRuntime();
|
|
1057
|
-
//
|
|
1058
|
-
const
|
|
1059
|
-
const stdlibNames = [];
|
|
1060
|
-
let fnMatch;
|
|
1061
|
-
while ((fnMatch = fnNameRegex.exec(stdlib)) !== null) stdlibNames.push(fnMatch[1]);
|
|
1062
|
-
// Also include const bindings (Ok, Err, Some, None, __propagate)
|
|
1063
|
-
const constRegex = /\bconst\s+([a-zA-Z_]\w*)/g;
|
|
1064
|
-
while ((fnMatch = constRegex.exec(stdlib)) !== null) stdlibNames.push(fnMatch[1]);
|
|
1416
|
+
// Use authoritative BUILTIN_NAMES + runtime names (Ok, Err, Some, None, __propagate)
|
|
1417
|
+
const stdlibNames = [...BUILTIN_NAMES, 'Ok', 'Err', 'Some', 'None', '__propagate'];
|
|
1065
1418
|
const initFn = new Function(stdlib + '\nObject.assign(this, {' + stdlibNames.join(',') + '});');
|
|
1066
1419
|
initFn.call(context);
|
|
1067
1420
|
|
|
@@ -1136,8 +1489,12 @@ async function startRepl() {
|
|
|
1136
1489
|
evalCode = allButLast + (allButLast ? '\n' : '') + `return (${returnExpr});`;
|
|
1137
1490
|
}
|
|
1138
1491
|
try {
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1492
|
+
// Extract function/const names from compiled code to avoid shadowing conflicts
|
|
1493
|
+
const declaredInCode = new Set();
|
|
1494
|
+
for (const m of evalCode.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
1495
|
+
for (const m of evalCode.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
1496
|
+
const ctxKeys = Object.keys(context).filter(k => !declaredInCode.has(k));
|
|
1497
|
+
const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;` : '';
|
|
1141
1498
|
const fn = new Function('__ctx', `${destructure}\n${evalCode}`);
|
|
1142
1499
|
const result = fn(context);
|
|
1143
1500
|
if (result !== undefined) {
|
|
@@ -1145,8 +1502,11 @@ async function startRepl() {
|
|
|
1145
1502
|
}
|
|
1146
1503
|
} catch (e) {
|
|
1147
1504
|
// If return-wrapping fails, fall back to plain execution
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1505
|
+
const declaredInCode = new Set();
|
|
1506
|
+
for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
1507
|
+
for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
1508
|
+
const ctxKeys = Object.keys(context).filter(k => !declaredInCode.has(k));
|
|
1509
|
+
const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;` : '';
|
|
1150
1510
|
const fn = new Function('__ctx', `${destructure}\n${code}`);
|
|
1151
1511
|
fn(context);
|
|
1152
1512
|
}
|
|
@@ -1191,27 +1551,43 @@ function startWatcher(srcDir, callback) {
|
|
|
1191
1551
|
// ─── Source Map Support ──────────────────────────────────────
|
|
1192
1552
|
|
|
1193
1553
|
class SourceMapBuilder {
|
|
1194
|
-
constructor(sourceFile) {
|
|
1554
|
+
constructor(sourceFile, sourceFiles = null) {
|
|
1195
1555
|
this.sourceFile = sourceFile;
|
|
1556
|
+
// Multi-source support: array of all source files for merged output
|
|
1557
|
+
this.sourceFiles = sourceFiles || [sourceFile];
|
|
1558
|
+
this._sourceIndex = new Map();
|
|
1559
|
+
for (let i = 0; i < this.sourceFiles.length; i++) {
|
|
1560
|
+
this._sourceIndex.set(this.sourceFiles[i], i);
|
|
1561
|
+
}
|
|
1196
1562
|
this.mappings = [];
|
|
1197
1563
|
this.outputLine = 0;
|
|
1198
1564
|
this.outputCol = 0;
|
|
1199
1565
|
}
|
|
1200
1566
|
|
|
1201
|
-
addMapping(sourceLine, sourceCol, outputLine, outputCol) {
|
|
1202
|
-
this.
|
|
1567
|
+
addMapping(sourceLine, sourceCol, outputLine, outputCol, sourceFile = null) {
|
|
1568
|
+
const srcIdx = sourceFile ? (this._sourceIndex.get(sourceFile) || 0) : 0;
|
|
1569
|
+
this.mappings.push({ sourceLine, sourceCol, outputLine, outputCol, sourceIdx: srcIdx });
|
|
1203
1570
|
}
|
|
1204
1571
|
|
|
1205
1572
|
// Generate a VLQ-encoded source map
|
|
1206
|
-
generate(sourceContent) {
|
|
1207
|
-
const sources =
|
|
1573
|
+
generate(sourceContent, sourceContentsMap = null) {
|
|
1574
|
+
const sources = this.sourceFiles;
|
|
1208
1575
|
const names = [];
|
|
1209
1576
|
|
|
1577
|
+
// Build sourcesContent array for multi-source
|
|
1578
|
+
let sourcesContent;
|
|
1579
|
+
if (sourceContentsMap && sourceContentsMap instanceof Map) {
|
|
1580
|
+
sourcesContent = sources.map(s => sourceContentsMap.get(s) || null);
|
|
1581
|
+
} else if (sourceContent) {
|
|
1582
|
+
sourcesContent = [sourceContent];
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1210
1585
|
// Sort mappings by output position
|
|
1211
1586
|
this.mappings.sort((a, b) => a.outputLine - b.outputLine || a.outputCol - b.outputCol);
|
|
1212
1587
|
|
|
1213
1588
|
// Encode mappings using VLQ
|
|
1214
1589
|
let prevOutputCol = 0;
|
|
1590
|
+
let prevSourceIdx = 0;
|
|
1215
1591
|
let prevSourceLine = 0;
|
|
1216
1592
|
let prevSourceCol = 0;
|
|
1217
1593
|
let currentOutputLine = 0;
|
|
@@ -1229,22 +1605,24 @@ class SourceMapBuilder {
|
|
|
1229
1605
|
|
|
1230
1606
|
const segment = [];
|
|
1231
1607
|
segment.push(this._vlqEncode(m.outputCol - prevOutputCol));
|
|
1232
|
-
segment.push(this._vlqEncode(
|
|
1608
|
+
segment.push(this._vlqEncode(m.sourceIdx - prevSourceIdx));
|
|
1233
1609
|
segment.push(this._vlqEncode(m.sourceLine - prevSourceLine));
|
|
1234
1610
|
segment.push(this._vlqEncode(m.sourceCol - prevSourceCol));
|
|
1235
1611
|
|
|
1236
1612
|
currentLine.push(segment.join(''));
|
|
1237
1613
|
prevOutputCol = m.outputCol;
|
|
1614
|
+
prevSourceIdx = m.sourceIdx;
|
|
1238
1615
|
prevSourceLine = m.sourceLine;
|
|
1239
1616
|
prevSourceCol = m.sourceCol;
|
|
1240
1617
|
}
|
|
1241
1618
|
lines.push(currentLine.join(','));
|
|
1242
1619
|
|
|
1620
|
+
const outFile = typeof this.sourceFile === 'string' ? this.sourceFile.replace('.tova', '.js') : 'merged.js';
|
|
1243
1621
|
return JSON.stringify({
|
|
1244
1622
|
version: 3,
|
|
1245
|
-
file:
|
|
1623
|
+
file: outFile,
|
|
1246
1624
|
sources,
|
|
1247
|
-
sourcesContent:
|
|
1625
|
+
sourcesContent: sourcesContent || undefined,
|
|
1248
1626
|
names,
|
|
1249
1627
|
mappings: lines.join(';'),
|
|
1250
1628
|
});
|
|
@@ -1263,8 +1641,8 @@ class SourceMapBuilder {
|
|
|
1263
1641
|
return encoded;
|
|
1264
1642
|
}
|
|
1265
1643
|
|
|
1266
|
-
toDataURL(sourceContent) {
|
|
1267
|
-
const mapJson = this.generate(sourceContent);
|
|
1644
|
+
toDataURL(sourceContent, sourceContentsMap = null) {
|
|
1645
|
+
const mapJson = this.generate(sourceContent, sourceContentsMap);
|
|
1268
1646
|
const base64 = Buffer.from(mapJson).toString('base64');
|
|
1269
1647
|
return `//# sourceMappingURL=data:application/json;base64,${base64}`;
|
|
1270
1648
|
}
|
|
@@ -1317,11 +1695,22 @@ async function productionBuild(srcDir, outDir) {
|
|
|
1317
1695
|
|
|
1318
1696
|
// Write client bundle
|
|
1319
1697
|
if (allClientCode.trim()) {
|
|
1320
|
-
const
|
|
1321
|
-
const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
|
|
1698
|
+
const fullClientModule = allSharedCode + '\n' + allClientCode;
|
|
1322
1699
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1700
|
+
let clientBundle;
|
|
1701
|
+
let useModule = false;
|
|
1702
|
+
|
|
1703
|
+
if (hasNpmImports(fullClientModule)) {
|
|
1704
|
+
// npm imports detected — bundle with Bun.build to resolve bare specifiers
|
|
1705
|
+
clientBundle = await bundleClientCode(fullClientModule, srcDir);
|
|
1706
|
+
useModule = true;
|
|
1707
|
+
} else {
|
|
1708
|
+
// No npm imports — inline runtime, strip all imports
|
|
1709
|
+
const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
|
|
1710
|
+
const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
|
|
1711
|
+
clientBundle = reactivityCode + '\n' + rpcCode + '\n' + allSharedCode + '\n' +
|
|
1712
|
+
allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
|
|
1713
|
+
}
|
|
1325
1714
|
|
|
1326
1715
|
const hash = hashCode(clientBundle);
|
|
1327
1716
|
const clientPath = join(outDir, `client.${hash}.js`);
|
|
@@ -1329,6 +1718,9 @@ async function productionBuild(srcDir, outDir) {
|
|
|
1329
1718
|
console.log(` client.${hash}.js`);
|
|
1330
1719
|
|
|
1331
1720
|
// Generate production HTML
|
|
1721
|
+
const scriptTag = useModule
|
|
1722
|
+
? `<script type="module" src="client.${hash}.js"></script>`
|
|
1723
|
+
: `<script src="client.${hash}.js"></script>`;
|
|
1332
1724
|
const html = `<!DOCTYPE html>
|
|
1333
1725
|
<html lang="en">
|
|
1334
1726
|
<head>
|
|
@@ -1338,7 +1730,7 @@ async function productionBuild(srcDir, outDir) {
|
|
|
1338
1730
|
</head>
|
|
1339
1731
|
<body>
|
|
1340
1732
|
<div id="app"></div>
|
|
1341
|
-
|
|
1733
|
+
${scriptTag}
|
|
1342
1734
|
</body>
|
|
1343
1735
|
</html>`;
|
|
1344
1736
|
writeFileSync(join(outDir, 'index.html'), html);
|
|
@@ -1378,7 +1770,8 @@ const moduleExports = new Map();
|
|
|
1378
1770
|
function collectExports(ast, filename) {
|
|
1379
1771
|
const publicExports = new Set();
|
|
1380
1772
|
const allNames = new Set();
|
|
1381
|
-
|
|
1773
|
+
|
|
1774
|
+
function collectFromNode(node) {
|
|
1382
1775
|
if (node.type === 'FunctionDeclaration') {
|
|
1383
1776
|
allNames.add(node.name);
|
|
1384
1777
|
if (node.isPublic) publicExports.add(node.name);
|
|
@@ -1421,6 +1814,19 @@ function collectExports(ast, filename) {
|
|
|
1421
1814
|
}
|
|
1422
1815
|
if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
|
|
1423
1816
|
}
|
|
1817
|
+
|
|
1818
|
+
for (const node of ast.body) {
|
|
1819
|
+
// Also collect exports from inside shared/server/client blocks
|
|
1820
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
|
|
1821
|
+
if (node.body) {
|
|
1822
|
+
for (const inner of node.body) {
|
|
1823
|
+
collectFromNode(inner);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
collectFromNode(node);
|
|
1829
|
+
}
|
|
1424
1830
|
moduleExports.set(filename, { publicExports, allNames });
|
|
1425
1831
|
return { publicExports, allNames };
|
|
1426
1832
|
}
|
|
@@ -1509,6 +1915,257 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
1509
1915
|
}
|
|
1510
1916
|
}
|
|
1511
1917
|
|
|
1918
|
+
// ─── Multi-file Directory Merging ────────────────────────────
|
|
1919
|
+
|
|
1920
|
+
function validateMergedAST(mergedBlocks, sourceFiles) {
|
|
1921
|
+
const errors = [];
|
|
1922
|
+
|
|
1923
|
+
function addDup(kind, name, loc1, loc2) {
|
|
1924
|
+
const f1 = loc1.source || 'unknown';
|
|
1925
|
+
const f2 = loc2.source || 'unknown';
|
|
1926
|
+
errors.push(
|
|
1927
|
+
`Duplicate ${kind} '${name}'\n` +
|
|
1928
|
+
` → first defined in ${basename(f1)}:${loc1.line}\n` +
|
|
1929
|
+
` → also defined in ${basename(f2)}:${loc2.line}`
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Check client blocks — top-level declarations only
|
|
1934
|
+
const clientDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
|
|
1935
|
+
for (const block of mergedBlocks.clientBlocks) {
|
|
1936
|
+
for (const stmt of block.body) {
|
|
1937
|
+
const loc = stmt.loc || block.loc;
|
|
1938
|
+
if (stmt.type === 'ComponentDeclaration') {
|
|
1939
|
+
if (clientDecls.component.has(stmt.name)) addDup('component', stmt.name, clientDecls.component.get(stmt.name), loc);
|
|
1940
|
+
else clientDecls.component.set(stmt.name, loc);
|
|
1941
|
+
} else if (stmt.type === 'StateDeclaration') {
|
|
1942
|
+
const name = stmt.name || (stmt.targets && stmt.targets[0]);
|
|
1943
|
+
if (name) {
|
|
1944
|
+
if (clientDecls.state.has(name)) addDup('state', name, clientDecls.state.get(name), loc);
|
|
1945
|
+
else clientDecls.state.set(name, loc);
|
|
1946
|
+
}
|
|
1947
|
+
} else if (stmt.type === 'ComputedDeclaration') {
|
|
1948
|
+
const name = stmt.name;
|
|
1949
|
+
if (name) {
|
|
1950
|
+
if (clientDecls.computed.has(name)) addDup('computed', name, clientDecls.computed.get(name), loc);
|
|
1951
|
+
else clientDecls.computed.set(name, loc);
|
|
1952
|
+
}
|
|
1953
|
+
} else if (stmt.type === 'StoreDeclaration') {
|
|
1954
|
+
if (clientDecls.store.has(stmt.name)) addDup('store', stmt.name, clientDecls.store.get(stmt.name), loc);
|
|
1955
|
+
else clientDecls.store.set(stmt.name, loc);
|
|
1956
|
+
} else if (stmt.type === 'FunctionDeclaration') {
|
|
1957
|
+
if (clientDecls.fn.has(stmt.name)) addDup('function', stmt.name, clientDecls.fn.get(stmt.name), loc);
|
|
1958
|
+
else clientDecls.fn.set(stmt.name, loc);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Check server blocks — group by block name, check within same-name groups
|
|
1964
|
+
const serverGrouped = new Map();
|
|
1965
|
+
for (const block of mergedBlocks.serverBlocks) {
|
|
1966
|
+
const key = block.name || null;
|
|
1967
|
+
if (!serverGrouped.has(key)) serverGrouped.set(key, []);
|
|
1968
|
+
serverGrouped.get(key).push(block);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
for (const [, blocks] of serverGrouped) {
|
|
1972
|
+
const serverDecls = { fn: new Map(), model: new Map(), route: new Map() };
|
|
1973
|
+
const singletons = new Map(); // db, cors, auth, session, etc.
|
|
1974
|
+
const SINGLETON_TYPES = {
|
|
1975
|
+
'DbDeclaration': 'db', 'CorsDeclaration': 'cors', 'AuthDeclaration': 'auth',
|
|
1976
|
+
'SessionDeclaration': 'session', 'CompressionDeclaration': 'compression',
|
|
1977
|
+
'TlsDeclaration': 'tls', 'UploadDeclaration': 'upload', 'RateLimitDeclaration': 'rate_limit',
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
for (const block of blocks) {
|
|
1981
|
+
const walkBody = (stmts) => {
|
|
1982
|
+
for (const stmt of stmts) {
|
|
1983
|
+
const loc = stmt.loc || block.loc;
|
|
1984
|
+
if (stmt.type === 'FunctionDeclaration') {
|
|
1985
|
+
if (serverDecls.fn.has(stmt.name)) addDup('server function', stmt.name, serverDecls.fn.get(stmt.name), loc);
|
|
1986
|
+
else serverDecls.fn.set(stmt.name, loc);
|
|
1987
|
+
} else if (stmt.type === 'ModelDeclaration') {
|
|
1988
|
+
if (serverDecls.model.has(stmt.name)) addDup('model', stmt.name, serverDecls.model.get(stmt.name), loc);
|
|
1989
|
+
else serverDecls.model.set(stmt.name, loc);
|
|
1990
|
+
} else if (stmt.type === 'RouteDeclaration') {
|
|
1991
|
+
const routeKey = `${stmt.method} ${stmt.path}`;
|
|
1992
|
+
if (serverDecls.route.has(routeKey)) addDup('route', routeKey, serverDecls.route.get(routeKey), loc);
|
|
1993
|
+
else serverDecls.route.set(routeKey, loc);
|
|
1994
|
+
} else if (SINGLETON_TYPES[stmt.type]) {
|
|
1995
|
+
const sName = SINGLETON_TYPES[stmt.type];
|
|
1996
|
+
if (singletons.has(sName)) addDup('server config', sName, singletons.get(sName), loc);
|
|
1997
|
+
else singletons.set(sName, loc);
|
|
1998
|
+
} else if (stmt.type === 'RouteGroupDeclaration') {
|
|
1999
|
+
walkBody(stmt.body);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
};
|
|
2003
|
+
walkBody(block.body);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// Check shared blocks
|
|
2008
|
+
const sharedDecls = { type: new Map(), fn: new Map(), interface: new Map() };
|
|
2009
|
+
for (const block of mergedBlocks.sharedBlocks) {
|
|
2010
|
+
for (const stmt of block.body) {
|
|
2011
|
+
const loc = stmt.loc || block.loc;
|
|
2012
|
+
if (stmt.type === 'TypeDeclaration') {
|
|
2013
|
+
if (sharedDecls.type.has(stmt.name)) addDup('type', stmt.name, sharedDecls.type.get(stmt.name), loc);
|
|
2014
|
+
else sharedDecls.type.set(stmt.name, loc);
|
|
2015
|
+
} else if (stmt.type === 'FunctionDeclaration') {
|
|
2016
|
+
if (sharedDecls.fn.has(stmt.name)) addDup('shared function', stmt.name, sharedDecls.fn.get(stmt.name), loc);
|
|
2017
|
+
else sharedDecls.fn.set(stmt.name, loc);
|
|
2018
|
+
} else if (stmt.type === 'InterfaceDeclaration' || stmt.type === 'TraitDeclaration') {
|
|
2019
|
+
if (sharedDecls.interface.has(stmt.name)) addDup('interface/trait', stmt.name, sharedDecls.interface.get(stmt.name), loc);
|
|
2020
|
+
else sharedDecls.interface.set(stmt.name, loc);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (errors.length > 0) {
|
|
2026
|
+
throw new Error('Merge validation failed:\n\n' + errors.join('\n\n'));
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function mergeDirectory(dir, srcDir, options = {}) {
|
|
2031
|
+
// Find all .tova files in this directory only (non-recursive)
|
|
2032
|
+
const entries = readdirSync(dir);
|
|
2033
|
+
const tovaFiles = entries
|
|
2034
|
+
.filter(e => e.endsWith('.tova') && !e.startsWith('.'))
|
|
2035
|
+
.map(e => join(dir, e))
|
|
2036
|
+
.filter(f => statSync(f).isFile())
|
|
2037
|
+
.sort();
|
|
2038
|
+
|
|
2039
|
+
if (tovaFiles.length === 0) return null;
|
|
2040
|
+
if (tovaFiles.length === 1) {
|
|
2041
|
+
// Single file — use existing per-file compilation
|
|
2042
|
+
const file = tovaFiles[0];
|
|
2043
|
+
const source = readFileSync(file, 'utf-8');
|
|
2044
|
+
return { output: compileWithImports(source, file, srcDir), files: [file], single: true };
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// Parse all files in the directory
|
|
2048
|
+
const parsedFiles = [];
|
|
2049
|
+
for (const file of tovaFiles) {
|
|
2050
|
+
const source = readFileSync(file, 'utf-8');
|
|
2051
|
+
const lexer = new Lexer(source, file);
|
|
2052
|
+
const tokens = lexer.tokenize();
|
|
2053
|
+
const parser = new Parser(tokens, file);
|
|
2054
|
+
const ast = parser.parse();
|
|
2055
|
+
|
|
2056
|
+
// Collect exports for cross-file import validation
|
|
2057
|
+
collectExports(ast, file);
|
|
2058
|
+
|
|
2059
|
+
// Resolve cross-directory .tova imports (same logic as compileWithImports)
|
|
2060
|
+
for (const node of ast.body) {
|
|
2061
|
+
if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
|
|
2062
|
+
const importPath = resolve(dirname(file), node.source);
|
|
2063
|
+
// Only process imports from OTHER directories (same-dir files are merged)
|
|
2064
|
+
if (dirname(importPath) !== dir) {
|
|
2065
|
+
if (compilationInProgress.has(importPath)) {
|
|
2066
|
+
throw new Error(`Circular import detected: ${file} → ${importPath}`);
|
|
2067
|
+
} else if (existsSync(importPath) && !compilationCache.has(importPath)) {
|
|
2068
|
+
const importSource = readFileSync(importPath, 'utf-8');
|
|
2069
|
+
compileWithImports(importSource, importPath, srcDir);
|
|
2070
|
+
}
|
|
2071
|
+
// Validate imported names
|
|
2072
|
+
if (node.type === 'ImportDeclaration' && moduleExports.has(importPath)) {
|
|
2073
|
+
const { publicExports, allNames } = moduleExports.get(importPath);
|
|
2074
|
+
for (const spec of node.specifiers) {
|
|
2075
|
+
if (!publicExports.has(spec.imported)) {
|
|
2076
|
+
if (allNames.has(spec.imported)) {
|
|
2077
|
+
throw new Error(`'${spec.imported}' is private in module '${node.source}'. Add 'pub' to export it.`);
|
|
2078
|
+
} else {
|
|
2079
|
+
throw new Error(`Module '${node.source}' does not export '${spec.imported}'`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
// Rewrite to .js
|
|
2085
|
+
node.source = node.source.replace('.tova', '.shared.js');
|
|
2086
|
+
} else {
|
|
2087
|
+
// Same-directory import — remove it since files are merged
|
|
2088
|
+
node._removed = true;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
parsedFiles.push({ file, source, ast });
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// Merge ASTs: collect blocks from all files, tagged with source file
|
|
2097
|
+
const mergedBody = [];
|
|
2098
|
+
const sharedBlocks = [];
|
|
2099
|
+
const serverBlocks = [];
|
|
2100
|
+
const clientBlocks = [];
|
|
2101
|
+
|
|
2102
|
+
for (const { file, ast } of parsedFiles) {
|
|
2103
|
+
for (const node of ast.body) {
|
|
2104
|
+
// Skip removed same-directory imports
|
|
2105
|
+
if (node._removed) continue;
|
|
2106
|
+
|
|
2107
|
+
// Tag node with source file for source maps and error reporting
|
|
2108
|
+
if (node.loc) node.loc.source = file;
|
|
2109
|
+
else node.loc = { line: 1, column: 0, source: file };
|
|
2110
|
+
|
|
2111
|
+
// Tag children too
|
|
2112
|
+
if (node.body && Array.isArray(node.body)) {
|
|
2113
|
+
for (const child of node.body) {
|
|
2114
|
+
if (child.loc) child.loc.source = file;
|
|
2115
|
+
else child.loc = { line: 1, column: 0, source: file };
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (node.type === 'SharedBlock') sharedBlocks.push(node);
|
|
2120
|
+
else if (node.type === 'ServerBlock') serverBlocks.push(node);
|
|
2121
|
+
else if (node.type === 'ClientBlock') clientBlocks.push(node);
|
|
2122
|
+
|
|
2123
|
+
mergedBody.push(node);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Validate for duplicate declarations across files
|
|
2128
|
+
validateMergedAST({ sharedBlocks, serverBlocks, clientBlocks }, tovaFiles);
|
|
2129
|
+
|
|
2130
|
+
// Build merged Program AST
|
|
2131
|
+
const mergedAST = new Program(mergedBody);
|
|
2132
|
+
|
|
2133
|
+
// Run analyzer on merged AST
|
|
2134
|
+
const analyzer = new Analyzer(mergedAST, dir);
|
|
2135
|
+
const { warnings } = analyzer.analyze();
|
|
2136
|
+
|
|
2137
|
+
if (warnings.length > 0) {
|
|
2138
|
+
for (const w of warnings) {
|
|
2139
|
+
console.warn(` Warning: ${w.message} (line ${w.line})`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Run codegen on merged AST
|
|
2144
|
+
const codegen = new CodeGenerator(mergedAST, dir);
|
|
2145
|
+
const output = codegen.generate();
|
|
2146
|
+
|
|
2147
|
+
// Collect source content from all files for source maps
|
|
2148
|
+
const sourceContents = new Map();
|
|
2149
|
+
for (const { file, source } of parsedFiles) {
|
|
2150
|
+
sourceContents.set(file, source);
|
|
2151
|
+
}
|
|
2152
|
+
output._sourceContents = sourceContents;
|
|
2153
|
+
output._sourceFiles = tovaFiles;
|
|
2154
|
+
|
|
2155
|
+
return { output, files: tovaFiles, single: false };
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Group .tova files by their parent directory
|
|
2159
|
+
function groupFilesByDirectory(files) {
|
|
2160
|
+
const groups = new Map();
|
|
2161
|
+
for (const file of files) {
|
|
2162
|
+
const dir = dirname(file);
|
|
2163
|
+
if (!groups.has(dir)) groups.set(dir, []);
|
|
2164
|
+
groups.get(dir).push(file);
|
|
2165
|
+
}
|
|
2166
|
+
return groups;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
1512
2169
|
function findFiles(dir, ext) {
|
|
1513
2170
|
const results = [];
|
|
1514
2171
|
if (!existsSync(dir)) return results;
|