future-lang 0.4.0 → 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.
@@ -14,7 +14,7 @@ agent use as
14
14
 
15
15
  Reserved namespaces (cannot be reassigned or used as function names):
16
16
  ```
17
- ai http mqtt tts rag vision home memory schedule system device math
17
+ ai http mqtt tts rag vision home memory schedule system device math assert
18
18
  ```
19
19
 
20
20
  ---
@@ -176,6 +176,10 @@ embed = ai.embed("text to embed")
176
176
  ai.configure("openai", "sk-...")
177
177
  ai.configure("ollama")
178
178
 
179
+ # With options: temperature and max_tokens
180
+ answer = ai.ask("Explain quantum physics", { temperature: 0.2 max_tokens: 200 })
181
+ reply = ai.chat(messages, { model: "gpt-4o" temperature: 0.7 })
182
+
179
183
  stream ai.ask("Tell me a story")
180
184
  print chunk
181
185
  end
@@ -224,7 +228,36 @@ end
224
228
  # schedule.once and schedule.cron also available
225
229
  ```
226
230
 
227
- ### `http`, `system`, `rag`, `vision`, `home`, `device`
231
+ ### `http`
232
+ ```
233
+ data = http.get("https://api.example.com/todos/1")
234
+ print data.title
235
+
236
+ res = http.post("https://api.example.com/items", { name: "Widget" price: 9.99 })
237
+ print res.id
238
+
239
+ # Global config (call once at the top of your program)
240
+ http.configure({ headers: { Authorization: "Bearer {token}" } timeout: 5000 })
241
+
242
+ # Errors have .status, .code, .url, .body properties
243
+ try
244
+ data = http.get("https://api.example.com/private")
245
+ catch err
246
+ print "Status: {err.status}"
247
+ print "Code: {err.code}"
248
+ end
249
+ ```
250
+
251
+ ### `assert` (use in *.test.future files)
252
+ ```
253
+ assert.ok(value)
254
+ assert.equal(actual, expected)
255
+ assert.notEqual(a, b)
256
+ assert.deepEqual(obj1, obj2)
257
+ assert.fail("custom message")
258
+ ```
259
+
260
+ ### `system`, `rag`, `vision`, `home`, `device`
228
261
  See [README.md](README.md) for full API tables.
229
262
 
230
263
  ### `math`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "future-lang",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Future — a small programming language that transpiles to JavaScript, with a capability runtime (HTTP/AI/MQTT/TTS).",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/ai.js CHANGED
@@ -34,31 +34,42 @@ export function configure(baseUrlOrProvider, apiKey, model) {
34
34
  });
35
35
  }
36
36
 
37
- /** Ask a single question. @returns {Promise<string>} */
38
- export async function ask(prompt) {
39
- return chat([{ role: 'user', content: String(prompt) }]);
37
+ /**
38
+ * Ask a single question.
39
+ * @param {string} prompt
40
+ * @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
41
+ * @returns {Promise<string>}
42
+ */
43
+ export async function ask(prompt, opts = {}) {
44
+ return chat([{ role: 'user', content: String(prompt) }], opts);
40
45
  }
41
46
 
42
- /** Multi-turn chat. messages = [{ role, content }, ...]. @returns {Promise<string>} */
43
- export async function chat(messages) {
47
+ /**
48
+ * Multi-turn chat.
49
+ * @param {Array<{role,content}>} messages
50
+ * @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
51
+ * @returns {Promise<string>}
52
+ */
53
+ export async function chat(messages, opts = {}) {
44
54
  const provider = resolveProvider();
45
55
  if (!provider) return offlineStub(messages);
46
- return provider.chat(messages);
56
+ return provider.chat(messages, opts);
47
57
  }
48
58
 
49
59
  /**
50
60
  * Stream a response chunk-by-chunk.
51
61
  * @param {string|Array} promptOrMessages
52
- * @param {(chunk: string) => void} onChunk Called with each text fragment.
62
+ * @param {(chunk: string) => void} onChunk
63
+ * @param {{ temperature?: number, max_tokens?: number, model?: string }} [opts]
53
64
  * @returns {Promise<void>}
54
65
  */
55
- export async function stream(promptOrMessages, onChunk) {
66
+ export async function stream(promptOrMessages, onChunk, opts = {}) {
56
67
  const messages = Array.isArray(promptOrMessages)
57
68
  ? promptOrMessages
58
69
  : [{ role: 'user', content: String(promptOrMessages) }];
59
70
  const provider = resolveProvider();
60
71
  if (!provider) { onChunk(offlineStub(messages)); return; }
61
- return provider.stream(messages, onChunk);
72
+ return provider.stream(messages, onChunk, opts);
62
73
  }
63
74
 
64
75
  /**
@@ -0,0 +1,27 @@
1
+ // runtime/assert.js — Test assertions for `future test`.
2
+ // Wraps node:assert/strict with Future-friendly error messages.
3
+
4
+ import nodeAssert from 'node:assert/strict';
5
+
6
+ function wrap(fn, name) {
7
+ return (...args) => {
8
+ try {
9
+ fn(...args);
10
+ } catch (err) {
11
+ // Re-throw with the assert namespace tag so the test runner can identify it.
12
+ const e = new Error(err.message);
13
+ e.name = 'AssertionError';
14
+ e.namespace = 'assert';
15
+ e.operator = err.operator ?? name;
16
+ e.actual = err.actual;
17
+ e.expected = err.expected;
18
+ throw e;
19
+ }
20
+ };
21
+ }
22
+
23
+ export const ok = wrap((val, msg) => nodeAssert.ok(val, msg), 'ok');
24
+ export const equal = wrap((a, b, msg) => nodeAssert.equal(a, b, msg), 'equal');
25
+ export const notEqual = wrap((a, b, msg) => nodeAssert.notEqual(a, b, msg), 'notEqual');
26
+ export const deepEqual = wrap((a, b, msg) => nodeAssert.deepEqual(a, b, msg), 'deepEqual');
27
+ export const fail = (msg = 'assertion failed') => { throw Object.assign(new Error(msg), { name: 'AssertionError', namespace: 'assert' }); };
package/runtime/http.js CHANGED
@@ -1,32 +1,78 @@
1
1
  // runtime/http.js — consume REST APIs.
2
2
  // Uses the global fetch (stable in Node 22). Returns parsed JSON or text.
3
3
 
4
+ export class HttpError extends Error {
5
+ constructor(status, statusText, url, body) {
6
+ super(`HTTP ${status} ${statusText} — ${url}`);
7
+ this.name = 'HttpError';
8
+ this.status = status;
9
+ this.statusText = statusText;
10
+ this.url = url;
11
+ this.body = body;
12
+ this.namespace = 'http';
13
+ this.code = `HTTP_${status}`;
14
+ }
15
+ }
16
+
17
+ // Global config state — mutated by configure().
18
+ let _config = {
19
+ headers: {},
20
+ timeout: 0,
21
+ };
22
+
23
+ /**
24
+ * Set global defaults for all HTTP requests.
25
+ * Useful for Authorization headers, base timeouts, etc.
26
+ * @param {{ headers?: Record<string,string>, timeout?: number }} opts
27
+ */
28
+ export function configure(opts = {}) {
29
+ if (opts.headers) _config.headers = { ..._config.headers, ...opts.headers };
30
+ if (opts.timeout != null) _config.timeout = opts.timeout;
31
+ }
32
+
4
33
  // Default headers — many public APIs (e.g. GitHub) reject requests without a
5
34
  // User-Agent. Callers can override any of these.
6
35
  const DEFAULT_HEADERS = {
7
- 'user-agent': 'future-lang/0.2 (+https://github.com/future-lang)',
36
+ 'user-agent': 'future-lang/0.4 (+https://github.com/humolot/future-lang)',
8
37
  accept: 'application/json, text/*;q=0.9, */*;q=0.8',
9
38
  };
10
39
 
11
- async function parse(res) {
40
+ async function parseBody(res) {
12
41
  const ct = res.headers.get('content-type') || '';
13
42
  return ct.includes('application/json') ? res.json() : res.text();
14
43
  }
15
44
 
45
+ function buildSignal() {
46
+ if (!_config.timeout) return undefined;
47
+ return AbortSignal.timeout(_config.timeout);
48
+ }
49
+
16
50
  /** GET a URL. @returns parsed JSON object/array, or text. */
17
51
  export async function get(url, headers = {}) {
18
- const res = await fetch(url, { headers: { ...DEFAULT_HEADERS, ...headers } });
19
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
20
- return parse(res);
52
+ const res = await fetch(url, {
53
+ headers: { ...DEFAULT_HEADERS, ..._config.headers, ...headers },
54
+ signal: buildSignal(),
55
+ });
56
+ if (!res.ok) {
57
+ let body;
58
+ try { body = await parseBody(res); } catch { body = null; }
59
+ throw new HttpError(res.status, res.statusText, url, body);
60
+ }
61
+ return parseBody(res);
21
62
  }
22
63
 
23
64
  /** POST a JSON body to a URL. @returns parsed JSON or text. */
24
65
  export async function post(url, body, headers = {}) {
25
66
  const res = await fetch(url, {
26
67
  method: 'POST',
27
- headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ...headers },
68
+ headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ..._config.headers, ...headers },
28
69
  body: typeof body === 'string' ? body : JSON.stringify(body),
70
+ signal: buildSignal(),
29
71
  });
30
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
31
- return parse(res);
72
+ if (!res.ok) {
73
+ let errBody;
74
+ try { errBody = await parseBody(res); } catch { errBody = null; }
75
+ throw new HttpError(res.status, res.statusText, url, errBody);
76
+ }
77
+ return parseBody(res);
32
78
  }
package/runtime/index.js CHANGED
@@ -16,15 +16,16 @@ import * as schedule from './schedule.js';
16
16
  import * as system from './system.js';
17
17
  import * as device from './device.js';
18
18
  import * as math from './math.js';
19
+ import * as assert from './assert.js';
19
20
  import readline from 'node:readline';
20
21
 
21
22
  // Canonical ordered list of capability module names.
22
23
  const MODULE_NAMES = [
23
24
  'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
24
- 'memory', 'schedule', 'system', 'device', 'math',
25
+ 'memory', 'schedule', 'system', 'device', 'math', 'assert',
25
26
  ];
26
27
 
27
- const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math };
28
+ const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math, assert };
28
29
 
29
30
  /**
30
31
  * When FUTURE_DEBUG=1, wrap every namespace method with timing/logging.
@@ -406,6 +407,14 @@ export const manifest = {
406
407
  pi: { description: 'The mathematical constant π', params: [], returns: 'number', async: false },
407
408
  e: { description: "Euler's number", params: [], returns: 'number', async: false },
408
409
  },
410
+
411
+ assert: {
412
+ ok: { description: 'Assert that value is truthy', params: [{ name: 'value', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
413
+ equal: { description: 'Assert that actual === expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
414
+ notEqual: { description: 'Assert that actual !== expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
415
+ deepEqual: { description: 'Assert deep structural equality', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
416
+ fail: { description: 'Unconditionally fail the test with a message', params: [{ name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
417
+ },
409
418
  };
410
419
 
411
420
  // --- Introspection API ---
@@ -425,7 +434,7 @@ runtime.listFunctions = (mod) => {
425
434
  * Suitable for AI agent discovery or documentation generation.
426
435
  */
427
436
  runtime.describe = () => ({
428
- version: '0.4.0',
437
+ version: '0.4.1',
429
438
  modules: [...MODULE_NAMES],
430
439
  manifest,
431
440
  });
@@ -6,13 +6,26 @@ import { parseSSE, keywordVector } from './util.js';
6
6
 
7
7
  const BASE = 'https://api.anthropic.com/v1';
8
8
 
9
+ export class AiError extends Error {
10
+ constructor(status, provider, body) {
11
+ const msg = body?.error?.message ?? body ?? `HTTP ${status}`;
12
+ super(`[ai:${provider}] ${msg}`);
13
+ this.name = 'AiError';
14
+ this.status = status;
15
+ this.code = `AI_HTTP_${status}`;
16
+ this.namespace = 'ai';
17
+ this.provider = provider;
18
+ this.body = body;
19
+ }
20
+ }
21
+
9
22
  /**
10
23
  * Create an Anthropic provider instance.
11
24
  * @param {{ apiKey: string, model?: string }} config
12
25
  */
13
26
  export function create(config) {
14
- const key = config.apiKey;
15
- const model = config.model ?? 'claude-sonnet-4-6';
27
+ const key = config.apiKey;
28
+ const defaultModel = config.model ?? 'claude-sonnet-4-6';
16
29
 
17
30
  const headers = {
18
31
  'content-type': 'application/json',
@@ -20,28 +33,43 @@ export function create(config) {
20
33
  'anthropic-version': '2023-06-01',
21
34
  };
22
35
 
23
- async function chat(messages) {
36
+ async function chat(messages, opts = {}) {
37
+ const model = opts.model ?? defaultModel;
38
+ const max_tokens = opts.max_tokens ?? 1024;
39
+ const body = { model, max_tokens, messages };
40
+ if (opts.temperature != null) body.temperature = opts.temperature;
41
+ if (opts.system) body.system = opts.system;
42
+
24
43
  const res = await fetch(`${BASE}/messages`, {
25
44
  method: 'POST',
26
45
  headers,
27
- body: JSON.stringify({ model, max_tokens: 1024, messages }),
46
+ body: JSON.stringify(body),
28
47
  });
29
- if (!res.ok) throw new Error(`[anthropic] HTTP ${res.status}: ${await res.text()}`);
48
+ if (!res.ok) {
49
+ let errBody;
50
+ try { errBody = await res.json(); } catch { errBody = await res.text(); }
51
+ throw new AiError(res.status, 'anthropic', errBody);
52
+ }
30
53
  const data = await res.json();
31
54
  return (data.content ?? []).map((b) => b.text ?? '').join('').trim();
32
55
  }
33
56
 
34
- async function ask(prompt) {
35
- return chat([{ role: 'user', content: String(prompt) }]);
57
+ async function ask(prompt, opts = {}) {
58
+ return chat([{ role: 'user', content: String(prompt) }], opts);
36
59
  }
37
60
 
38
- async function stream(messages, onChunk) {
61
+ async function stream(messages, onChunk, opts = {}) {
62
+ const model = opts.model ?? defaultModel;
63
+ const max_tokens = opts.max_tokens ?? 1024;
64
+ const body = { model, max_tokens, messages, stream: true };
65
+ if (opts.temperature != null) body.temperature = opts.temperature;
66
+
39
67
  const res = await fetch(`${BASE}/messages`, {
40
68
  method: 'POST',
41
69
  headers,
42
- body: JSON.stringify({ model, max_tokens: 1024, messages, stream: true }),
70
+ body: JSON.stringify(body),
43
71
  });
44
- if (!res.ok) throw new Error(`[anthropic] stream HTTP ${res.status}`);
72
+ if (!res.ok) throw new AiError(res.status, 'anthropic', `stream HTTP ${res.status}`);
45
73
  for await (const { event, data } of parseSSE(res.body)) {
46
74
  if (event === 'content_block_delta' || data?.type === 'content_block_delta') {
47
75
  onChunk(data.delta?.text ?? '');
@@ -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.4.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
@@ -45,16 +47,19 @@ Import system:
45
47
 
46
48
  Flags:
47
49
  future run --debug <file> Show timing for every namespace call
50
+ future compile --sourcemap <file> Also emit a .js.map source map
48
51
  `;
