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.
@@ -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', embedModel: 'text-embedding-3-small' },
17
- ollama: { baseUrl: 'http://localhost:11434/v1', embedModel: 'nomic-embed-text' },
18
- openrouter: { baseUrl: 'https://openrouter.ai/api/v1', embedModel: null },
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', embedModel: null },
21
- groq: { baseUrl: 'https://api.groq.com/openai/v1', embedModel: null },
22
- together: { baseUrl: 'https://api.together.xyz/v1', embedModel: 'togethercomputer/m2-bert-80M-8k-retrieval' },
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 = config.baseUrl.replace(/\/$/, '');
31
- const apiKey = config.apiKey;
32
- const model = config.model ?? 'gpt-4o-mini';
33
- const embedModel = config.embedModel ?? null;
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({ model, messages, max_tokens: 1024 }),
51
+ body: JSON.stringify(body),
45
52
  });
46
- if (!res.ok) throw new Error(`[ai/${baseUrl}] HTTP ${res.status}: ${await res.text()}`);
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({ model, messages, max_tokens: 1024, stream: true }),
75
+ body: JSON.stringify(body),
60
76
  });
61
- if (!res.ok) throw new Error(`[ai/${baseUrl}] stream HTTP ${res.status}`);
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: `openai-compat(${baseUrl})`, ask, chat, stream, embed };
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.3.2';
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 [command, arg] = argv;
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 js = compileOrReport(source, file, {
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 (js === null) return 1;
231
-
232
- const outPath = join(outDir, `${basename(path, extname(path))}.js`);
233
- writeFileSync(outPath, js, 'utf8');
234
- console.log(`Compiled ${file} -> ${outPath}`);
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
- lines.push(this.genStatement(stmt, 0, /* topLevel= */ true));
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 jsPath = node.path.replace(/\.future$/, '.js');
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
- out += ` else {\n${this.genBody(node.alternate, depth + 1)}\n${pad}}`;
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
- // Compiles to: await __rt.<source>.subscribe(<channel>, async (message) => { ... })
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
- return `${pad}await __rt.${node.source}.subscribe(${chan}, async (message) => {\n${inner}\n${pad}});`;
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
- // Compiles to: await __rt.schedule.every(<interval>, async () => { ... })
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
- parseIf() {
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
- alternate = this.parseBlock(['END']);
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, expected ${expected}`,
314
+ `Unexpected end of file expected ${expected}${hint}`,
284
315
  tok.line, tok.column, 'parse',
285
316
  );
286
317
  }