tova 0.3.0 → 0.3.1
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 +1401 -111
- package/package.json +3 -1
- package/src/analyzer/analyzer.js +831 -709
- package/src/analyzer/client-analyzer.js +191 -0
- package/src/analyzer/server-analyzer.js +467 -0
- package/src/analyzer/types.js +20 -4
- package/src/codegen/base-codegen.js +467 -109
- package/src/codegen/client-codegen.js +92 -42
- package/src/codegen/codegen.js +65 -5
- package/src/codegen/server-codegen.js +290 -36
- package/src/diagnostics/error-codes.js +255 -0
- package/src/diagnostics/formatter.js +150 -28
- package/src/docs/generator.js +390 -0
- package/src/lexer/lexer.js +305 -63
- package/src/lexer/tokens.js +19 -0
- package/src/lsp/server.js +892 -30
- package/src/parser/ast.js +81 -368
- package/src/parser/client-ast.js +138 -0
- package/src/parser/client-parser.js +504 -0
- package/src/parser/parser.js +491 -1064
- package/src/parser/server-ast.js +240 -0
- package/src/parser/server-parser.js +602 -0
- package/src/runtime/array-proto.js +32 -0
- package/src/runtime/embedded.js +1 -1
- package/src/runtime/reactivity.js +191 -10
- package/src/stdlib/advanced-collections.js +81 -0
- package/src/stdlib/inline.js +549 -6
- package/src/version.js +1 -1
package/bin/tova.js
CHANGED
|
@@ -9,11 +9,13 @@ import { Parser } from '../src/parser/parser.js';
|
|
|
9
9
|
import { Analyzer } from '../src/analyzer/analyzer.js';
|
|
10
10
|
import { Program } from '../src/parser/ast.js';
|
|
11
11
|
import { CodeGenerator } from '../src/codegen/codegen.js';
|
|
12
|
-
import { richError, formatDiagnostics, DiagnosticFormatter } from '../src/diagnostics/formatter.js';
|
|
12
|
+
import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from '../src/diagnostics/formatter.js';
|
|
13
|
+
import { getExplanation, lookupCode } from '../src/diagnostics/error-codes.js';
|
|
13
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 '../src/runtime/array-proto.js';
|
|
17
19
|
import { resolveConfig } from '../src/config/resolve.js';
|
|
18
20
|
import { writePackageJson } from '../src/config/package-json.js';
|
|
19
21
|
import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
|
|
@@ -35,6 +37,8 @@ Usage:
|
|
|
35
37
|
Commands:
|
|
36
38
|
run <file> Compile and execute a .tova file
|
|
37
39
|
build [dir] Compile .tova files to JavaScript (default: current dir)
|
|
40
|
+
check [dir] Type-check .tova files without generating code
|
|
41
|
+
clean Delete .tova-out build artifacts
|
|
38
42
|
dev Start development server with live reload
|
|
39
43
|
new <name> Create a new Tova project
|
|
40
44
|
install Install npm dependencies from tova.toml
|
|
@@ -43,17 +47,25 @@ Commands:
|
|
|
43
47
|
repl Start interactive Tova REPL
|
|
44
48
|
lsp Start Language Server Protocol server
|
|
45
49
|
fmt <file> Format a .tova file (--check to verify only)
|
|
46
|
-
test [dir] Run test blocks in .tova files (--filter, --watch)
|
|
50
|
+
test [dir] Run test blocks in .tova files (--filter, --watch, --coverage, --serial)
|
|
51
|
+
bench [dir] Run bench blocks in .tova files
|
|
52
|
+
doc [dir] Generate documentation from /// docstrings
|
|
53
|
+
init Initialize a Tova project in the current directory
|
|
47
54
|
migrate:create <name> Create a new migration file
|
|
48
55
|
migrate:up [file.tova] Run pending migrations
|
|
49
56
|
migrate:status [file.tova] Show migration status
|
|
57
|
+
upgrade Upgrade Tova to the latest version
|
|
58
|
+
info Show Tova version, Bun version, project config, and installed dependencies
|
|
59
|
+
explain <code> Show detailed explanation for an error/warning code (e.g., tova explain E202)
|
|
50
60
|
|
|
51
61
|
Options:
|
|
52
62
|
--help, -h Show this help message
|
|
53
63
|
--version, -v Show version
|
|
54
64
|
--output, -o Output directory (default: .tova-out)
|
|
55
65
|
--production Production build (minify, bundle, hash)
|
|
56
|
-
--watch Watch for file changes
|
|
66
|
+
--watch Watch for file changes and rebuild
|
|
67
|
+
--verbose Show detailed output during compilation
|
|
68
|
+
--quiet Suppress non-error output
|
|
57
69
|
--debug Show verbose error output
|
|
58
70
|
--strict Enable strict type checking
|
|
59
71
|
`;
|
|
@@ -75,12 +87,24 @@ async function main() {
|
|
|
75
87
|
|
|
76
88
|
const isStrict = args.includes('--strict');
|
|
77
89
|
switch (command) {
|
|
78
|
-
case 'run':
|
|
79
|
-
|
|
90
|
+
case 'run': {
|
|
91
|
+
const runArgs = args.filter(a => a !== '--strict');
|
|
92
|
+
const filePath = runArgs[1];
|
|
93
|
+
const restArgs = runArgs.slice(2);
|
|
94
|
+
const ddIdx = restArgs.indexOf('--');
|
|
95
|
+
const scriptArgs = ddIdx !== -1 ? restArgs.slice(ddIdx + 1) : restArgs;
|
|
96
|
+
await runFile(filePath, { strict: isStrict, scriptArgs });
|
|
80
97
|
break;
|
|
98
|
+
}
|
|
81
99
|
case 'build':
|
|
82
100
|
await buildProject(args.slice(1));
|
|
83
101
|
break;
|
|
102
|
+
case 'check':
|
|
103
|
+
await checkProject(args.slice(1));
|
|
104
|
+
break;
|
|
105
|
+
case 'clean':
|
|
106
|
+
cleanBuild(args.slice(1));
|
|
107
|
+
break;
|
|
84
108
|
case 'dev':
|
|
85
109
|
await devServer(args.slice(1));
|
|
86
110
|
break;
|
|
@@ -93,6 +117,9 @@ async function main() {
|
|
|
93
117
|
case 'new':
|
|
94
118
|
newProject(args[1]);
|
|
95
119
|
break;
|
|
120
|
+
case 'init':
|
|
121
|
+
initProject();
|
|
122
|
+
break;
|
|
96
123
|
case 'install':
|
|
97
124
|
await installDeps();
|
|
98
125
|
break;
|
|
@@ -108,18 +135,62 @@ async function main() {
|
|
|
108
135
|
case 'test':
|
|
109
136
|
await runTests(args.slice(1));
|
|
110
137
|
break;
|
|
138
|
+
case 'bench':
|
|
139
|
+
await runBench(args.slice(1));
|
|
140
|
+
break;
|
|
141
|
+
case 'doc':
|
|
142
|
+
await generateDocs(args.slice(1));
|
|
143
|
+
break;
|
|
111
144
|
case 'migrate:create':
|
|
112
145
|
migrateCreate(args[1]);
|
|
113
146
|
break;
|
|
114
147
|
case 'migrate:up':
|
|
115
148
|
await migrateUp(args.slice(1));
|
|
116
149
|
break;
|
|
150
|
+
case 'migrate:down':
|
|
151
|
+
await migrateDown(args.slice(1));
|
|
152
|
+
break;
|
|
153
|
+
case 'migrate:reset':
|
|
154
|
+
await migrateReset(args.slice(1));
|
|
155
|
+
break;
|
|
156
|
+
case 'migrate:fresh':
|
|
157
|
+
await migrateFresh(args.slice(1));
|
|
158
|
+
break;
|
|
117
159
|
case 'migrate:status':
|
|
118
160
|
await migrateStatus(args.slice(1));
|
|
119
161
|
break;
|
|
162
|
+
case 'explain': {
|
|
163
|
+
const code = args[1];
|
|
164
|
+
if (!code) {
|
|
165
|
+
console.error('Usage: tova explain <error-code> (e.g., tova explain E202)');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const info = lookupCode(code);
|
|
169
|
+
if (!info) {
|
|
170
|
+
console.error(`Unknown error code: ${code}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
const explanation = getExplanation(code);
|
|
174
|
+
console.log(`\n ${code}: ${info.title} [${info.category}]\n`);
|
|
175
|
+
if (explanation) {
|
|
176
|
+
console.log(explanation);
|
|
177
|
+
} else {
|
|
178
|
+
console.log(` No detailed explanation available yet for ${code}.\n`);
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'upgrade':
|
|
183
|
+
await upgradeCommand();
|
|
184
|
+
break;
|
|
185
|
+
case 'info':
|
|
186
|
+
await infoCommand();
|
|
187
|
+
break;
|
|
120
188
|
default:
|
|
121
189
|
if (command.endsWith('.tova')) {
|
|
122
|
-
|
|
190
|
+
const directArgs = args.filter(a => a !== '--strict').slice(1);
|
|
191
|
+
const ddIdx = directArgs.indexOf('--');
|
|
192
|
+
const scriptArgs = ddIdx !== -1 ? directArgs.slice(ddIdx + 1) : directArgs;
|
|
193
|
+
await runFile(command, { strict: isStrict, scriptArgs });
|
|
123
194
|
} else {
|
|
124
195
|
console.error(`Unknown command: ${command}`);
|
|
125
196
|
console.log(HELP);
|
|
@@ -143,7 +214,7 @@ function compileTova(source, filename, options = {}) {
|
|
|
143
214
|
if (warnings.length > 0) {
|
|
144
215
|
const formatter = new DiagnosticFormatter(source, filename);
|
|
145
216
|
for (const w of warnings) {
|
|
146
|
-
console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }));
|
|
217
|
+
console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
|
|
147
218
|
}
|
|
148
219
|
}
|
|
149
220
|
|
|
@@ -205,15 +276,23 @@ function formatFile(args) {
|
|
|
205
276
|
async function runTests(args) {
|
|
206
277
|
const filterPattern = args.find((a, i) => args[i - 1] === '--filter') || null;
|
|
207
278
|
const watchMode = args.includes('--watch');
|
|
279
|
+
const coverageMode = args.includes('--coverage');
|
|
280
|
+
const serialMode = args.includes('--serial');
|
|
208
281
|
const targetDir = args.find(a => !a.startsWith('--') && a !== filterPattern) || '.';
|
|
209
282
|
|
|
210
|
-
// Find all .tova files with test blocks
|
|
283
|
+
// Find all .tova files with test blocks + dedicated test files (*.test.tova, *_test.tova)
|
|
211
284
|
const tovaFiles = findTovaFiles(resolve(targetDir));
|
|
212
285
|
const testFiles = [];
|
|
213
286
|
|
|
214
287
|
for (const file of tovaFiles) {
|
|
288
|
+
const base = basename(file);
|
|
289
|
+
// Dedicated test files are always included
|
|
290
|
+
if (base.endsWith('.test.tova') || base.endsWith('_test.tova')) {
|
|
291
|
+
testFiles.push(file);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
215
294
|
const source = readFileSync(file, 'utf-8');
|
|
216
|
-
// Quick check for test blocks
|
|
295
|
+
// Quick check for inline test blocks
|
|
217
296
|
if (/\btest\s+["'{]/m.test(source) || /\btest\s*\{/m.test(source)) {
|
|
218
297
|
testFiles.push(file);
|
|
219
298
|
}
|
|
@@ -235,7 +314,17 @@ async function runTests(args) {
|
|
|
235
314
|
for (const file of testFiles) {
|
|
236
315
|
try {
|
|
237
316
|
const source = readFileSync(file, 'utf-8');
|
|
238
|
-
const
|
|
317
|
+
const base = basename(file);
|
|
318
|
+
const isDedicatedTestFile = base.endsWith('.test.tova') || base.endsWith('_test.tova');
|
|
319
|
+
|
|
320
|
+
// For dedicated test files without explicit test blocks, wrap entire file in one
|
|
321
|
+
let sourceToCompile = source;
|
|
322
|
+
if (isDedicatedTestFile && !/\btest\s+["'{]/m.test(source) && !/\btest\s*\{/m.test(source)) {
|
|
323
|
+
const testName = base.replace(/\.(test|_test)\.tova$/, '').replace(/_test\.tova$/, '');
|
|
324
|
+
sourceToCompile = `test "${testName}" {\n${source}\n}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const lexer = new Lexer(sourceToCompile, file);
|
|
239
328
|
const tokens = lexer.tokenize();
|
|
240
329
|
const parser = new Parser(tokens, file);
|
|
241
330
|
const ast = parser.parse();
|
|
@@ -273,6 +362,13 @@ async function runTests(args) {
|
|
|
273
362
|
if (filterPattern) {
|
|
274
363
|
bunArgs.push('-t', filterPattern);
|
|
275
364
|
}
|
|
365
|
+
if (coverageMode) {
|
|
366
|
+
bunArgs.push('--coverage');
|
|
367
|
+
}
|
|
368
|
+
if (serialMode) {
|
|
369
|
+
// Force sequential execution (bun runs files in parallel by default)
|
|
370
|
+
bunArgs.push('--concurrency', '1');
|
|
371
|
+
}
|
|
276
372
|
|
|
277
373
|
const runBunTest = () => {
|
|
278
374
|
return new Promise((res) => {
|
|
@@ -299,6 +395,113 @@ async function runTests(args) {
|
|
|
299
395
|
}
|
|
300
396
|
}
|
|
301
397
|
|
|
398
|
+
async function runBench(args) {
|
|
399
|
+
const targetDir = args.find(a => !a.startsWith('--')) || '.';
|
|
400
|
+
const tovaFiles = findTovaFiles(resolve(targetDir));
|
|
401
|
+
const benchFiles = [];
|
|
402
|
+
|
|
403
|
+
for (const file of tovaFiles) {
|
|
404
|
+
const source = readFileSync(file, 'utf-8');
|
|
405
|
+
if (/\bbench\s+["'{]/m.test(source) || /\bbench\s*\{/m.test(source)) {
|
|
406
|
+
benchFiles.push(file);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (benchFiles.length === 0) {
|
|
411
|
+
console.log('No bench files found.');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(`Found ${benchFiles.length} bench file(s)\n`);
|
|
416
|
+
|
|
417
|
+
const tmpDir = resolve('.tova-bench-out');
|
|
418
|
+
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
|
|
419
|
+
|
|
420
|
+
for (const file of benchFiles) {
|
|
421
|
+
try {
|
|
422
|
+
const source = readFileSync(file, 'utf-8');
|
|
423
|
+
const lexer = new Lexer(source, file);
|
|
424
|
+
const tokens = lexer.tokenize();
|
|
425
|
+
const parser = new Parser(tokens, file);
|
|
426
|
+
const ast = parser.parse();
|
|
427
|
+
|
|
428
|
+
const codegen = new CodeGenerator(ast, file);
|
|
429
|
+
const result = codegen.generate();
|
|
430
|
+
|
|
431
|
+
if (result.bench) {
|
|
432
|
+
const relPath = relative(resolve(targetDir), file).replace(/\.tova$/, '.bench.js');
|
|
433
|
+
const outPath = join(tmpDir, relPath);
|
|
434
|
+
const outDir = dirname(outPath);
|
|
435
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
436
|
+
|
|
437
|
+
const stdlib = getFullStdlib();
|
|
438
|
+
const fullBench = stdlib + '\n' + result.bench;
|
|
439
|
+
writeFileSync(outPath, fullBench);
|
|
440
|
+
console.log(` Compiled: ${relative('.', file)}`);
|
|
441
|
+
|
|
442
|
+
// Run the bench file
|
|
443
|
+
console.log('');
|
|
444
|
+
const proc = spawn('bun', ['run', outPath], { stdio: 'inherit' });
|
|
445
|
+
await new Promise(res => proc.on('close', res));
|
|
446
|
+
console.log('');
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.error(` Error compiling ${relative('.', file)}: ${err.message}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function generateDocs(args) {
|
|
455
|
+
const { DocGenerator } = await import('../src/docs/generator.js');
|
|
456
|
+
const outputDir = args.find((a, i) => args[i - 1] === '--output' || args[i - 1] === '-o') || 'docs-out';
|
|
457
|
+
const format = args.find((a, i) => args[i - 1] === '--format') || 'html';
|
|
458
|
+
const targetDir = args.find(a => !a.startsWith('--') && a !== outputDir && a !== format) || '.';
|
|
459
|
+
|
|
460
|
+
const tovaFiles = findTovaFiles(resolve(targetDir));
|
|
461
|
+
if (tovaFiles.length === 0) {
|
|
462
|
+
console.log('No .tova files found.');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const modules = [];
|
|
467
|
+
for (const file of tovaFiles) {
|
|
468
|
+
try {
|
|
469
|
+
const source = readFileSync(file, 'utf-8');
|
|
470
|
+
// Quick check: skip files without docstrings
|
|
471
|
+
if (!source.includes('///')) continue;
|
|
472
|
+
|
|
473
|
+
const lexer = new Lexer(source, file);
|
|
474
|
+
const tokens = lexer.tokenize();
|
|
475
|
+
const parser = new Parser(tokens, file);
|
|
476
|
+
const ast = parser.parse();
|
|
477
|
+
const name = relative(resolve(targetDir), file).replace(/\.tova$/, '').replace(/[/\\]/g, '.');
|
|
478
|
+
modules.push({ name, ast });
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error(` Error parsing ${relative('.', file)}: ${err.message}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (modules.length === 0) {
|
|
485
|
+
console.log('No documented .tova files found (add /// docstrings to your code).');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const generator = new DocGenerator(modules);
|
|
490
|
+
const pages = generator.generate(format);
|
|
491
|
+
|
|
492
|
+
const outDir = resolve(outputDir);
|
|
493
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
494
|
+
|
|
495
|
+
let count = 0;
|
|
496
|
+
for (const [filename, content] of Object.entries(pages)) {
|
|
497
|
+
const outPath = join(outDir, filename);
|
|
498
|
+
writeFileSync(outPath, content);
|
|
499
|
+
count++;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
console.log(`Generated ${count} documentation file(s) in ${relative('.', outDir)}/`);
|
|
503
|
+
}
|
|
504
|
+
|
|
302
505
|
function findTovaFiles(dir) {
|
|
303
506
|
const files = [];
|
|
304
507
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) return files;
|
|
@@ -347,16 +550,48 @@ async function runFile(filePath, options = {}) {
|
|
|
347
550
|
const source = readFileSync(resolved, 'utf-8');
|
|
348
551
|
|
|
349
552
|
try {
|
|
553
|
+
// Check if file has .tova imports — if so, compile dependencies and inline them
|
|
554
|
+
const hasTovaImports = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.tova['"]/m.test(source);
|
|
555
|
+
|
|
350
556
|
const output = compileTova(source, filePath, { strict: options.strict });
|
|
351
557
|
|
|
352
558
|
// Execute the generated JavaScript (with stdlib)
|
|
353
559
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
354
560
|
const stdlib = getRunStdlib();
|
|
355
|
-
|
|
561
|
+
|
|
562
|
+
// Compile .tova dependencies and inline them
|
|
563
|
+
let depCode = '';
|
|
564
|
+
if (hasTovaImports) {
|
|
565
|
+
const importRegex = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]*\.tova)['"]/gm;
|
|
566
|
+
let match;
|
|
567
|
+
const compiled = new Set();
|
|
568
|
+
while ((match = importRegex.exec(source)) !== null) {
|
|
569
|
+
const depPath = resolve(dirname(resolved), match[1]);
|
|
570
|
+
if (compiled.has(depPath)) continue;
|
|
571
|
+
compiled.add(depPath);
|
|
572
|
+
if (existsSync(depPath)) {
|
|
573
|
+
const depSource = readFileSync(depPath, 'utf-8');
|
|
574
|
+
const dep = compileTova(depSource, depPath, { strict: options.strict });
|
|
575
|
+
let depShared = dep.shared || '';
|
|
576
|
+
// Strip export keywords for inlining
|
|
577
|
+
depShared = depShared.replace(/^export /gm, '');
|
|
578
|
+
depCode += depShared + '\n';
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.client || '');
|
|
356
584
|
// Strip 'export ' keywords — not valid inside AsyncFunction (used in tova build only)
|
|
357
585
|
code = code.replace(/^export /gm, '');
|
|
358
|
-
|
|
359
|
-
|
|
586
|
+
// Strip import lines for .tova files (already inlined above)
|
|
587
|
+
code = code.replace(/^import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.(?:tova|(?:shared\.)?js)['"];?\s*$/gm, '');
|
|
588
|
+
// Auto-call main() if the compiled code defines a main function
|
|
589
|
+
const scriptArgs = options.scriptArgs || [];
|
|
590
|
+
if (/\bfunction\s+main\s*\(/.test(code)) {
|
|
591
|
+
code += '\nconst __tova_exit = await main(__tova_args); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
|
|
592
|
+
}
|
|
593
|
+
const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
|
|
594
|
+
await fn(scriptArgs, resolved, dirname(resolved));
|
|
360
595
|
} catch (err) {
|
|
361
596
|
console.error(richError(source, err, filePath));
|
|
362
597
|
if (process.argv.includes('--debug') || process.env.DEBUG) {
|
|
@@ -372,6 +607,9 @@ async function buildProject(args) {
|
|
|
372
607
|
const config = resolveConfig(process.cwd());
|
|
373
608
|
const isProduction = args.includes('--production');
|
|
374
609
|
const buildStrict = args.includes('--strict');
|
|
610
|
+
const isVerbose = args.includes('--verbose');
|
|
611
|
+
const isQuiet = args.includes('--quiet');
|
|
612
|
+
const isWatch = args.includes('--watch');
|
|
375
613
|
const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
|
|
376
614
|
const srcDir = resolve(explicitSrc || config.project.entry || '.');
|
|
377
615
|
const outIdx = args.indexOf('--output');
|
|
@@ -397,18 +635,56 @@ async function buildProject(args) {
|
|
|
397
635
|
writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
|
|
398
636
|
writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
|
|
399
637
|
|
|
400
|
-
console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
|
|
638
|
+
if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
|
|
401
639
|
|
|
402
640
|
let errorCount = 0;
|
|
641
|
+
const buildStart = Date.now();
|
|
403
642
|
compilationCache.clear();
|
|
404
643
|
|
|
644
|
+
// Load incremental build cache
|
|
645
|
+
const noCache = args.includes('--no-cache');
|
|
646
|
+
const buildCache = new BuildCache(join(outDir, '.cache'));
|
|
647
|
+
if (!noCache) buildCache.load();
|
|
648
|
+
let skippedCount = 0;
|
|
649
|
+
|
|
405
650
|
// Group files by directory for multi-file merging
|
|
406
651
|
const dirGroups = groupFilesByDirectory(tovaFiles);
|
|
407
652
|
|
|
408
653
|
for (const [dir, files] of dirGroups) {
|
|
409
654
|
const dirName = basename(dir) === '.' ? 'app' : basename(dir);
|
|
410
655
|
const relDir = relative(srcDir, dir) || '.';
|
|
656
|
+
const groupStart = Date.now();
|
|
411
657
|
try {
|
|
658
|
+
// Check incremental cache: skip if all files in this group are unchanged
|
|
659
|
+
if (!noCache) {
|
|
660
|
+
if (files.length === 1) {
|
|
661
|
+
const absFile = files[0];
|
|
662
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
663
|
+
if (buildCache.isUpToDate(absFile, sourceContent)) {
|
|
664
|
+
const cached = buildCache.getCached(absFile);
|
|
665
|
+
if (cached) {
|
|
666
|
+
skippedCount++;
|
|
667
|
+
if (isVerbose && !isQuiet) {
|
|
668
|
+
console.log(` ○ ${relative(srcDir, absFile)} (cached)`);
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
const dirKey = `dir:${dir}`;
|
|
675
|
+
if (buildCache.isGroupUpToDate(dirKey, files)) {
|
|
676
|
+
const cached = buildCache.getCached(dirKey);
|
|
677
|
+
if (cached) {
|
|
678
|
+
skippedCount++;
|
|
679
|
+
if (isVerbose && !isQuiet) {
|
|
680
|
+
console.log(` ○ ${relative(srcDir, dir)}/ (${files.length} files, cached)`);
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
412
688
|
const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
|
|
413
689
|
if (!result) continue;
|
|
414
690
|
|
|
@@ -416,6 +692,8 @@ async function buildProject(args) {
|
|
|
416
692
|
// Use single-file basename for single-file dirs, directory name for multi-file
|
|
417
693
|
const outBaseName = single ? basename(files[0], '.tova') : dirName;
|
|
418
694
|
const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
|
|
695
|
+
const elapsed = Date.now() - groupStart;
|
|
696
|
+
const timing = isVerbose ? ` (${elapsed}ms)` : '';
|
|
419
697
|
|
|
420
698
|
// Helper to generate source maps
|
|
421
699
|
const generateSourceMap = (code, jsFile) => {
|
|
@@ -433,44 +711,80 @@ async function buildProject(args) {
|
|
|
433
711
|
return code;
|
|
434
712
|
};
|
|
435
713
|
|
|
436
|
-
//
|
|
437
|
-
if (output.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
714
|
+
// Module files: write single <name>.js (not .shared.js)
|
|
715
|
+
if (output.isModule) {
|
|
716
|
+
if (output.shared && output.shared.trim()) {
|
|
717
|
+
const modulePath = join(outDir, `${outBaseName}.js`);
|
|
718
|
+
writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
|
|
719
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
|
|
720
|
+
}
|
|
721
|
+
// Update incremental build cache
|
|
722
|
+
if (!noCache) {
|
|
723
|
+
const outputPaths = {};
|
|
724
|
+
if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.js`);
|
|
725
|
+
if (single) {
|
|
726
|
+
const absFile = files[0];
|
|
727
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
728
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
729
|
+
} else {
|
|
730
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
// Write shared
|
|
735
|
+
if (output.shared && output.shared.trim()) {
|
|
736
|
+
const sharedPath = join(outDir, `${outBaseName}.shared.js`);
|
|
737
|
+
writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
|
|
738
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
|
|
739
|
+
}
|
|
442
740
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
741
|
+
// Write default server
|
|
742
|
+
if (output.server) {
|
|
743
|
+
const serverPath = join(outDir, `${outBaseName}.server.js`);
|
|
744
|
+
writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
|
|
745
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
|
|
746
|
+
}
|
|
449
747
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Write named server blocks (multi-block)
|
|
458
|
-
if (output.multiBlock && output.servers) {
|
|
459
|
-
for (const [name, code] of Object.entries(output.servers)) {
|
|
460
|
-
if (name === 'default') continue;
|
|
461
|
-
const path = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
462
|
-
writeFileSync(path, code);
|
|
463
|
-
console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]`);
|
|
748
|
+
// Write default client
|
|
749
|
+
if (output.client) {
|
|
750
|
+
const clientPath = join(outDir, `${outBaseName}.client.js`);
|
|
751
|
+
writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
|
|
752
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}${timing}`);
|
|
464
753
|
}
|
|
465
|
-
}
|
|
466
754
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
755
|
+
// Write named server blocks (multi-block)
|
|
756
|
+
if (output.multiBlock && output.servers) {
|
|
757
|
+
for (const [name, code] of Object.entries(output.servers)) {
|
|
758
|
+
if (name === 'default') continue;
|
|
759
|
+
const path = join(outDir, `${outBaseName}.server.${name}.js`);
|
|
760
|
+
writeFileSync(path, code);
|
|
761
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Write named client blocks (multi-block)
|
|
766
|
+
if (output.multiBlock && output.clients) {
|
|
767
|
+
for (const [name, code] of Object.entries(output.clients)) {
|
|
768
|
+
if (name === 'default') continue;
|
|
769
|
+
const path = join(outDir, `${outBaseName}.client.${name}.js`);
|
|
770
|
+
writeFileSync(path, code);
|
|
771
|
+
if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]${timing}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Update incremental build cache
|
|
776
|
+
if (!noCache) {
|
|
777
|
+
const outputPaths = {};
|
|
778
|
+
if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
|
|
779
|
+
if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
|
|
780
|
+
if (output.client) outputPaths.client = join(outDir, `${outBaseName}.client.js`);
|
|
781
|
+
if (single) {
|
|
782
|
+
const absFile = files[0];
|
|
783
|
+
const sourceContent = readFileSync(absFile, 'utf-8');
|
|
784
|
+
buildCache.set(absFile, sourceContent, outputPaths);
|
|
785
|
+
} else {
|
|
786
|
+
buildCache.setGroup(`dir:${dir}`, files, outputPaths);
|
|
787
|
+
}
|
|
474
788
|
}
|
|
475
789
|
}
|
|
476
790
|
} catch (err) {
|
|
@@ -479,9 +793,150 @@ async function buildProject(args) {
|
|
|
479
793
|
}
|
|
480
794
|
}
|
|
481
795
|
|
|
796
|
+
// Save incremental build cache and prune stale entries
|
|
797
|
+
if (!noCache) {
|
|
798
|
+
const dirKeys = [...dirGroups.keys()].map(d => `dir:${d}`);
|
|
799
|
+
buildCache.prune(tovaFiles, dirKeys);
|
|
800
|
+
buildCache.save();
|
|
801
|
+
}
|
|
802
|
+
|
|
482
803
|
const dirCount = dirGroups.size;
|
|
483
|
-
|
|
804
|
+
const totalElapsed = Date.now() - buildStart;
|
|
805
|
+
if (!isQuiet) {
|
|
806
|
+
const timingStr = isVerbose ? ` in ${totalElapsed}ms` : '';
|
|
807
|
+
const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
|
|
808
|
+
console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
|
|
809
|
+
}
|
|
484
810
|
if (errorCount > 0) process.exit(1);
|
|
811
|
+
|
|
812
|
+
// Watch mode for build command
|
|
813
|
+
if (isWatch) {
|
|
814
|
+
console.log(' Watching for changes...\n');
|
|
815
|
+
let debounceTimer = null;
|
|
816
|
+
const watcher = fsWatch(srcDir, { recursive: true }, (event, filename) => {
|
|
817
|
+
if (!filename || !filename.endsWith('.tova')) return;
|
|
818
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
819
|
+
debounceTimer = setTimeout(async () => {
|
|
820
|
+
compilationCache.clear();
|
|
821
|
+
if (!isQuiet) console.log(` Rebuilding (${filename} changed)...`);
|
|
822
|
+
await buildProject(args.filter(a => a !== '--watch'));
|
|
823
|
+
if (!isQuiet) console.log(' Watching for changes...\n');
|
|
824
|
+
}, 100);
|
|
825
|
+
});
|
|
826
|
+
// Keep process alive
|
|
827
|
+
await new Promise(() => {});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ─── Check (type-check only, no codegen) ────────────────────
|
|
832
|
+
|
|
833
|
+
async function checkProject(args) {
|
|
834
|
+
const checkStrict = args.includes('--strict');
|
|
835
|
+
const isVerbose = args.includes('--verbose');
|
|
836
|
+
const isQuiet = args.includes('--quiet');
|
|
837
|
+
|
|
838
|
+
// --explain <code>: show explanation for a specific error code inline with check output
|
|
839
|
+
const explainIdx = args.indexOf('--explain');
|
|
840
|
+
const explainCode = explainIdx >= 0 ? args[explainIdx + 1] : null;
|
|
841
|
+
if (explainCode) {
|
|
842
|
+
// If --explain is used standalone, just show the explanation
|
|
843
|
+
const info = lookupCode(explainCode);
|
|
844
|
+
if (!info) {
|
|
845
|
+
console.error(`Unknown error code: ${explainCode}`);
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
const explanation = getExplanation(explainCode);
|
|
849
|
+
console.log(`\n ${explainCode}: ${info.title} [${info.category}]\n`);
|
|
850
|
+
if (explanation) {
|
|
851
|
+
console.log(explanation);
|
|
852
|
+
} else {
|
|
853
|
+
console.log(` No detailed explanation available yet for ${explainCode}.\n`);
|
|
854
|
+
}
|
|
855
|
+
process.exit(0);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
|
|
859
|
+
const srcDir = resolve(explicitSrc || '.');
|
|
860
|
+
|
|
861
|
+
const tovaFiles = findFiles(srcDir, '.tova');
|
|
862
|
+
if (tovaFiles.length === 0) {
|
|
863
|
+
console.error('No .tova files found');
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
let totalErrors = 0;
|
|
868
|
+
let totalWarnings = 0;
|
|
869
|
+
const seenCodes = new Set();
|
|
870
|
+
|
|
871
|
+
for (const file of tovaFiles) {
|
|
872
|
+
const relPath = relative(srcDir, file);
|
|
873
|
+
const start = Date.now();
|
|
874
|
+
try {
|
|
875
|
+
const source = readFileSync(file, 'utf-8');
|
|
876
|
+
const lexer = new Lexer(source, file);
|
|
877
|
+
const tokens = lexer.tokenize();
|
|
878
|
+
const parser = new Parser(tokens, file);
|
|
879
|
+
const ast = parser.parse();
|
|
880
|
+
const analyzer = new Analyzer(ast, file, { strict: checkStrict, tolerant: true });
|
|
881
|
+
const result = analyzer.analyze();
|
|
882
|
+
|
|
883
|
+
const errors = result.errors || [];
|
|
884
|
+
const warnings = result.warnings || [];
|
|
885
|
+
totalErrors += errors.length;
|
|
886
|
+
totalWarnings += warnings.length;
|
|
887
|
+
|
|
888
|
+
if (errors.length > 0 || warnings.length > 0) {
|
|
889
|
+
const formatter = new DiagnosticFormatter(source, file);
|
|
890
|
+
for (const e of errors) {
|
|
891
|
+
console.error(formatter.formatError(e.message, { line: e.line, column: e.column }, { hint: e.hint, code: e.code, length: e.length, fix: e.fix }));
|
|
892
|
+
if (e.code) seenCodes.add(e.code);
|
|
893
|
+
}
|
|
894
|
+
for (const w of warnings) {
|
|
895
|
+
console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
|
|
896
|
+
if (w.code) seenCodes.add(w.code);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (isVerbose) {
|
|
901
|
+
const elapsed = Date.now() - start;
|
|
902
|
+
console.log(` ✓ ${relPath} (${elapsed}ms)`);
|
|
903
|
+
}
|
|
904
|
+
} catch (err) {
|
|
905
|
+
totalErrors++;
|
|
906
|
+
if (err.errors) {
|
|
907
|
+
const source = readFileSync(file, 'utf-8');
|
|
908
|
+
console.error(richError(source, err, file));
|
|
909
|
+
} else {
|
|
910
|
+
console.error(` ✗ ${relPath}: ${err.message}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!isQuiet) {
|
|
916
|
+
console.log(`\n ${tovaFiles.length} file${tovaFiles.length === 1 ? '' : 's'} checked, ${formatSummary(totalErrors, totalWarnings)}`);
|
|
917
|
+
// Show explain hint for encountered error codes
|
|
918
|
+
if (seenCodes.size > 0 && (totalErrors > 0 || totalWarnings > 0)) {
|
|
919
|
+
const codes = [...seenCodes].sort().slice(0, 5).join(', ');
|
|
920
|
+
const more = seenCodes.size > 5 ? ` and ${seenCodes.size - 5} more` : '';
|
|
921
|
+
console.log(`\n Run \`tova explain <code>\` for details on: ${codes}${more}`);
|
|
922
|
+
}
|
|
923
|
+
console.log('');
|
|
924
|
+
}
|
|
925
|
+
if (totalErrors > 0) process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ─── Clean (delete build artifacts) ─────────────────────────
|
|
929
|
+
|
|
930
|
+
function cleanBuild(args) {
|
|
931
|
+
const config = resolveConfig(process.cwd());
|
|
932
|
+
const outDir = resolve(config.build?.output || '.tova-out');
|
|
933
|
+
|
|
934
|
+
if (existsSync(outDir)) {
|
|
935
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
936
|
+
console.log(` Cleaned ${relative('.', outDir)}/`);
|
|
937
|
+
} else {
|
|
938
|
+
console.log(` Nothing to clean (${relative('.', outDir)}/ does not exist)`);
|
|
939
|
+
}
|
|
485
940
|
}
|
|
486
941
|
|
|
487
942
|
// ─── Dev Server ─────────────────────────────────────────────
|
|
@@ -1025,6 +1480,98 @@ tova add prettier --dev
|
|
|
1025
1480
|
console.log(` tova dev\n`);
|
|
1026
1481
|
}
|
|
1027
1482
|
|
|
1483
|
+
// ─── Init (in-place) ────────────────────────────────────────
|
|
1484
|
+
|
|
1485
|
+
function initProject() {
|
|
1486
|
+
const projectDir = process.cwd();
|
|
1487
|
+
const name = basename(projectDir);
|
|
1488
|
+
|
|
1489
|
+
if (existsSync(join(projectDir, 'tova.toml'))) {
|
|
1490
|
+
console.error('Error: tova.toml already exists in this directory');
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
console.log(`\n Initializing Tova project: ${name}\n`);
|
|
1495
|
+
|
|
1496
|
+
// tova.toml
|
|
1497
|
+
const tomlContent = stringifyTOML({
|
|
1498
|
+
project: {
|
|
1499
|
+
name,
|
|
1500
|
+
version: '0.1.0',
|
|
1501
|
+
description: '',
|
|
1502
|
+
entry: 'src',
|
|
1503
|
+
},
|
|
1504
|
+
build: {
|
|
1505
|
+
output: '.tova-out',
|
|
1506
|
+
},
|
|
1507
|
+
dev: {
|
|
1508
|
+
port: 3000,
|
|
1509
|
+
},
|
|
1510
|
+
dependencies: {},
|
|
1511
|
+
npm: {},
|
|
1512
|
+
});
|
|
1513
|
+
writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
|
|
1514
|
+
console.log(' ✓ Created tova.toml');
|
|
1515
|
+
|
|
1516
|
+
// src/ directory
|
|
1517
|
+
mkdirSync(join(projectDir, 'src'), { recursive: true });
|
|
1518
|
+
|
|
1519
|
+
// .gitignore (only if missing)
|
|
1520
|
+
const gitignorePath = join(projectDir, '.gitignore');
|
|
1521
|
+
if (!existsSync(gitignorePath)) {
|
|
1522
|
+
writeFileSync(gitignorePath, `node_modules/
|
|
1523
|
+
.tova-out/
|
|
1524
|
+
package.json
|
|
1525
|
+
bun.lock
|
|
1526
|
+
*.db
|
|
1527
|
+
*.db-shm
|
|
1528
|
+
*.db-wal
|
|
1529
|
+
`);
|
|
1530
|
+
console.log(' ✓ Created .gitignore');
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Starter app.tova (only if src/ has no .tova files)
|
|
1534
|
+
const srcDir = join(projectDir, 'src');
|
|
1535
|
+
const existingTova = existsSync(srcDir) ? readdirSync(srcDir).filter(f => f.endsWith('.tova')) : [];
|
|
1536
|
+
if (existingTova.length === 0) {
|
|
1537
|
+
writeFileSync(join(srcDir, 'app.tova'), `// ${name} — Built with Tova
|
|
1538
|
+
|
|
1539
|
+
shared {
|
|
1540
|
+
type Message {
|
|
1541
|
+
text: String
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
server {
|
|
1546
|
+
fn get_message() -> Message {
|
|
1547
|
+
Message("Hello from Tova!")
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
route GET "/api/message" => get_message
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
client {
|
|
1554
|
+
state message = ""
|
|
1555
|
+
|
|
1556
|
+
effect {
|
|
1557
|
+
result = server.get_message()
|
|
1558
|
+
message = result.text
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
component App {
|
|
1562
|
+
<div class="app">
|
|
1563
|
+
<h1>"{message}"</h1>
|
|
1564
|
+
<p>"Edit src/app.tova to get started."</p>
|
|
1565
|
+
</div>
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
`);
|
|
1569
|
+
console.log(' ✓ Created src/app.tova');
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
console.log(`\n Project initialized. Run 'tova dev' to start.\n`);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1028
1575
|
// ─── Package Management ─────────────────────────────────────
|
|
1029
1576
|
|
|
1030
1577
|
async function installDeps() {
|
|
@@ -1059,6 +1606,8 @@ async function addDep(args) {
|
|
|
1059
1606
|
if (!pkg) {
|
|
1060
1607
|
console.error('Error: No package specified');
|
|
1061
1608
|
console.error('Usage: tova add <package> [--dev]');
|
|
1609
|
+
console.error(' tova add npm:<package> — add an npm package');
|
|
1610
|
+
console.error(' tova add <tova-package> — add a Tova package (local path or git URL)');
|
|
1062
1611
|
process.exit(1);
|
|
1063
1612
|
}
|
|
1064
1613
|
|
|
@@ -1071,44 +1620,100 @@ async function addDep(args) {
|
|
|
1071
1620
|
process.exit(1);
|
|
1072
1621
|
}
|
|
1073
1622
|
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1623
|
+
// Determine if this is an npm package or a Tova native dependency
|
|
1624
|
+
const isNpm = pkg.startsWith('npm:');
|
|
1625
|
+
const actualPkg = isNpm ? pkg.slice(4) : pkg;
|
|
1626
|
+
|
|
1627
|
+
if (isNpm) {
|
|
1628
|
+
// npm package handling (existing behavior)
|
|
1629
|
+
let name = actualPkg;
|
|
1630
|
+
let version = 'latest';
|
|
1631
|
+
if (actualPkg.includes('@') && !actualPkg.startsWith('@')) {
|
|
1632
|
+
const atIdx = actualPkg.lastIndexOf('@');
|
|
1633
|
+
name = actualPkg.slice(0, atIdx);
|
|
1634
|
+
version = actualPkg.slice(atIdx + 1);
|
|
1635
|
+
} else if (actualPkg.startsWith('@') && actualPkg.includes('@', 1)) {
|
|
1636
|
+
const atIdx = actualPkg.lastIndexOf('@');
|
|
1637
|
+
name = actualPkg.slice(0, atIdx);
|
|
1638
|
+
version = actualPkg.slice(atIdx + 1);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (version === 'latest') {
|
|
1642
|
+
try {
|
|
1643
|
+
const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1644
|
+
let out = '';
|
|
1645
|
+
proc.stdout.on('data', d => out += d);
|
|
1646
|
+
const code = await new Promise(res => proc.on('close', res));
|
|
1647
|
+
if (code === 0 && out.trim()) {
|
|
1648
|
+
version = `^${out.trim()}`;
|
|
1649
|
+
} else {
|
|
1650
|
+
version = '*';
|
|
1651
|
+
}
|
|
1652
|
+
} catch {
|
|
1653
|
+
version = '*';
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const section = isDev ? 'npm.dev' : 'npm';
|
|
1658
|
+
addToSection(tomlPath, section, name, version);
|
|
1659
|
+
console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
|
|
1660
|
+
await installDeps();
|
|
1661
|
+
} else {
|
|
1662
|
+
// Tova native dependency
|
|
1663
|
+
let name = actualPkg;
|
|
1664
|
+
let source = actualPkg;
|
|
1665
|
+
|
|
1666
|
+
// Detect source type
|
|
1667
|
+
if (actualPkg.startsWith('file:') || actualPkg.startsWith('./') || actualPkg.startsWith('../') || actualPkg.startsWith('/')) {
|
|
1668
|
+
// Local path dependency
|
|
1669
|
+
source = actualPkg.startsWith('file:') ? actualPkg : `file:${actualPkg}`;
|
|
1670
|
+
name = basename(actualPkg.replace(/^file:/, ''));
|
|
1671
|
+
} else if (actualPkg.startsWith('git:') || actualPkg.includes('github.com/') || actualPkg.includes('.git')) {
|
|
1672
|
+
// Git dependency
|
|
1673
|
+
source = actualPkg.startsWith('git:') ? actualPkg : `git:${actualPkg}`;
|
|
1674
|
+
name = basename(actualPkg.replace(/\.git$/, '').replace(/^git:/, ''));
|
|
1675
|
+
} else {
|
|
1676
|
+
// Tova registry package (future: for now, just store the name)
|
|
1677
|
+
source = `*`;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
addToSection(tomlPath, 'dependencies', name, source);
|
|
1681
|
+
console.log(` Added ${name} = "${source}" to [dependencies] in tova.toml`);
|
|
1682
|
+
|
|
1683
|
+
// Generate lock file
|
|
1684
|
+
generateLockFile(cwd);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function generateLockFile(cwd) {
|
|
1689
|
+
const config = resolveConfig(cwd);
|
|
1690
|
+
const deps = config.dependencies || {};
|
|
1691
|
+
const npmProd = config.npm?.prod || {};
|
|
1692
|
+
const npmDev = config.npm?.dev || {};
|
|
1693
|
+
|
|
1694
|
+
const lock = {
|
|
1695
|
+
version: 1,
|
|
1696
|
+
generated: new Date().toISOString(),
|
|
1697
|
+
dependencies: {},
|
|
1698
|
+
npm: {},
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
for (const [name, source] of Object.entries(deps)) {
|
|
1702
|
+
lock.dependencies[name] = {
|
|
1703
|
+
source,
|
|
1704
|
+
resolved: source, // For now, resolved = source
|
|
1705
|
+
};
|
|
1086
1706
|
}
|
|
1087
1707
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
proc.stdout.on('data', d => out += d);
|
|
1094
|
-
const code = await new Promise(res => proc.on('close', res));
|
|
1095
|
-
if (code === 0 && out.trim()) {
|
|
1096
|
-
version = `^${out.trim()}`;
|
|
1097
|
-
} else {
|
|
1098
|
-
version = '*';
|
|
1099
|
-
}
|
|
1100
|
-
} catch {
|
|
1101
|
-
version = '*';
|
|
1102
|
-
}
|
|
1708
|
+
for (const [name, version] of Object.entries(npmProd)) {
|
|
1709
|
+
lock.npm[name] = { version, dev: false };
|
|
1710
|
+
}
|
|
1711
|
+
for (const [name, version] of Object.entries(npmDev)) {
|
|
1712
|
+
lock.npm[name] = { version, dev: true };
|
|
1103
1713
|
}
|
|
1104
1714
|
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
|
|
1109
|
-
|
|
1110
|
-
// Run install
|
|
1111
|
-
await installDeps();
|
|
1715
|
+
const lockPath = join(cwd, 'tova.lock');
|
|
1716
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
1112
1717
|
}
|
|
1113
1718
|
|
|
1114
1719
|
async function removeDep(pkg) {
|
|
@@ -1126,8 +1731,9 @@ async function removeDep(pkg) {
|
|
|
1126
1731
|
process.exit(1);
|
|
1127
1732
|
}
|
|
1128
1733
|
|
|
1129
|
-
// Try removing from [npm]
|
|
1130
|
-
const removed = removeFromSection(tomlPath, '
|
|
1734
|
+
// Try removing from [dependencies], [npm], or [npm.dev]
|
|
1735
|
+
const removed = removeFromSection(tomlPath, 'dependencies', pkg) ||
|
|
1736
|
+
removeFromSection(tomlPath, 'npm', pkg) ||
|
|
1131
1737
|
removeFromSection(tomlPath, 'npm.dev', pkg);
|
|
1132
1738
|
|
|
1133
1739
|
if (removed) {
|
|
@@ -1312,6 +1918,170 @@ async function migrateUp(args) {
|
|
|
1312
1918
|
}
|
|
1313
1919
|
}
|
|
1314
1920
|
|
|
1921
|
+
async function migrateDown(args) {
|
|
1922
|
+
const tovaFile = findTovaFile(args[0]);
|
|
1923
|
+
const cfg = discoverDbConfig(tovaFile);
|
|
1924
|
+
const db = await connectDb(cfg);
|
|
1925
|
+
|
|
1926
|
+
try {
|
|
1927
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
|
|
1928
|
+
id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
|
|
1929
|
+
name TEXT NOT NULL UNIQUE,
|
|
1930
|
+
applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
|
|
1931
|
+
)`);
|
|
1932
|
+
|
|
1933
|
+
const applied = await db.query('SELECT name FROM __migrations ORDER BY name DESC');
|
|
1934
|
+
if (applied.length === 0) {
|
|
1935
|
+
console.log('\n No migrations to roll back.\n');
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const migrDir = resolve('migrations');
|
|
1940
|
+
const lastMigration = applied[0].name;
|
|
1941
|
+
|
|
1942
|
+
console.log(`\n Rolling back: ${lastMigration}...\n`);
|
|
1943
|
+
|
|
1944
|
+
const mod = await import(join(migrDir, lastMigration));
|
|
1945
|
+
if (!mod.down) {
|
|
1946
|
+
console.error(` Error: ${lastMigration} has no 'down' export — cannot roll back`);
|
|
1947
|
+
process.exit(1);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const sql = mod.down.trim();
|
|
1951
|
+
if (sql) {
|
|
1952
|
+
await db.exec(sql);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
await db.exec(`DELETE FROM __migrations WHERE name = '${lastMigration}'`);
|
|
1956
|
+
console.log(` ✓ Rolled back: ${lastMigration}`);
|
|
1957
|
+
console.log(`\n Done.\n`);
|
|
1958
|
+
} finally {
|
|
1959
|
+
await db.close();
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
async function migrateReset(args) {
|
|
1964
|
+
const tovaFile = findTovaFile(args[0]);
|
|
1965
|
+
const cfg = discoverDbConfig(tovaFile);
|
|
1966
|
+
const db = await connectDb(cfg);
|
|
1967
|
+
|
|
1968
|
+
try {
|
|
1969
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
|
|
1970
|
+
id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
|
|
1971
|
+
name TEXT NOT NULL UNIQUE,
|
|
1972
|
+
applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
|
|
1973
|
+
)`);
|
|
1974
|
+
|
|
1975
|
+
const applied = await db.query('SELECT name FROM __migrations ORDER BY name DESC');
|
|
1976
|
+
if (applied.length === 0) {
|
|
1977
|
+
console.log('\n No migrations to roll back.\n');
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const migrDir = resolve('migrations');
|
|
1982
|
+
console.log(`\n Rolling back ${applied.length} migration(s)...\n`);
|
|
1983
|
+
|
|
1984
|
+
for (const row of applied) {
|
|
1985
|
+
const file = row.name;
|
|
1986
|
+
try {
|
|
1987
|
+
const mod = await import(join(migrDir, file));
|
|
1988
|
+
if (mod.down) {
|
|
1989
|
+
const sql = mod.down.trim();
|
|
1990
|
+
if (sql) {
|
|
1991
|
+
await db.exec(sql);
|
|
1992
|
+
}
|
|
1993
|
+
} else {
|
|
1994
|
+
console.error(` ⚠ ${file} has no 'down' export — skipping rollback`);
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
} catch (e) {
|
|
1998
|
+
console.error(` ⚠ Error rolling back ${file}: ${e.message}`);
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
await db.exec(`DELETE FROM __migrations WHERE name = '${file}'`);
|
|
2002
|
+
console.log(` ✓ Rolled back: ${file}`);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
console.log(`\n Done. All migrations rolled back.\n`);
|
|
2006
|
+
} finally {
|
|
2007
|
+
await db.close();
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function migrateFresh(args) {
|
|
2012
|
+
const tovaFile = findTovaFile(args[0]);
|
|
2013
|
+
const cfg = discoverDbConfig(tovaFile);
|
|
2014
|
+
const db = await connectDb(cfg);
|
|
2015
|
+
|
|
2016
|
+
try {
|
|
2017
|
+
// Drop all tables
|
|
2018
|
+
console.log('\n Dropping all tables...\n');
|
|
2019
|
+
if (db.driver === 'sqlite') {
|
|
2020
|
+
const tables = await db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
|
2021
|
+
for (const t of tables) {
|
|
2022
|
+
await db.exec(`DROP TABLE IF EXISTS "${t.name}"`);
|
|
2023
|
+
console.log(` ✓ Dropped: ${t.name}`);
|
|
2024
|
+
}
|
|
2025
|
+
} else if (db.driver === 'postgres') {
|
|
2026
|
+
const tables = await db.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public'");
|
|
2027
|
+
for (const t of tables) {
|
|
2028
|
+
await db.exec(`DROP TABLE IF EXISTS "${t.tablename}" CASCADE`);
|
|
2029
|
+
console.log(` ✓ Dropped: ${t.tablename}`);
|
|
2030
|
+
}
|
|
2031
|
+
} else if (db.driver === 'mysql') {
|
|
2032
|
+
const tables = await db.query("SHOW TABLES");
|
|
2033
|
+
for (const t of tables) {
|
|
2034
|
+
const tableName = Object.values(t)[0];
|
|
2035
|
+
await db.exec(`DROP TABLE IF EXISTS \`${tableName}\``);
|
|
2036
|
+
console.log(` ✓ Dropped: ${tableName}`);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Re-create migrations table and run all migrations
|
|
2041
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
|
|
2042
|
+
id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
|
|
2043
|
+
name TEXT NOT NULL UNIQUE,
|
|
2044
|
+
applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
|
|
2045
|
+
)`);
|
|
2046
|
+
|
|
2047
|
+
const migrDir = resolve('migrations');
|
|
2048
|
+
if (!existsSync(migrDir)) {
|
|
2049
|
+
console.log(' No migrations directory found.\n');
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const files = readdirSync(migrDir)
|
|
2054
|
+
.filter(f => f.endsWith('.js'))
|
|
2055
|
+
.sort();
|
|
2056
|
+
|
|
2057
|
+
if (files.length === 0) {
|
|
2058
|
+
console.log(' No migration files found.\n');
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
console.log(`\n Running ${files.length} migration(s)...\n`);
|
|
2063
|
+
|
|
2064
|
+
for (const file of files) {
|
|
2065
|
+
const mod = await import(join(migrDir, file));
|
|
2066
|
+
if (!mod.up) {
|
|
2067
|
+
console.error(` Skipping ${file}: no 'up' export`);
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
const sql = mod.up.trim();
|
|
2071
|
+
if (sql) {
|
|
2072
|
+
await db.exec(sql);
|
|
2073
|
+
}
|
|
2074
|
+
const ph = db.driver === 'postgres' ? '$1' : '?';
|
|
2075
|
+
await db.query(`INSERT INTO __migrations (name) VALUES (${ph})`, file);
|
|
2076
|
+
console.log(` ✓ ${file}`);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
console.log(`\n Done. Fresh database with ${files.length} migration(s) applied.\n`);
|
|
2080
|
+
} finally {
|
|
2081
|
+
await db.close();
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
1315
2085
|
async function migrateStatus(args) {
|
|
1316
2086
|
const tovaFile = findTovaFile(args[0]);
|
|
1317
2087
|
const cfg = discoverDbConfig(tovaFile);
|
|
@@ -1440,14 +2210,147 @@ async function startLsp() {
|
|
|
1440
2210
|
|
|
1441
2211
|
async function startRepl() {
|
|
1442
2212
|
const readline = await import('readline');
|
|
2213
|
+
|
|
2214
|
+
// ─── ANSI color helpers ──────────────────────────
|
|
2215
|
+
const c = {
|
|
2216
|
+
reset: '\x1b[0m',
|
|
2217
|
+
keyword: '\x1b[35m', // magenta
|
|
2218
|
+
string: '\x1b[32m', // green
|
|
2219
|
+
number: '\x1b[33m', // yellow
|
|
2220
|
+
boolean: '\x1b[33m', // yellow
|
|
2221
|
+
comment: '\x1b[90m', // gray
|
|
2222
|
+
builtin: '\x1b[36m', // cyan
|
|
2223
|
+
type: '\x1b[34m', // blue
|
|
2224
|
+
nil: '\x1b[90m', // gray
|
|
2225
|
+
prompt: '\x1b[1;36m', // bold cyan
|
|
2226
|
+
result: '\x1b[90m', // gray
|
|
2227
|
+
typeHint: '\x1b[2;36m', // dim cyan
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
const KEYWORDS = new Set([
|
|
2231
|
+
'fn', 'let', 'var', 'if', 'elif', 'else', 'for', 'while', 'loop', 'when',
|
|
2232
|
+
'in', 'return', 'match', 'type', 'import', 'from', 'and', 'or', 'not',
|
|
2233
|
+
'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
|
|
2234
|
+
'guard', 'interface', 'derive', 'pub', 'impl', 'trait', 'defer',
|
|
2235
|
+
'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'shared',
|
|
2236
|
+
]);
|
|
2237
|
+
|
|
2238
|
+
const TYPE_NAMES = new Set([
|
|
2239
|
+
'Int', 'Float', 'String', 'Bool', 'Nil', 'Any', 'Result', 'Option',
|
|
2240
|
+
'Function', 'List', 'Object', 'Promise',
|
|
2241
|
+
]);
|
|
2242
|
+
|
|
2243
|
+
const RUNTIME_NAMES = new Set(['Ok', 'Err', 'Some', 'None', 'true', 'false', 'nil']);
|
|
2244
|
+
|
|
2245
|
+
// ─── Syntax highlighter ──────────────────────────
|
|
2246
|
+
function highlight(line) {
|
|
2247
|
+
let out = '';
|
|
2248
|
+
let i = 0;
|
|
2249
|
+
while (i < line.length) {
|
|
2250
|
+
// Comments
|
|
2251
|
+
if (line[i] === '/' && line[i + 1] === '/') {
|
|
2252
|
+
out += c.comment + line.slice(i) + c.reset;
|
|
2253
|
+
break;
|
|
2254
|
+
}
|
|
2255
|
+
// Strings
|
|
2256
|
+
if (line[i] === '"' || line[i] === "'" || line[i] === '`') {
|
|
2257
|
+
const quote = line[i];
|
|
2258
|
+
let j = i + 1;
|
|
2259
|
+
// Handle triple-quoted strings
|
|
2260
|
+
if (quote === '"' && line[j] === '"' && line[j + 1] === '"') {
|
|
2261
|
+
j += 2;
|
|
2262
|
+
while (j < line.length && !(line[j] === '"' && line[j + 1] === '"' && line[j + 2] === '"')) j++;
|
|
2263
|
+
if (j < line.length) j += 3;
|
|
2264
|
+
out += c.string + line.slice(i, j) + c.reset;
|
|
2265
|
+
i = j;
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
while (j < line.length && line[j] !== quote) {
|
|
2269
|
+
if (line[j] === '\\') j++;
|
|
2270
|
+
j++;
|
|
2271
|
+
}
|
|
2272
|
+
if (j < line.length) j++;
|
|
2273
|
+
out += c.string + line.slice(i, j) + c.reset;
|
|
2274
|
+
i = j;
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
2277
|
+
// Numbers
|
|
2278
|
+
if (/[0-9]/.test(line[i]) && (i === 0 || !/[a-zA-Z_]/.test(line[i - 1]))) {
|
|
2279
|
+
let j = i;
|
|
2280
|
+
while (j < line.length && /[0-9._eExXbBoO]/.test(line[j])) j++;
|
|
2281
|
+
out += c.number + line.slice(i, j) + c.reset;
|
|
2282
|
+
i = j;
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
// Identifiers and keywords
|
|
2286
|
+
if (/[a-zA-Z_]/.test(line[i])) {
|
|
2287
|
+
let j = i;
|
|
2288
|
+
while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) j++;
|
|
2289
|
+
const word = line.slice(i, j);
|
|
2290
|
+
if (KEYWORDS.has(word)) {
|
|
2291
|
+
out += c.keyword + word + c.reset;
|
|
2292
|
+
} else if (TYPE_NAMES.has(word)) {
|
|
2293
|
+
out += c.type + word + c.reset;
|
|
2294
|
+
} else if (RUNTIME_NAMES.has(word)) {
|
|
2295
|
+
out += c.boolean + word + c.reset;
|
|
2296
|
+
} else if (BUILTIN_NAMES.has(word)) {
|
|
2297
|
+
out += c.builtin + word + c.reset;
|
|
2298
|
+
} else {
|
|
2299
|
+
out += word;
|
|
2300
|
+
}
|
|
2301
|
+
i = j;
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
out += line[i];
|
|
2305
|
+
i++;
|
|
2306
|
+
}
|
|
2307
|
+
return out;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// ─── Tab completions ─────────────────────────────
|
|
2311
|
+
const completionWords = [
|
|
2312
|
+
...KEYWORDS, ...TYPE_NAMES, ...RUNTIME_NAMES, ...BUILTIN_NAMES,
|
|
2313
|
+
':quit', ':exit', ':help', ':clear', ':type',
|
|
2314
|
+
];
|
|
2315
|
+
const userDefinedNames = new Set();
|
|
2316
|
+
|
|
2317
|
+
function completer(line) {
|
|
2318
|
+
const allWords = [...new Set([...completionWords, ...userDefinedNames])];
|
|
2319
|
+
// Find the last word being typed
|
|
2320
|
+
const match = line.match(/([a-zA-Z_:][a-zA-Z0-9_]*)$/);
|
|
2321
|
+
if (!match) return [[], line];
|
|
2322
|
+
const prefix = match[1];
|
|
2323
|
+
const hits = allWords.filter(w => w.startsWith(prefix));
|
|
2324
|
+
return [hits, prefix];
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// ─── Type display helper ─────────────────────────
|
|
2328
|
+
function inferType(val) {
|
|
2329
|
+
if (val === null || val === undefined) return 'Nil';
|
|
2330
|
+
if (Array.isArray(val)) {
|
|
2331
|
+
if (val.length === 0) return '[_]';
|
|
2332
|
+
const elemType = inferType(val[0]);
|
|
2333
|
+
return `[${elemType}]`;
|
|
2334
|
+
}
|
|
2335
|
+
if (val?.__tag) return val.__tag;
|
|
2336
|
+
if (typeof val === 'number') return Number.isInteger(val) ? 'Int' : 'Float';
|
|
2337
|
+
if (typeof val === 'string') return 'String';
|
|
2338
|
+
if (typeof val === 'boolean') return 'Bool';
|
|
2339
|
+
if (typeof val === 'function') return 'Function';
|
|
2340
|
+
if (typeof val === 'object') return 'Object';
|
|
2341
|
+
return 'Unknown';
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1443
2344
|
const rl = readline.createInterface({
|
|
1444
2345
|
input: process.stdin,
|
|
1445
2346
|
output: process.stdout,
|
|
1446
|
-
prompt:
|
|
2347
|
+
prompt: `${c.prompt}tova>${c.reset} `,
|
|
2348
|
+
completer,
|
|
1447
2349
|
});
|
|
1448
2350
|
|
|
1449
2351
|
console.log(`\n Tova REPL v${VERSION}`);
|
|
1450
|
-
console.log(' Type expressions to evaluate. Use :quit to exit
|
|
2352
|
+
console.log(' Type expressions to evaluate. Use :quit to exit.');
|
|
2353
|
+
console.log(' Use _ to reference the last result. Tab for completions.\n');
|
|
1451
2354
|
|
|
1452
2355
|
const context = {};
|
|
1453
2356
|
const stdlib = getStdlibForRuntime();
|
|
@@ -1461,7 +2364,7 @@ async function startRepl() {
|
|
|
1461
2364
|
|
|
1462
2365
|
rl.prompt();
|
|
1463
2366
|
|
|
1464
|
-
rl.on('line', (line) => {
|
|
2367
|
+
rl.on('line', async (line) => {
|
|
1465
2368
|
const trimmed = line.trim();
|
|
1466
2369
|
|
|
1467
2370
|
if (trimmed === ':quit' || trimmed === ':exit' || trimmed === ':q') {
|
|
@@ -1473,7 +2376,29 @@ async function startRepl() {
|
|
|
1473
2376
|
if (trimmed === ':help') {
|
|
1474
2377
|
console.log(' :quit Exit the REPL');
|
|
1475
2378
|
console.log(' :help Show this help');
|
|
1476
|
-
console.log(' :clear Clear context
|
|
2379
|
+
console.log(' :clear Clear context');
|
|
2380
|
+
console.log(' :type Show inferred type of expression');
|
|
2381
|
+
console.log(' _ Reference the last result');
|
|
2382
|
+
console.log(' Tab Autocomplete keywords, builtins, and variables\n');
|
|
2383
|
+
rl.prompt();
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if (trimmed.startsWith(':type ')) {
|
|
2388
|
+
const expr = trimmed.slice(6).trim();
|
|
2389
|
+
try {
|
|
2390
|
+
const output = compileTova(expr, '<repl>');
|
|
2391
|
+
const code = output.shared || '';
|
|
2392
|
+
const ctxKeys = Object.keys(context).filter(k => k !== '__mutable');
|
|
2393
|
+
const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;\n` : '';
|
|
2394
|
+
// REPL context: evaluating user-provided Tova expressions (intentional dynamic eval)
|
|
2395
|
+
const fn = new Function('__ctx', `${destructure}return (${code.replace(/;$/, '')});`);
|
|
2396
|
+
const val = fn(context);
|
|
2397
|
+
const typeStr = val === null ? 'Nil' : Array.isArray(val) ? 'List' : val?.__tag ? val.__tag : typeof val === 'number' ? (Number.isInteger(val) ? 'Int' : 'Float') : typeof val === 'string' ? 'String' : typeof val === 'boolean' ? 'Bool' : typeof val === 'function' ? 'Function' : typeof val === 'object' ? 'Object' : 'Unknown';
|
|
2398
|
+
console.log(` ${expr} : ${typeStr}`);
|
|
2399
|
+
} catch (err) {
|
|
2400
|
+
console.error(` Error: ${err.message}`);
|
|
2401
|
+
}
|
|
1477
2402
|
rl.prompt();
|
|
1478
2403
|
return;
|
|
1479
2404
|
}
|
|
@@ -1482,11 +2407,68 @@ async function startRepl() {
|
|
|
1482
2407
|
for (const key of Object.keys(context)) delete context[key];
|
|
1483
2408
|
delete context.__mutable;
|
|
1484
2409
|
initFn.call(context);
|
|
2410
|
+
userDefinedNames.clear();
|
|
1485
2411
|
console.log(' Context cleared.\n');
|
|
1486
2412
|
rl.prompt();
|
|
1487
2413
|
return;
|
|
1488
2414
|
}
|
|
1489
2415
|
|
|
2416
|
+
// Handle import statements
|
|
2417
|
+
const tovaImportMatch = trimmed.match(/^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]*\.tova)['"]\s*$/);
|
|
2418
|
+
const npmImportNamedMatch = !tovaImportMatch && trimmed.match(/^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*$/);
|
|
2419
|
+
const npmImportDefaultMatch = !tovaImportMatch && !npmImportNamedMatch && trimmed.match(/^import\s+([\w$]+)\s+from\s+['"]([^'"]+)['"]\s*$/);
|
|
2420
|
+
|
|
2421
|
+
if (tovaImportMatch) {
|
|
2422
|
+
// .tova file import: compile and inject into context
|
|
2423
|
+
const names = tovaImportMatch[1].split(',').map(n => n.trim()).filter(Boolean);
|
|
2424
|
+
const modulePath = resolve(process.cwd(), tovaImportMatch[2]);
|
|
2425
|
+
try {
|
|
2426
|
+
if (!existsSync(modulePath)) {
|
|
2427
|
+
throw new Error(`Module not found: ${tovaImportMatch[2]}`);
|
|
2428
|
+
}
|
|
2429
|
+
const modSource = readFileSync(modulePath, 'utf-8');
|
|
2430
|
+
const modOutput = compileTova(modSource, modulePath);
|
|
2431
|
+
let modCode = (modOutput.shared || '');
|
|
2432
|
+
// Strip export keywords
|
|
2433
|
+
modCode = modCode.replace(/^export /gm, '');
|
|
2434
|
+
// REPL context: executing compiled Tova module code (intentional dynamic eval)
|
|
2435
|
+
const modFn = new Function('__ctx', modCode + '\n' + names.map(n => `__ctx.${n} = ${n};`).join('\n'));
|
|
2436
|
+
modFn(context);
|
|
2437
|
+
console.log(` Imported { ${names.join(', ')} } from ${tovaImportMatch[2]}`);
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
console.error(` Error: ${err.message}`);
|
|
2440
|
+
}
|
|
2441
|
+
rl.prompt();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if (npmImportNamedMatch || npmImportDefaultMatch) {
|
|
2446
|
+
// npm/JS module import via dynamic import()
|
|
2447
|
+
const moduleName = (npmImportNamedMatch || npmImportDefaultMatch)[2];
|
|
2448
|
+
try {
|
|
2449
|
+
const mod = await import(moduleName);
|
|
2450
|
+
if (npmImportNamedMatch) {
|
|
2451
|
+
const names = npmImportNamedMatch[1].split(',').map(n => n.trim()).filter(Boolean);
|
|
2452
|
+
for (const name of names) {
|
|
2453
|
+
if (name in mod) {
|
|
2454
|
+
context[name] = mod[name];
|
|
2455
|
+
} else {
|
|
2456
|
+
console.error(` Warning: '${name}' not found in '${moduleName}'`);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
console.log(` Imported { ${names.join(', ')} } from ${moduleName}`);
|
|
2460
|
+
} else {
|
|
2461
|
+
const name = npmImportDefaultMatch[1];
|
|
2462
|
+
context[name] = mod.default || mod;
|
|
2463
|
+
console.log(` Imported ${name} from ${moduleName}`);
|
|
2464
|
+
}
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
console.error(` Error: ${err.message}`);
|
|
2467
|
+
}
|
|
2468
|
+
rl.prompt();
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
1490
2472
|
buffer += (buffer ? '\n' : '') + line;
|
|
1491
2473
|
|
|
1492
2474
|
// Track open braces for multi-line input (skip braces inside strings)
|
|
@@ -1504,7 +2486,7 @@ async function startRepl() {
|
|
|
1504
2486
|
}
|
|
1505
2487
|
|
|
1506
2488
|
if (braceDepth > 0) {
|
|
1507
|
-
process.stdout.write(
|
|
2489
|
+
process.stdout.write(`${c.prompt}...${c.reset} `);
|
|
1508
2490
|
return;
|
|
1509
2491
|
}
|
|
1510
2492
|
|
|
@@ -1518,10 +2500,11 @@ async function startRepl() {
|
|
|
1518
2500
|
if (code.trim()) {
|
|
1519
2501
|
// Extract function/const/let names from compiled code
|
|
1520
2502
|
const declaredInCode = new Set();
|
|
1521
|
-
for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
1522
|
-
for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
|
|
2503
|
+
for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
|
|
2504
|
+
for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
|
|
1523
2505
|
for (const m of code.matchAll(/\blet\s+([a-zA-Z_]\w*)/g)) {
|
|
1524
2506
|
declaredInCode.add(m[1]);
|
|
2507
|
+
userDefinedNames.add(m[1]);
|
|
1525
2508
|
// Track mutable variables for proper let destructuring
|
|
1526
2509
|
if (!context.__mutable) context.__mutable = new Set();
|
|
1527
2510
|
context.__mutable.add(m[1]);
|
|
@@ -1569,7 +2552,9 @@ async function startRepl() {
|
|
|
1569
2552
|
const fn = new Function('__ctx', `${destructure}${evalCode}`);
|
|
1570
2553
|
const result = fn(context);
|
|
1571
2554
|
if (result !== undefined) {
|
|
1572
|
-
|
|
2555
|
+
context._ = result; // Save as last result
|
|
2556
|
+
const typeStr = inferType(result);
|
|
2557
|
+
console.log(` ${result} ${c.typeHint}: ${typeStr}${c.reset}`);
|
|
1573
2558
|
}
|
|
1574
2559
|
} catch (e) {
|
|
1575
2560
|
// If return-wrapping fails, fall back to plain execution
|
|
@@ -1804,11 +2789,14 @@ async function productionBuild(srcDir, outDir) {
|
|
|
1804
2789
|
console.log(` index.html`);
|
|
1805
2790
|
}
|
|
1806
2791
|
|
|
1807
|
-
//
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
2792
|
+
// Minify all JS bundles using Bun's built-in transpiler
|
|
2793
|
+
const jsFiles = readdirSync(outDir).filter(f => f.endsWith('.js') && !f.endsWith('.min.js'));
|
|
2794
|
+
let minified = 0;
|
|
2795
|
+
for (const f of jsFiles) {
|
|
2796
|
+
const filePath = join(outDir, f);
|
|
2797
|
+
const minPath = join(outDir, f.replace('.js', '.min.js'));
|
|
2798
|
+
try {
|
|
2799
|
+
// Use Bun.build for proper minification with tree-shaking
|
|
1812
2800
|
const result = await Bun.build({
|
|
1813
2801
|
entrypoints: [filePath],
|
|
1814
2802
|
outdir: outDir,
|
|
@@ -1816,20 +2804,184 @@ async function productionBuild(srcDir, outDir) {
|
|
|
1816
2804
|
naming: f.replace('.js', '.min.js'),
|
|
1817
2805
|
});
|
|
1818
2806
|
if (result.success) {
|
|
1819
|
-
|
|
2807
|
+
const originalSize = statSync(filePath).size;
|
|
2808
|
+
const minSize = statSync(minPath).size;
|
|
2809
|
+
const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
|
|
2810
|
+
console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
|
|
2811
|
+
minified++;
|
|
2812
|
+
}
|
|
2813
|
+
} catch {
|
|
2814
|
+
// Bun.build not available — fall back to simple whitespace stripping
|
|
2815
|
+
try {
|
|
2816
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
2817
|
+
const stripped = _simpleMinify(source);
|
|
2818
|
+
writeFileSync(minPath, stripped);
|
|
2819
|
+
const originalSize = Buffer.byteLength(source);
|
|
2820
|
+
const minSize = Buffer.byteLength(stripped);
|
|
2821
|
+
const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
|
|
2822
|
+
console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
|
|
2823
|
+
minified++;
|
|
2824
|
+
} catch {
|
|
2825
|
+
// Skip files that can't be minified
|
|
1820
2826
|
}
|
|
1821
2827
|
}
|
|
1822
|
-
}
|
|
1823
|
-
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (minified === 0 && jsFiles.length > 0) {
|
|
2831
|
+
console.log(' (minification skipped — Bun.build unavailable)');
|
|
1824
2832
|
}
|
|
1825
2833
|
|
|
1826
2834
|
console.log(`\n Production build complete.\n`);
|
|
1827
2835
|
}
|
|
1828
2836
|
|
|
2837
|
+
// Simple minification fallback: strip comments and collapse whitespace
|
|
2838
|
+
function _simpleMinify(code) {
|
|
2839
|
+
// Strip single-line comments (but not URLs with //)
|
|
2840
|
+
let result = code.replace(/(?<![:"'])\/\/[^\n]*/g, '');
|
|
2841
|
+
// Strip multi-line comments
|
|
2842
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
2843
|
+
// Collapse multiple blank lines
|
|
2844
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
2845
|
+
// Trim trailing whitespace from each line
|
|
2846
|
+
result = result.replace(/[ \t]+$/gm, '');
|
|
2847
|
+
return result.trim();
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function _formatBytes(bytes) {
|
|
2851
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
2852
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2853
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// ─── Incremental Build Cache ─────────────────────────────────
|
|
2857
|
+
|
|
2858
|
+
class BuildCache {
|
|
2859
|
+
constructor(cacheDir) {
|
|
2860
|
+
this._cacheDir = cacheDir;
|
|
2861
|
+
this._manifest = null; // { files: { [absPath]: { hash, outputs: {...} } } }
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
_manifestPath() {
|
|
2865
|
+
return join(this._cacheDir, 'manifest.json');
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
_hashContent(content) {
|
|
2869
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
load() {
|
|
2873
|
+
try {
|
|
2874
|
+
if (existsSync(this._manifestPath())) {
|
|
2875
|
+
this._manifest = JSON.parse(readFileSync(this._manifestPath(), 'utf-8'));
|
|
2876
|
+
}
|
|
2877
|
+
} catch {
|
|
2878
|
+
this._manifest = null;
|
|
2879
|
+
}
|
|
2880
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
save() {
|
|
2884
|
+
mkdirSync(this._cacheDir, { recursive: true });
|
|
2885
|
+
writeFileSync(this._manifestPath(), JSON.stringify(this._manifest, null, 2));
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// Check if a source file is unchanged since last build
|
|
2889
|
+
isUpToDate(absPath, sourceContent) {
|
|
2890
|
+
if (!this._manifest) return false;
|
|
2891
|
+
const entry = this._manifest.files[absPath];
|
|
2892
|
+
if (!entry) return false;
|
|
2893
|
+
return entry.hash === this._hashContent(sourceContent);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// Check if a multi-file group (directory) is unchanged since last build
|
|
2897
|
+
isGroupUpToDate(dirKey, files) {
|
|
2898
|
+
if (!this._manifest) return false;
|
|
2899
|
+
const entry = this._manifest.files[dirKey];
|
|
2900
|
+
if (!entry) return false;
|
|
2901
|
+
return entry.hash === this._hashGroup(files);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Hash multiple files together for group caching
|
|
2905
|
+
_hashGroup(files) {
|
|
2906
|
+
const hash = createHash('sha256');
|
|
2907
|
+
for (const f of files.slice().sort()) {
|
|
2908
|
+
hash.update(f);
|
|
2909
|
+
hash.update(readFileSync(f, 'utf-8'));
|
|
2910
|
+
}
|
|
2911
|
+
return hash.digest('hex').slice(0, 16);
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// Store compiled output for a multi-file group
|
|
2915
|
+
setGroup(dirKey, files, outputs) {
|
|
2916
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
2917
|
+
this._manifest.files[dirKey] = {
|
|
2918
|
+
hash: this._hashGroup(files),
|
|
2919
|
+
outputs,
|
|
2920
|
+
timestamp: Date.now(),
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// Get cached compiled output for a source file
|
|
2925
|
+
getCached(absPath) {
|
|
2926
|
+
if (!this._manifest) return null;
|
|
2927
|
+
const entry = this._manifest.files[absPath];
|
|
2928
|
+
if (!entry || !entry.outputs) return null;
|
|
2929
|
+
// Verify cached output files still exist on disk
|
|
2930
|
+
for (const outFile of Object.values(entry.outputs)) {
|
|
2931
|
+
if (outFile && !existsSync(outFile)) return null;
|
|
2932
|
+
}
|
|
2933
|
+
return entry.outputs;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// Store compiled output for a source file
|
|
2937
|
+
set(absPath, sourceContent, outputs) {
|
|
2938
|
+
if (!this._manifest) this._manifest = { files: {} };
|
|
2939
|
+
this._manifest.files[absPath] = {
|
|
2940
|
+
hash: this._hashContent(sourceContent),
|
|
2941
|
+
outputs,
|
|
2942
|
+
timestamp: Date.now(),
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Remove stale entries for files/dirs that no longer exist
|
|
2947
|
+
prune(existingFiles, existingDirs) {
|
|
2948
|
+
if (!this._manifest) return;
|
|
2949
|
+
const existingSet = new Set(existingFiles);
|
|
2950
|
+
const dirSet = existingDirs ? new Set(existingDirs) : null;
|
|
2951
|
+
for (const key of Object.keys(this._manifest.files)) {
|
|
2952
|
+
if (key.startsWith('dir:')) {
|
|
2953
|
+
if (dirSet && !dirSet.has(key)) delete this._manifest.files[key];
|
|
2954
|
+
} else if (!existingSet.has(key)) {
|
|
2955
|
+
delete this._manifest.files[key];
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
|
|
1829
2961
|
// ─── Multi-file Import Support ───────────────────────────────
|
|
1830
2962
|
|
|
2963
|
+
// Determine the compiled JS extension for a .tova file.
|
|
2964
|
+
// Module files (no blocks) → '.js', app files → '.shared.js'
|
|
2965
|
+
function getCompiledExtension(tovaPath) {
|
|
2966
|
+
// Check compilation cache first
|
|
2967
|
+
if (compilationCache.has(tovaPath)) {
|
|
2968
|
+
return compilationCache.get(tovaPath).isModule ? '.js' : '.shared.js';
|
|
2969
|
+
}
|
|
2970
|
+
// Quick-scan the source for block keywords
|
|
2971
|
+
if (existsSync(tovaPath)) {
|
|
2972
|
+
const src = readFileSync(tovaPath, 'utf-8');
|
|
2973
|
+
// If the file contains top-level block keywords followed by '{', it's an app file
|
|
2974
|
+
if (/^(?:shared|server|client|test|bench|data)\s*(?:\{|")/m.test(src)) {
|
|
2975
|
+
return '.shared.js';
|
|
2976
|
+
}
|
|
2977
|
+
return '.js';
|
|
2978
|
+
}
|
|
2979
|
+
return '.shared.js'; // default fallback
|
|
2980
|
+
}
|
|
2981
|
+
|
|
1831
2982
|
const compilationCache = new Map();
|
|
1832
2983
|
const compilationInProgress = new Set();
|
|
2984
|
+
const compilationChain = []; // ordered import chain for circular import error messages
|
|
1833
2985
|
|
|
1834
2986
|
// Track module exports for cross-file import validation
|
|
1835
2987
|
const moduleExports = new Map();
|
|
@@ -1904,6 +3056,7 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
1904
3056
|
}
|
|
1905
3057
|
|
|
1906
3058
|
compilationInProgress.add(filename);
|
|
3059
|
+
compilationChain.push(filename);
|
|
1907
3060
|
|
|
1908
3061
|
try {
|
|
1909
3062
|
// Parse and find .tova imports
|
|
@@ -1920,7 +3073,8 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
1920
3073
|
if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
|
|
1921
3074
|
const importPath = resolve(dirname(filename), node.source);
|
|
1922
3075
|
if (compilationInProgress.has(importPath)) {
|
|
1923
|
-
|
|
3076
|
+
const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
|
|
3077
|
+
throw new Error(`Circular import detected: ${chain}`);
|
|
1924
3078
|
} else if (existsSync(importPath) && !compilationCache.has(importPath)) {
|
|
1925
3079
|
const importSource = readFileSync(importPath, 'utf-8');
|
|
1926
3080
|
compileWithImports(importSource, importPath, srcDir);
|
|
@@ -1938,28 +3092,33 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
1938
3092
|
}
|
|
1939
3093
|
}
|
|
1940
3094
|
}
|
|
1941
|
-
// Rewrite the import path to .js
|
|
1942
|
-
|
|
3095
|
+
// Rewrite the import path to .js (module) or .shared.js (app)
|
|
3096
|
+
const ext = getCompiledExtension(importPath);
|
|
3097
|
+
node.source = node.source.replace('.tova', ext);
|
|
1943
3098
|
}
|
|
1944
3099
|
if (node.type === 'ImportDefault' && node.source.endsWith('.tova')) {
|
|
1945
3100
|
const importPath = resolve(dirname(filename), node.source);
|
|
1946
3101
|
if (compilationInProgress.has(importPath)) {
|
|
1947
|
-
|
|
3102
|
+
const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
|
|
3103
|
+
throw new Error(`Circular import detected: ${chain}`);
|
|
1948
3104
|
} else if (existsSync(importPath) && !compilationCache.has(importPath)) {
|
|
1949
3105
|
const importSource = readFileSync(importPath, 'utf-8');
|
|
1950
3106
|
compileWithImports(importSource, importPath, srcDir);
|
|
1951
3107
|
}
|
|
1952
|
-
|
|
3108
|
+
const ext2 = getCompiledExtension(importPath);
|
|
3109
|
+
node.source = node.source.replace('.tova', ext2);
|
|
1953
3110
|
}
|
|
1954
3111
|
if (node.type === 'ImportWildcard' && node.source.endsWith('.tova')) {
|
|
1955
3112
|
const importPath = resolve(dirname(filename), node.source);
|
|
1956
3113
|
if (compilationInProgress.has(importPath)) {
|
|
1957
|
-
|
|
3114
|
+
const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
|
|
3115
|
+
throw new Error(`Circular import detected: ${chain}`);
|
|
1958
3116
|
} else if (existsSync(importPath) && !compilationCache.has(importPath)) {
|
|
1959
3117
|
const importSource = readFileSync(importPath, 'utf-8');
|
|
1960
3118
|
compileWithImports(importSource, importPath, srcDir);
|
|
1961
3119
|
}
|
|
1962
|
-
|
|
3120
|
+
const ext3 = getCompiledExtension(importPath);
|
|
3121
|
+
node.source = node.source.replace('.tova', ext3);
|
|
1963
3122
|
}
|
|
1964
3123
|
}
|
|
1965
3124
|
|
|
@@ -1979,6 +3138,7 @@ function compileWithImports(source, filename, srcDir) {
|
|
|
1979
3138
|
return output;
|
|
1980
3139
|
} finally {
|
|
1981
3140
|
compilationInProgress.delete(filename);
|
|
3141
|
+
compilationChain.pop();
|
|
1982
3142
|
}
|
|
1983
3143
|
}
|
|
1984
3144
|
|
|
@@ -2130,7 +3290,8 @@ function mergeDirectory(dir, srcDir, options = {}) {
|
|
|
2130
3290
|
// Only process imports from OTHER directories (same-dir files are merged)
|
|
2131
3291
|
if (dirname(importPath) !== dir) {
|
|
2132
3292
|
if (compilationInProgress.has(importPath)) {
|
|
2133
|
-
|
|
3293
|
+
const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
|
|
3294
|
+
throw new Error(`Circular import detected: ${chain}`);
|
|
2134
3295
|
} else if (existsSync(importPath) && !compilationCache.has(importPath)) {
|
|
2135
3296
|
const importSource = readFileSync(importPath, 'utf-8');
|
|
2136
3297
|
compileWithImports(importSource, importPath, srcDir);
|
|
@@ -2148,8 +3309,9 @@ function mergeDirectory(dir, srcDir, options = {}) {
|
|
|
2148
3309
|
}
|
|
2149
3310
|
}
|
|
2150
3311
|
}
|
|
2151
|
-
// Rewrite to .js
|
|
2152
|
-
|
|
3312
|
+
// Rewrite to .js (module) or .shared.js (app)
|
|
3313
|
+
const ext = getCompiledExtension(importPath);
|
|
3314
|
+
node.source = node.source.replace('.tova', ext);
|
|
2153
3315
|
} else {
|
|
2154
3316
|
// Same-directory import — remove it since files are merged
|
|
2155
3317
|
node._removed = true;
|
|
@@ -2251,4 +3413,132 @@ function findFiles(dir, ext) {
|
|
|
2251
3413
|
return results;
|
|
2252
3414
|
}
|
|
2253
3415
|
|
|
3416
|
+
// ─── Upgrade Command ─────────────────────────────────────────
|
|
3417
|
+
|
|
3418
|
+
async function upgradeCommand() {
|
|
3419
|
+
console.log(`\n Current version: Tova v${VERSION}\n`);
|
|
3420
|
+
console.log(' Checking for updates...');
|
|
3421
|
+
|
|
3422
|
+
try {
|
|
3423
|
+
// Check the npm registry for the latest version
|
|
3424
|
+
const res = await fetch('https://registry.npmjs.org/tova/latest');
|
|
3425
|
+
if (!res.ok) {
|
|
3426
|
+
console.error(' Could not reach the npm registry. Check your network connection.');
|
|
3427
|
+
process.exit(1);
|
|
3428
|
+
}
|
|
3429
|
+
const data = await res.json();
|
|
3430
|
+
const latestVersion = data.version;
|
|
3431
|
+
|
|
3432
|
+
if (latestVersion === VERSION) {
|
|
3433
|
+
console.log(` Already on the latest version (v${VERSION}).\n`);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
console.log(` New version available: v${latestVersion}\n`);
|
|
3438
|
+
console.log(' Upgrading...');
|
|
3439
|
+
|
|
3440
|
+
// Detect the package manager used to install tova
|
|
3441
|
+
const pm = detectPackageManager();
|
|
3442
|
+
const installCmd = pm === 'bun' ? ['bun', ['add', '-g', 'tova@latest']]
|
|
3443
|
+
: pm === 'pnpm' ? ['pnpm', ['add', '-g', 'tova@latest']]
|
|
3444
|
+
: pm === 'yarn' ? ['yarn', ['global', 'add', 'tova@latest']]
|
|
3445
|
+
: ['npm', ['install', '-g', 'tova@latest']];
|
|
3446
|
+
|
|
3447
|
+
const proc = spawn(installCmd[0], installCmd[1], { stdio: 'inherit' });
|
|
3448
|
+
const exitCode = await new Promise(res => proc.on('close', res));
|
|
3449
|
+
|
|
3450
|
+
if (exitCode === 0) {
|
|
3451
|
+
console.log(`\n Upgraded to Tova v${latestVersion}.\n`);
|
|
3452
|
+
} else {
|
|
3453
|
+
console.error(`\n Upgrade failed (exit code ${exitCode}).`);
|
|
3454
|
+
console.error(` Try manually: ${installCmd[0]} ${installCmd[1].join(' ')}\n`);
|
|
3455
|
+
process.exit(1);
|
|
3456
|
+
}
|
|
3457
|
+
} catch (err) {
|
|
3458
|
+
console.error(` Upgrade failed: ${err.message}`);
|
|
3459
|
+
console.error(' Try manually: bun add -g tova@latest\n');
|
|
3460
|
+
process.exit(1);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function detectPackageManager() {
|
|
3465
|
+
// Check if we're running under bun (most likely for Tova)
|
|
3466
|
+
if (typeof Bun !== 'undefined') return 'bun';
|
|
3467
|
+
// Check npm_config_user_agent for the package manager
|
|
3468
|
+
const ua = process.env.npm_config_user_agent || '';
|
|
3469
|
+
if (ua.includes('pnpm')) return 'pnpm';
|
|
3470
|
+
if (ua.includes('yarn')) return 'yarn';
|
|
3471
|
+
if (ua.includes('bun')) return 'bun';
|
|
3472
|
+
return 'npm';
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// ─── Info Command ────────────────────────────────────────────
|
|
3476
|
+
|
|
3477
|
+
async function infoCommand() {
|
|
3478
|
+
const config = resolveConfig(process.cwd());
|
|
3479
|
+
const hasTOML = config._source === 'tova.toml';
|
|
3480
|
+
|
|
3481
|
+
console.log(`\n ╦ ╦ ╦═╗ ╦`);
|
|
3482
|
+
console.log(` ║ ║ ║ ║ ╠╣`);
|
|
3483
|
+
console.log(` ╩═╝╚═╝╩═╝╩ v${VERSION}\n`);
|
|
3484
|
+
|
|
3485
|
+
// Bun version
|
|
3486
|
+
let bunVersion = 'not found';
|
|
3487
|
+
try {
|
|
3488
|
+
const proc = Bun.spawnSync(['bun', '--version']);
|
|
3489
|
+
bunVersion = proc.stdout.toString().trim();
|
|
3490
|
+
} catch {}
|
|
3491
|
+
console.log(` Runtime: Bun v${bunVersion}`);
|
|
3492
|
+
console.log(` Platform: ${process.platform} ${process.arch}`);
|
|
3493
|
+
console.log(` Node compat: ${process.version}`);
|
|
3494
|
+
|
|
3495
|
+
// Project config
|
|
3496
|
+
if (hasTOML) {
|
|
3497
|
+
console.log(`\n Project Config (tova.toml):`);
|
|
3498
|
+
if (config.project?.name) console.log(` Name: ${config.project.name}`);
|
|
3499
|
+
if (config.project?.version) console.log(` Version: ${config.project.version}`);
|
|
3500
|
+
if (config.project?.entry) console.log(` Entry: ${config.project.entry}`);
|
|
3501
|
+
if (config.build?.output) console.log(` Output: ${config.build.output}`);
|
|
3502
|
+
if (config.build?.target) console.log(` Target: ${config.build.target}`);
|
|
3503
|
+
} else {
|
|
3504
|
+
console.log(`\n No tova.toml found (using defaults).`);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
// Installed dependencies
|
|
3508
|
+
const pkgJsonPath = join(process.cwd(), 'package.json');
|
|
3509
|
+
if (existsSync(pkgJsonPath)) {
|
|
3510
|
+
try {
|
|
3511
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
3512
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
3513
|
+
const devDeps = Object.keys(pkg.devDependencies || {});
|
|
3514
|
+
|
|
3515
|
+
if (deps.length > 0 || devDeps.length > 0) {
|
|
3516
|
+
console.log(`\n Dependencies:`);
|
|
3517
|
+
for (const dep of deps) {
|
|
3518
|
+
console.log(` ${dep}: ${pkg.dependencies[dep]}`);
|
|
3519
|
+
}
|
|
3520
|
+
if (devDeps.length > 0) {
|
|
3521
|
+
console.log(` Dev Dependencies:`);
|
|
3522
|
+
for (const dep of devDeps) {
|
|
3523
|
+
console.log(` ${dep}: ${pkg.devDependencies[dep]}`);
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
} else {
|
|
3527
|
+
console.log(`\n No dependencies installed.`);
|
|
3528
|
+
}
|
|
3529
|
+
} catch {}
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
// Build output status
|
|
3533
|
+
const outDir = resolve(config.build?.output || '.tova-out');
|
|
3534
|
+
if (existsSync(outDir)) {
|
|
3535
|
+
const files = findFiles(outDir, '.js');
|
|
3536
|
+
console.log(`\n Build output: ${relative('.', outDir)}/ (${files.length} file${files.length === 1 ? '' : 's'})`);
|
|
3537
|
+
} else {
|
|
3538
|
+
console.log(`\n Build output: not built yet`);
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
console.log('');
|
|
3542
|
+
}
|
|
3543
|
+
|
|
2254
3544
|
main();
|