future-lang 0.3.2 → 0.4.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/FUTURE_FOR_LLMS.md +364 -0
- package/package.json +1 -1
- package/runtime/ai.js +20 -9
- package/runtime/assert.js +27 -0
- package/runtime/http.js +54 -8
- package/runtime/index.js +42 -3
- package/runtime/providers/anthropic.js +38 -10
- package/runtime/providers/openai-compat.js +35 -19
- package/src/cli.js +120 -11
- package/src/generator.js +50 -8
- package/src/index.js +2 -0
- package/src/parser.js +55 -24
- package/src/sourcemap.js +69 -0
|
@@ -7,19 +7,20 @@
|
|
|
7
7
|
// No separate SDK or special casing needed.
|
|
8
8
|
|
|
9
9
|
import { parseSSE, keywordVector } from './util.js';
|
|
10
|
+
import { AiError } from './anthropic.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Well-known provider presets. Used when FUTURE_AI_PROVIDER is set without FUTURE_AI_BASE_URL.
|
|
13
14
|
* Users can always override by setting FUTURE_AI_BASE_URL directly.
|
|
14
15
|
*/
|
|
15
16
|
export const PRESETS = {
|
|
16
|
-
openai: { baseUrl: 'https://api.openai.com/v1',
|
|
17
|
-
ollama: { baseUrl: 'http://localhost:11434/v1',
|
|
18
|
-
openrouter: { baseUrl: 'https://openrouter.ai/api/v1',
|
|
17
|
+
openai: { baseUrl: 'https://api.openai.com/v1', embedModel: 'text-embedding-3-small' },
|
|
18
|
+
ollama: { baseUrl: 'http://localhost:11434/v1', embedModel: 'nomic-embed-text' },
|
|
19
|
+
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', embedModel: null },
|
|
19
20
|
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', embedModel: 'text-embedding-004' },
|
|
20
|
-
venice: { baseUrl: 'https://api.venice.ai/api/v1',
|
|
21
|
-
groq: { baseUrl: 'https://api.groq.com/openai/v1',
|
|
22
|
-
together: { baseUrl: 'https://api.together.xyz/v1',
|
|
21
|
+
venice: { baseUrl: 'https://api.venice.ai/api/v1', embedModel: null },
|
|
22
|
+
groq: { baseUrl: 'https://api.groq.com/openai/v1', embedModel: null },
|
|
23
|
+
together: { baseUrl: 'https://api.together.xyz/v1', embedModel: 'togethercomputer/m2-bert-80M-8k-retrieval' },
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -27,38 +28,53 @@ export const PRESETS = {
|
|
|
27
28
|
* @param {{ baseUrl: string, apiKey: string, model?: string, embedModel?: string }} config
|
|
28
29
|
*/
|
|
29
30
|
export function create(config) {
|
|
30
|
-
const baseUrl
|
|
31
|
-
const apiKey
|
|
32
|
-
const
|
|
33
|
-
const embedModel
|
|
31
|
+
const baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
32
|
+
const apiKey = config.apiKey;
|
|
33
|
+
const defaultModel = config.model ?? 'gpt-4o-mini';
|
|
34
|
+
const embedModel = config.embedModel ?? null;
|
|
35
|
+
const providerTag = `openai-compat(${baseUrl})`;
|
|
34
36
|
|
|
35
37
|
const headers = {
|
|
36
38
|
'content-type': 'application/json',
|
|
37
39
|
'authorization': `Bearer ${apiKey}`,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
async function chat(messages) {
|
|
42
|
+
async function chat(messages, opts = {}) {
|
|
43
|
+
const model = opts.model ?? defaultModel;
|
|
44
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
45
|
+
const body = { model, messages, max_tokens };
|
|
46
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
47
|
+
|
|
41
48
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
42
49
|
method: 'POST',
|
|
43
50
|
headers,
|
|
44
|
-
body: JSON.stringify(
|
|
51
|
+
body: JSON.stringify(body),
|
|
45
52
|
});
|
|
46
|
-
if (!res.ok)
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
let errBody;
|
|
55
|
+
try { errBody = await res.json(); } catch { errBody = await res.text(); }
|
|
56
|
+
throw new AiError(res.status, providerTag, errBody);
|
|
57
|
+
}
|
|
47
58
|
const data = await res.json();
|
|
48
59
|
return data.choices?.[0]?.message?.content?.trim() ?? '';
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
async function ask(prompt) {
|
|
52
|
-
return chat([{ role: 'user', content: String(prompt) }]);
|
|
62
|
+
async function ask(prompt, opts = {}) {
|
|
63
|
+
return chat([{ role: 'user', content: String(prompt) }], opts);
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
async function stream(messages, onChunk) {
|
|
66
|
+
async function stream(messages, onChunk, opts = {}) {
|
|
67
|
+
const model = opts.model ?? defaultModel;
|
|
68
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
69
|
+
const body = { model, messages, max_tokens, stream: true };
|
|
70
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
71
|
+
|
|
56
72
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
57
73
|
method: 'POST',
|
|
58
74
|
headers,
|
|
59
|
-
body: JSON.stringify(
|
|
75
|
+
body: JSON.stringify(body),
|
|
60
76
|
});
|
|
61
|
-
if (!res.ok) throw new
|
|
77
|
+
if (!res.ok) throw new AiError(res.status, providerTag, `stream HTTP ${res.status}`);
|
|
62
78
|
for await (const { data } of parseSSE(res.body)) {
|
|
63
79
|
const chunk = data.choices?.[0]?.delta?.content;
|
|
64
80
|
if (chunk) onChunk(chunk);
|
|
@@ -81,5 +97,5 @@ export function create(config) {
|
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
return { name:
|
|
100
|
+
return { name: providerTag, ask, chat, stream, embed };
|
|
85
101
|
}
|
package/src/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// future help | --help
|
|
12
12
|
// future version | --version
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
15
15
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
@@ -20,8 +20,9 @@ import process from 'node:process';
|
|
|
20
20
|
import { compile, tokenize, parse } from './index.js';
|
|
21
21
|
import { format } from './formatter.js';
|
|
22
22
|
import { FutureError } from './errors.js';
|
|
23
|
+
import { buildSourceMap } from './sourcemap.js';
|
|
23
24
|
|
|
24
|
-
const VERSION = '0.
|
|
25
|
+
const VERSION = '0.4.1';
|
|
25
26
|
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
27
|
const RUNTIME_INDEX = join(PROJECT_ROOT, 'runtime', 'index.js');
|
|
27
28
|
|
|
@@ -30,6 +31,7 @@ const USAGE = `Future ${VERSION} — a tiny language that compiles to JavaScript
|
|
|
30
31
|
Usage:
|
|
31
32
|
future run <file.future> Compile and run a program
|
|
32
33
|
future compile <file.future> Compile to JavaScript (<file>.js)
|
|
34
|
+
future test [pattern] Run *.test.future files
|
|
33
35
|
future new <name> Create a new project
|
|
34
36
|
future check <file.future> Check for syntax errors
|
|
35
37
|
future fmt <file.future> Format source code in-place
|
|
@@ -41,13 +43,23 @@ Usage:
|
|
|
41
43
|
Import system:
|
|
42
44
|
use "./utils.future" Import all functions from a file
|
|
43
45
|
use "./math.future" as math Import as a namespace (math.add, math.pi …)
|
|
46
|
+
use "lodash" as _ Import an npm package as a namespace
|
|
47
|
+
|
|
48
|
+
Flags:
|
|
49
|
+
future run --debug <file> Show timing for every namespace call
|
|
50
|
+
future compile --sourcemap <file> Also emit a .js.map source map
|
|
44
51
|
`;
|
|
45
52
|
|
|
46
53
|
async function main(argv) {
|
|
47
|
-
const
|
|
54
|
+
const debug = argv.includes('--debug');
|
|
55
|
+
const sourcemap = argv.includes('--sourcemap');
|
|
56
|
+
if (debug) process.env.FUTURE_DEBUG = '1';
|
|
57
|
+
const rest = argv.filter((a) => a !== '--debug' && a !== '--sourcemap');
|
|
58
|
+
const [command, arg] = rest;
|
|
48
59
|
switch (command) {
|
|
49
60
|
case 'run': return cmdRun(arg);
|
|
50
|
-
case 'compile': return cmdCompile(arg);
|
|
61
|
+
case 'compile': return cmdCompile(arg, { sourcemap });
|
|
62
|
+
case 'test': return cmdTest(arg);
|
|
51
63
|
case 'new': return cmdNew(arg);
|
|
52
64
|
case 'check': return cmdCheck(arg);
|
|
53
65
|
case 'fmt': return cmdFmt(arg);
|
|
@@ -138,6 +150,7 @@ function compileDepsToTemp(sourcePath, sourceText, tempDir, pathMap = new Map())
|
|
|
138
150
|
const uses = findUseStatements(sourceText);
|
|
139
151
|
for (const { path: relPath } of uses) {
|
|
140
152
|
if (pathMap.has(relPath)) continue; // already compiled
|
|
153
|
+
if (!relPath.startsWith('./') && !relPath.startsWith('../')) continue; // npm module
|
|
141
154
|
const depAbsPath = resolve(dirname(sourcePath), relPath);
|
|
142
155
|
if (!existsSync(depAbsPath)) continue;
|
|
143
156
|
const depSource = readFileSync(depAbsPath, 'utf8');
|
|
@@ -163,6 +176,7 @@ function compileDepModule(source, sourcePath, tempDir, pathMap) {
|
|
|
163
176
|
// Ensure transitive deps are compiled first so pathMap is populated.
|
|
164
177
|
for (const { path: relPath } of uses) {
|
|
165
178
|
if (pathMap.has(relPath)) continue;
|
|
179
|
+
if (!relPath.startsWith('./') && !relPath.startsWith('../')) continue; // npm module
|
|
166
180
|
const depAbsPath = resolve(dirname(sourcePath), relPath);
|
|
167
181
|
if (!existsSync(depAbsPath)) continue;
|
|
168
182
|
const depSrc = readFileSync(depAbsPath, 'utf8');
|
|
@@ -189,7 +203,7 @@ function compileDepModule(source, sourcePath, tempDir, pathMap) {
|
|
|
189
203
|
// Commands
|
|
190
204
|
// ---------------------------------------------------------------------------
|
|
191
205
|
|
|
192
|
-
function cmdCompile(file) {
|
|
206
|
+
function cmdCompile(file, { sourcemap = false } = {}) {
|
|
193
207
|
let path, source;
|
|
194
208
|
try { ({ path, source } = readSource(file)); }
|
|
195
209
|
catch (err) { return fail(err, file); }
|
|
@@ -199,6 +213,7 @@ function cmdCompile(file) {
|
|
|
199
213
|
// Compile each imported .future dep as a module next to its source.
|
|
200
214
|
const uses = findUseStatements(source);
|
|
201
215
|
for (const { path: relPath } of uses) {
|
|
216
|
+
if (!relPath.startsWith('./') && !relPath.startsWith('../')) continue; // npm module
|
|
202
217
|
const depAbsPath = resolve(outDir, relPath);
|
|
203
218
|
if (!existsSync(depAbsPath)) {
|
|
204
219
|
process.stderr.write(`warning: imported file not found: ${relPath}\n`);
|
|
@@ -220,18 +235,30 @@ function cmdCompile(file) {
|
|
|
220
235
|
console.log(`Compiled ${relPath} -> ${depOut}`);
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
const
|
|
238
|
+
const rawJs = compileOrReport(source, file, {
|
|
224
239
|
runtimeSpecifier: relativeRuntimeSpecifier(outDir),
|
|
240
|
+
sourceMaps: sourcemap,
|
|
225
241
|
resolveSource: (relPath) => {
|
|
226
242
|
const abs = resolve(outDir, relPath);
|
|
227
243
|
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
228
244
|
},
|
|
229
245
|
});
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
if (rawJs === null) return 1;
|
|
247
|
+
|
|
248
|
+
const outBase = join(outDir, basename(path, extname(path)));
|
|
249
|
+
const outPath = `${outBase}.js`;
|
|
250
|
+
|
|
251
|
+
if (sourcemap) {
|
|
252
|
+
const mapFile = `${outBase}.js.map`;
|
|
253
|
+
const { code, map } = buildSourceMap(rawJs, basename(file), source);
|
|
254
|
+
writeFileSync(outPath, `${code}//# sourceMappingURL=${basename(mapFile)}\n`, 'utf8');
|
|
255
|
+
writeFileSync(mapFile, JSON.stringify(map), 'utf8');
|
|
256
|
+
console.log(`Compiled ${file} -> ${outPath}`);
|
|
257
|
+
console.log(`Source map -> ${mapFile}`);
|
|
258
|
+
} else {
|
|
259
|
+
writeFileSync(outPath, rawJs, 'utf8');
|
|
260
|
+
console.log(`Compiled ${file} -> ${outPath}`);
|
|
261
|
+
}
|
|
235
262
|
return 0;
|
|
236
263
|
}
|
|
237
264
|
|
|
@@ -318,6 +345,88 @@ function cmdFmt(file) {
|
|
|
318
345
|
return 0;
|
|
319
346
|
}
|
|
320
347
|
|
|
348
|
+
/** Recursively collect .future files matching a pattern or default test globs. */
|
|
349
|
+
function findTestFiles(pattern) {
|
|
350
|
+
const cwd = process.cwd();
|
|
351
|
+
const files = [];
|
|
352
|
+
|
|
353
|
+
function walk(dir) {
|
|
354
|
+
let entries;
|
|
355
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
if (entry === 'node_modules') continue;
|
|
358
|
+
const full = join(dir, entry);
|
|
359
|
+
const st = statSync(full);
|
|
360
|
+
if (st.isDirectory()) { walk(full); continue; }
|
|
361
|
+
if (!entry.endsWith('.future')) continue;
|
|
362
|
+
const rel = relative(cwd, full).split('\\').join('/');
|
|
363
|
+
if (pattern) {
|
|
364
|
+
if (rel.includes(pattern) || entry.includes(pattern)) files.push(full);
|
|
365
|
+
} else {
|
|
366
|
+
if (entry.endsWith('.test.future') || rel.startsWith('test/')) files.push(full);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
walk(cwd);
|
|
372
|
+
return files;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Run *.test.future files and report results. */
|
|
376
|
+
async function cmdTest(pattern) {
|
|
377
|
+
const testFiles = findTestFiles(pattern);
|
|
378
|
+
if (testFiles.length === 0) {
|
|
379
|
+
process.stderr.write('No test files found.\n');
|
|
380
|
+
process.stderr.write(' Naming: *.test.future or test/**/*.future\n');
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const tempDir = tmpdir();
|
|
385
|
+
let passed = 0;
|
|
386
|
+
let failed = 0;
|
|
387
|
+
|
|
388
|
+
for (const testFile of testFiles) {
|
|
389
|
+
const rel = relative(process.cwd(), testFile).split('\\').join('/');
|
|
390
|
+
let source;
|
|
391
|
+
try { source = readFileSync(testFile, 'utf8'); } catch (err) { process.stderr.write(`error reading ${rel}: ${err.message}\n`); failed++; continue; }
|
|
392
|
+
|
|
393
|
+
// Compile dependencies.
|
|
394
|
+
const pathMap = compileDepsToTemp(testFile, source, tempDir);
|
|
395
|
+
if (pathMap === null) { failed++; continue; }
|
|
396
|
+
|
|
397
|
+
const js = compileOrReport(source, rel, {
|
|
398
|
+
runtimeSpecifier: pathToFileURL(RUNTIME_INDEX).href,
|
|
399
|
+
pathMap,
|
|
400
|
+
resolveSource: (p) => {
|
|
401
|
+
const abs = resolve(dirname(testFile), p);
|
|
402
|
+
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
if (js === null) { failed++; continue; }
|
|
406
|
+
|
|
407
|
+
const tmp = join(tempDir, `future-test-${process.pid}-${Date.now()}.mjs`);
|
|
408
|
+
writeFileSync(tmp, js, 'utf8');
|
|
409
|
+
const depTmps = [...pathMap.values()].map((u) => fileURLToPath(u));
|
|
410
|
+
try {
|
|
411
|
+
await import(pathToFileURL(tmp).href);
|
|
412
|
+
console.log(` ✓ ${rel}`);
|
|
413
|
+
passed++;
|
|
414
|
+
} catch (err) {
|
|
415
|
+
const isAssert = err.name === 'AssertionError' || err.namespace === 'assert';
|
|
416
|
+
process.stderr.write(` ✗ ${rel}\n`);
|
|
417
|
+
process.stderr.write(` ${isAssert ? 'AssertionError' : err.name ?? 'Error'}: ${err.message}\n`);
|
|
418
|
+
failed++;
|
|
419
|
+
} finally {
|
|
420
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
421
|
+
for (const p of depTmps) { try { unlinkSync(p); } catch { /* ignore */ } }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const total = passed + failed;
|
|
426
|
+
console.log(`\n${passed}/${total} tests passed${failed > 0 ? `, ${failed} failed` : ''}`);
|
|
427
|
+
return failed > 0 ? 1 : 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
321
430
|
/** Create a new project scaffold. */
|
|
322
431
|
function cmdNew(name) {
|
|
323
432
|
if (!name) {
|
package/src/generator.js
CHANGED
|
@@ -24,6 +24,7 @@ export const NAMESPACES = new Set([
|
|
|
24
24
|
'rag', 'vision', 'home', // AI / automation extension points
|
|
25
25
|
'memory', 'schedule', 'system', 'device', // optional new modules
|
|
26
26
|
'math', // general-purpose math
|
|
27
|
+
'assert', // test assertions
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
export class Generator {
|
|
@@ -36,6 +37,7 @@ export class Generator {
|
|
|
36
37
|
this.runtimeSpecifier = options.runtimeSpecifier ?? 'future-lang/runtime';
|
|
37
38
|
this.browserMode = options.browserMode ?? false;
|
|
38
39
|
this.isModule = options.isModule ?? false;
|
|
40
|
+
this.sourceMaps = options.sourceMaps ?? false;
|
|
39
41
|
// Map<importedFuturePath, string[]> — exported names for non-aliased use statements.
|
|
40
42
|
this.importedNames = options.importedNames ?? new Map();
|
|
41
43
|
// Map<importedFuturePath, resolvedJsPath> — path override for `future run` temp files.
|
|
@@ -62,6 +64,12 @@ export class Generator {
|
|
|
62
64
|
lines.push('');
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// __safe wraps async event handlers so errors are logged instead of crashing silently.
|
|
68
|
+
if (usesHandlers(program)) {
|
|
69
|
+
lines.push('const __safe = (ns, fn) => async (...a) => { try { return await fn(...a); } catch (e) { console.error(`[future:${ns}]`, e.message); } };');
|
|
70
|
+
lines.push('');
|
|
71
|
+
}
|
|
72
|
+
|
|
65
73
|
// Emit __len helper only when len() is actually used — keeps simple programs clean.
|
|
66
74
|
if (usesBuiltin(program, 'len')) {
|
|
67
75
|
lines.push('function __len(x) { return x == null ? 0 : (x.length ?? Object.keys(x).length); }');
|
|
@@ -73,18 +81,29 @@ export class Generator {
|
|
|
73
81
|
}
|
|
74
82
|
for (const stmt of program.body) {
|
|
75
83
|
if (stmt.type === NodeType.UseStatement) continue; // already emitted above
|
|
76
|
-
|
|
84
|
+
const code = this.genStatement(stmt, 0, /* topLevel= */ true);
|
|
85
|
+
if (this.sourceMaps && stmt.line != null) {
|
|
86
|
+
lines.push(`/*@FL:${stmt.line}*/${code}`);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(code);
|
|
89
|
+
}
|
|
77
90
|
}
|
|
78
91
|
return lines.join('\n') + '\n';
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
/** Emit an ES `import` for a `use` statement. */
|
|
82
95
|
genUseStatement(node) {
|
|
83
|
-
const
|
|
96
|
+
const isRelative = node.path.startsWith('./') || node.path.startsWith('../');
|
|
97
|
+
const jsPath = isRelative ? node.path.replace(/\.future$/, '.js') : node.path;
|
|
84
98
|
const resolved = this.pathMap.get(node.path) ?? jsPath;
|
|
99
|
+
|
|
85
100
|
if (node.alias) {
|
|
86
101
|
return `import * as ${node.alias} from ${JSON.stringify(resolved)};`;
|
|
87
102
|
}
|
|
103
|
+
if (!isRelative) {
|
|
104
|
+
// npm module without alias — side-effect import
|
|
105
|
+
return `import ${JSON.stringify(resolved)};`;
|
|
106
|
+
}
|
|
88
107
|
const names = this.importedNames.get(node.path) ?? [];
|
|
89
108
|
if (names.length > 0) {
|
|
90
109
|
return `import { ${names.join(', ')} } from ${JSON.stringify(resolved)};`;
|
|
@@ -111,7 +130,13 @@ export class Generator {
|
|
|
111
130
|
out += this.genBody(node.consequent, depth + 1);
|
|
112
131
|
out += `\n${pad}}`;
|
|
113
132
|
if (node.alternate) {
|
|
114
|
-
|
|
133
|
+
// Single chained IfStatement → `else if (...)` without extra braces.
|
|
134
|
+
if (node.alternate.length === 1 && node.alternate[0].type === NodeType.IfStatement) {
|
|
135
|
+
const elseIf = this.genStatement(node.alternate[0], depth);
|
|
136
|
+
out += ` else ${elseIf.trimStart()}`;
|
|
137
|
+
} else {
|
|
138
|
+
out += ` else {\n${this.genBody(node.alternate, depth + 1)}\n${pad}}`;
|
|
139
|
+
}
|
|
115
140
|
}
|
|
116
141
|
return out;
|
|
117
142
|
}
|
|
@@ -172,7 +197,7 @@ export class Generator {
|
|
|
172
197
|
const args = call.arguments.map((a) => this.genExpression(a)).join(', ');
|
|
173
198
|
const sep = args ? ', ' : '';
|
|
174
199
|
const inner = this.genBody(node.body, depth + 1);
|
|
175
|
-
return `${pad}await __rt.${ns}.stream(${args}${sep}async (chunk) => {\n${inner}\n${pad}});`;
|
|
200
|
+
return `${pad}await __rt.${ns}.stream(${args}${sep}__safe("stream", async (chunk) => {\n${inner}\n${pad}}));`;
|
|
176
201
|
}
|
|
177
202
|
|
|
178
203
|
case NodeType.TryStatement: {
|
|
@@ -201,18 +226,19 @@ export class Generator {
|
|
|
201
226
|
|
|
202
227
|
case NodeType.OnStatement: {
|
|
203
228
|
// `on mqtt "topic" ... end`
|
|
204
|
-
//
|
|
229
|
+
// → await __rt.<source>.subscribe(<channel>, __safe("<source>", async (message) => { ... }))
|
|
205
230
|
const inner = this.genBody(node.body, depth + 1);
|
|
206
231
|
const chan = this.genExpression(node.channel);
|
|
207
|
-
|
|
232
|
+
const ns = JSON.stringify(node.source);
|
|
233
|
+
return `${pad}await __rt.${node.source}.subscribe(${chan}, __safe(${ns}, async (message) => {\n${inner}\n${pad}}));`;
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
case NodeType.EveryStatement: {
|
|
211
237
|
// `every "30m" ... end`
|
|
212
|
-
//
|
|
238
|
+
// → await __rt.schedule.every(<interval>, __safe("schedule", async () => { ... }))
|
|
213
239
|
const inner = this.genBody(node.body, depth + 1);
|
|
214
240
|
const interval = this.genExpression(node.interval);
|
|
215
|
-
return `${pad}await __rt.schedule.every(${interval}, async () => {\n${inner}\n${pad}});`;
|
|
241
|
+
return `${pad}await __rt.schedule.every(${interval}, __safe("schedule", async () => {\n${inner}\n${pad}}));`;
|
|
216
242
|
}
|
|
217
243
|
|
|
218
244
|
default:
|
|
@@ -390,6 +416,22 @@ function usesRuntime(node, useAliases = new Set()) {
|
|
|
390
416
|
return false;
|
|
391
417
|
}
|
|
392
418
|
|
|
419
|
+
/** True if the program has any async event handlers (on/every/stream). */
|
|
420
|
+
function usesHandlers(node) {
|
|
421
|
+
if (!node || typeof node !== 'object') return false;
|
|
422
|
+
if (
|
|
423
|
+
node.type === NodeType.OnStatement ||
|
|
424
|
+
node.type === NodeType.EveryStatement ||
|
|
425
|
+
node.type === NodeType.StreamStatement
|
|
426
|
+
) return true;
|
|
427
|
+
for (const key of Object.keys(node)) {
|
|
428
|
+
const v = node[key];
|
|
429
|
+
if (Array.isArray(v)) { if (v.some(usesHandlers)) return true; }
|
|
430
|
+
else if (v && typeof v === 'object' && v.type) { if (usesHandlers(v)) return true; }
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
393
435
|
/** Walk the AST and check if a specific built-in function name is called. */
|
|
394
436
|
function usesBuiltin(node, name) {
|
|
395
437
|
if (!node || typeof node !== 'object') return false;
|
package/src/index.js
CHANGED
|
@@ -31,6 +31,8 @@ export function compile(source, options = {}) {
|
|
|
31
31
|
if (options.resolveSource) {
|
|
32
32
|
for (const stmt of ast.body) {
|
|
33
33
|
if (stmt.type !== 'UseStatement' || stmt.alias) continue;
|
|
34
|
+
// Skip npm module imports — no source to resolve.
|
|
35
|
+
if (!stmt.path.startsWith('./') && !stmt.path.startsWith('../')) continue;
|
|
34
36
|
try {
|
|
35
37
|
const importedSrc = options.resolveSource(stmt.path);
|
|
36
38
|
if (importedSrc) {
|
package/src/parser.js
CHANGED
|
@@ -16,6 +16,12 @@ import * as AST from './ast.js';
|
|
|
16
16
|
*/
|
|
17
17
|
const EXPR_TERMINATORS = new Set(['END', 'ELSE', 'CATCH', 'EOF']);
|
|
18
18
|
|
|
19
|
+
/** Built-in namespace names that cannot be redefined by user code. */
|
|
20
|
+
const RESERVED_NAMESPACES = new Set([
|
|
21
|
+
'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
|
|
22
|
+
'memory', 'schedule', 'system', 'device', 'math', 'assert',
|
|
23
|
+
]);
|
|
24
|
+
|
|
19
25
|
export class Parser {
|
|
20
26
|
/** @param {import('./lexer.js').Token[]} tokens */
|
|
21
27
|
constructor(tokens) {
|
|
@@ -113,27 +119,51 @@ export class Parser {
|
|
|
113
119
|
|
|
114
120
|
parseAssignment() {
|
|
115
121
|
const name = this.advance(); // IDENTIFIER
|
|
122
|
+
if (RESERVED_NAMESPACES.has(name.value)) {
|
|
123
|
+
throw new FutureError(
|
|
124
|
+
`'${name.value}' is a reserved namespace and cannot be reassigned`,
|
|
125
|
+
name.line, name.column, 'parse',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
116
128
|
this.expect('ASSIGN', "'='");
|
|
117
129
|
const value = this.parseExpression();
|
|
118
130
|
return AST.Assignment(name.value, value, name.line, name.column);
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
/**
|
|
134
|
+
* `if cond ... [else if cond ...]* [else ...] end`
|
|
135
|
+
* @param {boolean} isChained True when parsing an `else if` branch — the
|
|
136
|
+
* outer `if` owns the single `end`, so this call must NOT consume it.
|
|
137
|
+
*/
|
|
138
|
+
parseIf(isChained = false) {
|
|
122
139
|
const kw = this.advance(); // IF
|
|
123
140
|
const condition = this.parseExpression();
|
|
124
|
-
const consequent = this.parseBlock(['ELSE', 'END']);
|
|
141
|
+
const consequent = this.parseBlock(['ELSE', 'END'], 'if');
|
|
125
142
|
let alternate = null;
|
|
126
143
|
if (this.check('ELSE')) {
|
|
127
|
-
this.advance();
|
|
128
|
-
|
|
144
|
+
this.advance(); // ELSE
|
|
145
|
+
if (this.check('IF')) {
|
|
146
|
+
// else if — recurse; the chained call skips its own `end`
|
|
147
|
+
alternate = [this.parseIf(true)];
|
|
148
|
+
} else {
|
|
149
|
+
alternate = this.parseBlock(['END'], 'else');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!isChained) {
|
|
153
|
+
this.expect('END', "'end' to close 'if'");
|
|
129
154
|
}
|
|
130
|
-
this.expect('END', "'end'");
|
|
131
155
|
return AST.IfStatement(condition, consequent, alternate, kw.line, kw.column);
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
parseFunction() {
|
|
135
159
|
const kw = this.advance(); // FUNCTION
|
|
136
160
|
const name = this.expect('IDENTIFIER', 'function name');
|
|
161
|
+
if (RESERVED_NAMESPACES.has(name.value)) {
|
|
162
|
+
throw new FutureError(
|
|
163
|
+
`'${name.value}' is a reserved namespace and cannot be used as a function name`,
|
|
164
|
+
name.line, name.column, 'parse',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
137
167
|
this.expect('LPAREN', "'('");
|
|
138
168
|
const params = [];
|
|
139
169
|
if (!this.check('RPAREN')) {
|
|
@@ -142,8 +172,8 @@ export class Parser {
|
|
|
142
172
|
} while (this.match('COMMA'));
|
|
143
173
|
}
|
|
144
174
|
this.expect('RPAREN', "')'");
|
|
145
|
-
const body = this.parseBlock(['END']);
|
|
146
|
-
this.expect('END', "'end'");
|
|
175
|
+
const body = this.parseBlock(['END'], 'function');
|
|
176
|
+
this.expect('END', "'end' to close 'function'");
|
|
147
177
|
return AST.FunctionDeclaration(name.value, params, body, kw.line, kw.column);
|
|
148
178
|
}
|
|
149
179
|
|
|
@@ -169,8 +199,8 @@ export class Parser {
|
|
|
169
199
|
const variable = this.expect('IDENTIFIER', 'loop variable name');
|
|
170
200
|
this.expect('IN', "'in'");
|
|
171
201
|
const iterable = this.parseExpression();
|
|
172
|
-
const body = this.parseBlock(['END']);
|
|
173
|
-
this.expect('END', "'end'");
|
|
202
|
+
const body = this.parseBlock(['END'], 'for');
|
|
203
|
+
this.expect('END', "'end' to close 'for'");
|
|
174
204
|
return AST.ForStatement(variable.value, iterable, body, kw.line, kw.column);
|
|
175
205
|
}
|
|
176
206
|
|
|
@@ -179,11 +209,11 @@ export class Parser {
|
|
|
179
209
|
*/
|
|
180
210
|
parseTry() {
|
|
181
211
|
const kw = this.advance(); // TRY
|
|
182
|
-
const body = this.parseBlock(['CATCH']);
|
|
183
|
-
this.expect('CATCH', "'catch'");
|
|
212
|
+
const body = this.parseBlock(['CATCH'], 'try');
|
|
213
|
+
this.expect('CATCH', "'catch' after 'try' block");
|
|
184
214
|
const errVar = this.expect('IDENTIFIER', 'error variable name');
|
|
185
|
-
const catchBody = this.parseBlock(['END']);
|
|
186
|
-
this.expect('END', "'end'");
|
|
215
|
+
const catchBody = this.parseBlock(['END'], 'catch');
|
|
216
|
+
this.expect('END', "'end' to close 'try'");
|
|
187
217
|
return AST.TryStatement(body, errVar.value, catchBody, kw.line, kw.column);
|
|
188
218
|
}
|
|
189
219
|
|
|
@@ -211,7 +241,7 @@ export class Parser {
|
|
|
211
241
|
body.push(this.parseStatement());
|
|
212
242
|
}
|
|
213
243
|
}
|
|
214
|
-
this.expect('END', "'end'");
|
|
244
|
+
this.expect('END', "'end' to close 'agent'");
|
|
215
245
|
return AST.AgentDeclaration(name.value, capabilities, body, kw.line, kw.column);
|
|
216
246
|
}
|
|
217
247
|
|
|
@@ -221,8 +251,8 @@ export class Parser {
|
|
|
221
251
|
parseWhile() {
|
|
222
252
|
const kw = this.advance(); // WHILE
|
|
223
253
|
const condition = this.parseExpression();
|
|
224
|
-
const body = this.parseBlock(['END']);
|
|
225
|
-
this.expect('END', "'end'");
|
|
254
|
+
const body = this.parseBlock(['END'], 'while');
|
|
255
|
+
this.expect('END', "'end' to close 'while'");
|
|
226
256
|
return AST.WhileStatement(condition, body, kw.line, kw.column);
|
|
227
257
|
}
|
|
228
258
|
|
|
@@ -235,8 +265,8 @@ export class Parser {
|
|
|
235
265
|
parseStream() {
|
|
236
266
|
const kw = this.advance(); // STREAM
|
|
237
267
|
const call = this.parseExpression();
|
|
238
|
-
const body = this.parseBlock(['END']);
|
|
239
|
-
this.expect('END', "'end'");
|
|
268
|
+
const body = this.parseBlock(['END'], 'stream');
|
|
269
|
+
this.expect('END', "'end' to close 'stream'");
|
|
240
270
|
return AST.StreamStatement(call, body, kw.line, kw.column);
|
|
241
271
|
}
|
|
242
272
|
|
|
@@ -249,8 +279,8 @@ export class Parser {
|
|
|
249
279
|
const kw = this.advance(); // ON
|
|
250
280
|
const source = this.expect('IDENTIFIER', 'event source name (e.g. mqtt)');
|
|
251
281
|
const channel = this.parseExpression();
|
|
252
|
-
const body = this.parseBlock(['END']);
|
|
253
|
-
this.expect('END', "'end'");
|
|
282
|
+
const body = this.parseBlock(['END'], 'on');
|
|
283
|
+
this.expect('END', "'end' to close 'on'");
|
|
254
284
|
return AST.OnStatement(source.value, channel, body, kw.line, kw.column);
|
|
255
285
|
}
|
|
256
286
|
|
|
@@ -262,8 +292,8 @@ export class Parser {
|
|
|
262
292
|
parseEvery() {
|
|
263
293
|
const kw = this.advance(); // EVERY
|
|
264
294
|
const interval = this.parseExpression();
|
|
265
|
-
const body = this.parseBlock(['END']);
|
|
266
|
-
this.expect('END', "'end'");
|
|
295
|
+
const body = this.parseBlock(['END'], 'every');
|
|
296
|
+
this.expect('END', "'end' to close 'every'");
|
|
267
297
|
return AST.EveryStatement(interval, body, kw.line, kw.column);
|
|
268
298
|
}
|
|
269
299
|
|
|
@@ -271,7 +301,7 @@ export class Parser {
|
|
|
271
301
|
* Collect statements until one of `terminators` (or EOF) is next.
|
|
272
302
|
* Throws if EOF is reached before a terminator (e.g. a missing `end`).
|
|
273
303
|
*/
|
|
274
|
-
parseBlock(terminators) {
|
|
304
|
+
parseBlock(terminators, openedBy = null) {
|
|
275
305
|
const statements = [];
|
|
276
306
|
while (!this.check('EOF') && !terminators.includes(this.peek().type)) {
|
|
277
307
|
statements.push(this.parseStatement());
|
|
@@ -279,8 +309,9 @@ export class Parser {
|
|
|
279
309
|
if (this.check('EOF')) {
|
|
280
310
|
const tok = this.peek();
|
|
281
311
|
const expected = terminators.map((t) => `'${t.toLowerCase()}'`).join(' or ');
|
|
312
|
+
const hint = openedBy ? ` to close '${openedBy}'` : '';
|
|
282
313
|
throw new FutureError(
|
|
283
|
-
`Unexpected end of file
|
|
314
|
+
`Unexpected end of file — expected ${expected}${hint}`,
|
|
284
315
|
tok.line, tok.column, 'parse',
|
|
285
316
|
);
|
|
286
317
|
}
|