49
52
 
50
53
  async function main(argv) {
51
- const debug = argv.includes('--debug');
54
+ const debug = argv.includes('--debug');
55
+ const sourcemap = argv.includes('--sourcemap');
52
56
  if (debug) process.env.FUTURE_DEBUG = '1';
53
- const rest = argv.filter((a) => a !== '--debug');
57
+ const rest = argv.filter((a) => a !== '--debug' && a !== '--sourcemap');
54
58
  const [command, arg] = rest;
55
59
  switch (command) {
56
60
  case 'run': return cmdRun(arg);
57
- case 'compile': return cmdCompile(arg);
61
+ case 'compile': return cmdCompile(arg, { sourcemap });
62
+ case 'test': return cmdTest(arg);
58
63
  case 'new': return cmdNew(arg);
59
64
  case 'check': return cmdCheck(arg);
60
65
  case 'fmt': return cmdFmt(arg);
@@ -198,7 +203,7 @@ function compileDepModule(source, sourcePath, tempDir, pathMap) {
198
203
  // Commands
199
204
  // ---------------------------------------------------------------------------
200
205
 
201
- function cmdCompile(file) {
206
+ function cmdCompile(file, { sourcemap = false } = {}) {
202
207
  let path, source;
203
208
  try { ({ path, source } = readSource(file)); }
204
209
  catch (err) { return fail(err, file); }
@@ -230,18 +235,30 @@ function cmdCompile(file) {
230
235
  console.log(`Compiled ${relPath} -> ${depOut}`);
231
236
  }
232
237
 
233
- const js = compileOrReport(source, file, {
238
+ const rawJs = compileOrReport(source, file, {
234
239
  runtimeSpecifier: relativeRuntimeSpecifier(outDir),
240
+ sourceMaps: sourcemap,
235
241
  resolveSource: (relPath) => {
236
242
  const abs = resolve(outDir, relPath);
237
243
  return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
238
244
  },
239
245
  });
240
- if (js === null) return 1;
241
-
242
- const outPath = join(outDir, `${basename(path, extname(path))}.js`);
243
- writeFileSync(outPath, js, 'utf8');
244
- 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
+ }
245
262
  return 0;
246
263
  }
247
264
 
@@ -328,6 +345,88 @@ function cmdFmt(file) {
328
345
  return 0;
329
346
  }
330
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
+
331
430
  /** Create a new project scaffold. */
332
431
  function cmdNew(name) {
333
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.
@@ -79,7 +81,12 @@ export class Generator {
79
81
  }
80
82
  for (const stmt of program.body) {
81
83
  if (stmt.type === NodeType.UseStatement) continue; // already emitted above
82
- 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
+ }
83
90
  }
84
91
  return lines.join('\n') + '\n';
85
92
  }
package/src/parser.js CHANGED
@@ -19,7 +19,7 @@ const EXPR_TERMINATORS = new Set(['END', 'ELSE', 'CATCH', 'EOF']);
19
19
  /** Built-in namespace names that cannot be redefined by user code. */
20
20
  const RESERVED_NAMESPACES = new Set([
21
21
  'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
22
- 'memory', 'schedule', 'system', 'device', 'math',
22
+ 'memory', 'schedule', 'system', 'device', 'math', 'assert',
23
23
  ]);
24
24
 
25
25
  export class Parser {
@@ -0,0 +1,69 @@
1
+ // src/sourcemap.js — Source map generation (Source Map v3).
2
+ // The generator embeds @FL:N markers (inside block comments) at statement lines.
3
+ // This module strips them and produces a v3 source map + clean JS.
4
+
5
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
6
+
7
+ function encodeVlq(value) {
8
+ let vlq = value < 0 ? ((-value) << 1) | 1 : (value << 1);
9
+ let out = '';
10
+ do {
11
+ let digit = vlq & 0x1F;
12
+ vlq >>>= 5;
13
+ if (vlq > 0) digit |= 0x20;
14
+ out += B64[digit];
15
+ } while (vlq > 0);
16
+ return out;
17
+ }
18
+
19
+ const MARKER_RE = /^\/\*@FL:(\d+)\*\//;
20
+
21
+ /**
22
+ * Strip @FL:N markers from generated JS and build a v3 source map.
23
+ *
24
+ * @param {string} js Generated JS (possibly with @FL markers)
25
+ * @param {string} sourceFile Original .future filename (for `sources` field)
26
+ * @param {string} futureSource Original .future source text (for `sourcesContent`)
27
+ * @returns {{ code: string, map: object }}
28
+ */
29
+ export function buildSourceMap(js, sourceFile, futureSource) {
30
+ const jsLines = js.split('\n');
31
+ const cleanLines = [];
32
+ const mappings = [];
33
+
34
+ // Delta state for VLQ.
35
+ let prevSrcLine = 0;
36
+ let prevSrcCol = 0;
37
+
38
+ for (const line of jsLines) {
39
+ const m = MARKER_RE.exec(line);
40
+ if (m) {
41
+ const srcLine = parseInt(m[1], 10) - 1; // 0-indexed
42
+ const srcCol = 0;
43
+ // Segment: [genCol=0, sourceIdx=0, srcLine delta, srcCol delta]
44
+ const seg = encodeVlq(0)
45
+ + encodeVlq(0)
46
+ + encodeVlq(srcLine - prevSrcLine)
47
+ + encodeVlq(srcCol - prevSrcCol);
48
+ mappings.push(seg);
49
+ prevSrcLine = srcLine;
50
+ prevSrcCol = srcCol;
51
+ cleanLines.push(line.slice(m[0].length));
52
+ } else {
53
+ // No marker — emit an empty mapping for this line.
54
+ mappings.push('');
55
+ cleanLines.push(line);
56
+ }
57
+ }
58
+
59
+ const map = {
60
+ version: 3,
61
+ file: sourceFile.replace(/\.future$/, '.js'),
62
+ sources: [sourceFile],
63
+ sourcesContent: [futureSource],
64
+ names: [],
65
+ mappings: mappings.join(';'),
66
+ };
67
+
68
+ return { code: cleanLines.join('\n'), map };
69
+ }