noahlang 0.0.0-stage → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +383 -2
- package/lib/Forge.noah +197 -0
- package/lib/style.css +41 -0
- package/noah.js +1319 -0
- package/package.json +42 -5
- package/scripts/postinstall.js +67 -0
package/noah.js
ADDED
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NoahLang Interpreter v2.0.1
|
|
3
|
+
// Creator: NoahKahenge
|
|
4
|
+
//
|
|
5
|
+
// v2.0.1 — list literals.
|
|
6
|
+
//
|
|
7
|
+
// Fixed:
|
|
8
|
+
// * parsePrimary now handles `[...]` as a list literal. This was
|
|
9
|
+
// missing in v2.0.0, so `for x in ["a", "b"]` failed at the `[`.
|
|
10
|
+
// Now `[...]` works anywhere an expression is valid: in `for`,
|
|
11
|
+
// `let`, function arguments, record fields, nested inside other
|
|
12
|
+
// lists.
|
|
13
|
+
//
|
|
14
|
+
// Everything from v2.0.0 works identically.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const { exec } = require('child_process');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------
|
|
24
|
+
// ERRORS
|
|
25
|
+
// ---------------------------------------------
|
|
26
|
+
class NoahLangError extends Error {
|
|
27
|
+
constructor(message, line, column = null, sourceLine = null) {
|
|
28
|
+
const where = column != null ? `Line ${line}, column ${column}` : `Line ${line}`;
|
|
29
|
+
super(`${where}: ${message}`);
|
|
30
|
+
this.name = 'NoahLangError';
|
|
31
|
+
this.line = line;
|
|
32
|
+
this.column = column;
|
|
33
|
+
this.sourceLine = sourceLine;
|
|
34
|
+
this.rawMessage = message;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------
|
|
39
|
+
// COMMENT STRIPPING
|
|
40
|
+
// ---------------------------------------------
|
|
41
|
+
function removeComment(text) {
|
|
42
|
+
let quote = null;
|
|
43
|
+
let escaped = false;
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const ch = text[i];
|
|
46
|
+
if (escaped) { escaped = false; continue; }
|
|
47
|
+
if (ch === '\\' && quote) { escaped = true; continue; }
|
|
48
|
+
if ((ch === '"' || ch === "'") && (!quote || quote === ch)) { quote = quote ? null : ch; continue; }
|
|
49
|
+
if (!quote && ch === '/' && text[i + 1] === '/') return text.slice(0, i).trimEnd();
|
|
50
|
+
}
|
|
51
|
+
return text.trimEnd();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------
|
|
55
|
+
// TOKENIZER
|
|
56
|
+
// ---------------------------------------------
|
|
57
|
+
const STRING_ESCAPES = Object.freeze({
|
|
58
|
+
n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0',
|
|
59
|
+
'{': '{', '}': '}', '\\': '\\', '"': '"', "'": "'"
|
|
60
|
+
});
|
|
61
|
+
const WORD_OPERATORS = new Set(['and', 'or', 'not']);
|
|
62
|
+
|
|
63
|
+
function tokenizeExpression(source, line) {
|
|
64
|
+
const tokens = [];
|
|
65
|
+
let i = 0;
|
|
66
|
+
while (i < source.length) {
|
|
67
|
+
const ch = source[i];
|
|
68
|
+
if (/\s/.test(ch)) { i++; continue; }
|
|
69
|
+
|
|
70
|
+
if (ch === '"' || ch === "'") {
|
|
71
|
+
const quote = ch;
|
|
72
|
+
const startCol = i + 1;
|
|
73
|
+
i++;
|
|
74
|
+
const parts = [];
|
|
75
|
+
let buffer = '';
|
|
76
|
+
let closed = false;
|
|
77
|
+
while (i < source.length) {
|
|
78
|
+
const c = source[i++];
|
|
79
|
+
if (c === '\\' && i < source.length) {
|
|
80
|
+
const next = source[i++];
|
|
81
|
+
if (next === 'u' && i + 4 <= source.length) {
|
|
82
|
+
const hex = source.slice(i, i + 4);
|
|
83
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
84
|
+
buffer += String.fromCharCode(parseInt(hex, 16));
|
|
85
|
+
i += 4;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
buffer += Object.prototype.hasOwnProperty.call(STRING_ESCAPES, next) ? STRING_ESCAPES[next] : next;
|
|
90
|
+
} else if (c === quote) { closed = true; break; }
|
|
91
|
+
else if (c === '{') {
|
|
92
|
+
if (buffer.length) { parts.push({ type: 'text', value: buffer }); buffer = ''; }
|
|
93
|
+
let depth = 1;
|
|
94
|
+
let expr = '';
|
|
95
|
+
while (i < source.length) {
|
|
96
|
+
const d = source[i++];
|
|
97
|
+
if (d === '{') depth++;
|
|
98
|
+
else if (d === '}') { depth--; if (depth === 0) break; }
|
|
99
|
+
if (depth > 0) expr += d;
|
|
100
|
+
}
|
|
101
|
+
if (depth !== 0) throw new NoahLangError('unterminated interpolation in string', line, startCol);
|
|
102
|
+
parts.push({ type: 'expr', value: expr });
|
|
103
|
+
} else { buffer += c; }
|
|
104
|
+
}
|
|
105
|
+
if (!closed) throw new NoahLangError('unterminated string literal', line, startCol);
|
|
106
|
+
if (buffer.length || parts.length === 0) parts.push({ type: 'text', value: buffer });
|
|
107
|
+
tokens.push({ type: 'string', parts, column: startCol });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (ch === '.' && source[i + 1] === '.') {
|
|
112
|
+
tokens.push({ type: 'range', value: '..', column: i + 1 });
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '.') {
|
|
117
|
+
tokens.push({ type: 'dot', value: '.', column: i + 1 });
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const number = source.slice(i).match(/^\d+(?:\.\d+)?/);
|
|
123
|
+
if (number) {
|
|
124
|
+
tokens.push({ type: 'literal', value: Number(number[0]), column: i + 1 });
|
|
125
|
+
i += number[0].length;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const identifier = source.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
130
|
+
if (identifier) {
|
|
131
|
+
const word = identifier[0];
|
|
132
|
+
const column = i + 1;
|
|
133
|
+
if (word === 'true' || word === 'false') tokens.push({ type: 'literal', value: word === 'true', column });
|
|
134
|
+
else if (word === 'null') tokens.push({ type: 'literal', value: null, column });
|
|
135
|
+
else if (word === 'then') tokens.push({ type: 'then', value: 'then', column });
|
|
136
|
+
else if (WORD_OPERATORS.has(word)) tokens.push({ type: 'operator', value: word, column });
|
|
137
|
+
else tokens.push({ type: 'identifier', value: word, column });
|
|
138
|
+
i += word.length;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const two = source.slice(i, i + 2);
|
|
143
|
+
if (['==', '!=', '<=', '>='].includes(two)) {
|
|
144
|
+
tokens.push({ type: 'operator', value: two, column: i + 1 });
|
|
145
|
+
i += 2;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if ('+-*/%(),<>[]'.includes(ch)) {
|
|
150
|
+
const punct = '()[],'.includes(ch);
|
|
151
|
+
tokens.push({ type: punct ? ch : 'operator', value: ch, column: i + 1 });
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new NoahLangError(`unsupported character '${ch}' in expression`, line, i + 1);
|
|
157
|
+
}
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------
|
|
162
|
+
// RUNTIME VALUES
|
|
163
|
+
// ---------------------------------------------
|
|
164
|
+
class NoahRange {
|
|
165
|
+
constructor(start, end) { this.start = start; this.end = end; }
|
|
166
|
+
}
|
|
167
|
+
function describeType(value) {
|
|
168
|
+
if (value === null) return 'null';
|
|
169
|
+
if (Array.isArray(value)) return 'array';
|
|
170
|
+
if (value instanceof NoahRange) return 'range';
|
|
171
|
+
if (typeof value === 'object') return 'record';
|
|
172
|
+
return typeof value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------
|
|
176
|
+
// EXPRESSION PARSER
|
|
177
|
+
// ---------------------------------------------
|
|
178
|
+
class ExpressionParser {
|
|
179
|
+
constructor(tokens, interpreter, env, line) {
|
|
180
|
+
this.tokens = tokens; this.interpreter = interpreter;
|
|
181
|
+
this.env = env; this.line = line; this.position = 0;
|
|
182
|
+
}
|
|
183
|
+
peek() { return this.tokens[this.position]; }
|
|
184
|
+
take() { return this.tokens[this.position++]; }
|
|
185
|
+
expect(type, value) {
|
|
186
|
+
const token = this.take();
|
|
187
|
+
if (!token || token.type !== type || (value !== undefined && token.value !== value)) {
|
|
188
|
+
throw new NoahLangError(`expected '${value ?? type}' in expression`, this.line, token ? token.column : null);
|
|
189
|
+
}
|
|
190
|
+
return token;
|
|
191
|
+
}
|
|
192
|
+
parse() {
|
|
193
|
+
const result = this.parsePipeline();
|
|
194
|
+
if (this.peek()) throw new NoahLangError(`unexpected '${this.peek().value}' in expression`, this.line, this.peek().column);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
parsePipeline() {
|
|
198
|
+
let left = this.parseOr();
|
|
199
|
+
while (this.peek() && this.peek().type === 'then') {
|
|
200
|
+
this.take();
|
|
201
|
+
const stage = this.take();
|
|
202
|
+
if (!stage) throw new NoahLangError('incomplete pipeline after `then`', this.line);
|
|
203
|
+
if (stage.type !== 'identifier') throw new NoahLangError("`then` expects a function name", this.line, stage.column);
|
|
204
|
+
if (this.peek() && this.peek().type === '(') {
|
|
205
|
+
this.take();
|
|
206
|
+
const extraArgs = [];
|
|
207
|
+
if (!this.peek() || this.peek().type !== ')') {
|
|
208
|
+
while (true) {
|
|
209
|
+
extraArgs.push(this.parseOr());
|
|
210
|
+
if (!this.peek() || this.peek().type !== ',') break;
|
|
211
|
+
this.take();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.expect(')', ')');
|
|
215
|
+
left = this.interpreter.callFunction(stage.value, [left, ...extraArgs], this.line, this.env);
|
|
216
|
+
} else {
|
|
217
|
+
left = this.interpreter.callFunction(stage.value, [left], this.line, this.env);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return left;
|
|
221
|
+
}
|
|
222
|
+
parseOr() {
|
|
223
|
+
let left = this.parseAnd();
|
|
224
|
+
while (this.peek() && this.peek().type === 'operator' && this.peek().value === 'or') {
|
|
225
|
+
this.take();
|
|
226
|
+
if (left) this._skipOperand(); else left = this.parseAnd();
|
|
227
|
+
}
|
|
228
|
+
return left;
|
|
229
|
+
}
|
|
230
|
+
parseAnd() {
|
|
231
|
+
let left = this.parseNot();
|
|
232
|
+
while (this.peek() && this.peek().type === 'operator' && this.peek().value === 'and') {
|
|
233
|
+
this.take();
|
|
234
|
+
if (!left) this._skipOperand(); else left = this.parseNot();
|
|
235
|
+
}
|
|
236
|
+
return left;
|
|
237
|
+
}
|
|
238
|
+
parseNot() {
|
|
239
|
+
if (this.peek() && this.peek().type === 'operator' && this.peek().value === 'not') {
|
|
240
|
+
this.take(); return !this.parseNot();
|
|
241
|
+
}
|
|
242
|
+
return this.parseComparison();
|
|
243
|
+
}
|
|
244
|
+
_skipOperand() {
|
|
245
|
+
const saved = this.interpreter.skipEvaluation;
|
|
246
|
+
this.interpreter.skipEvaluation = true;
|
|
247
|
+
try { this.parseNot(); } finally { this.interpreter.skipEvaluation = saved; }
|
|
248
|
+
}
|
|
249
|
+
parseComparison() {
|
|
250
|
+
let left = this.parseAdditive();
|
|
251
|
+
while (this.peek() && this.peek().type === 'operator' && ['==', '!=', '<', '>', '<=', '>='].includes(this.peek().value)) {
|
|
252
|
+
const op = this.take().value;
|
|
253
|
+
const right = this.parseAdditive();
|
|
254
|
+
switch (op) {
|
|
255
|
+
case '==': left = left === right; break;
|
|
256
|
+
case '!=': left = left !== right; break;
|
|
257
|
+
case '<': left = left < right; break;
|
|
258
|
+
case '>': left = left > right; break;
|
|
259
|
+
case '<=': left = left <= right; break;
|
|
260
|
+
case '>=': left = left >= right; break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return left;
|
|
264
|
+
}
|
|
265
|
+
parseAdditive() {
|
|
266
|
+
let left = this.parseMultiplicative();
|
|
267
|
+
while (this.peek() && this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) {
|
|
268
|
+
const op = this.take().value;
|
|
269
|
+
const right = this.parseMultiplicative();
|
|
270
|
+
if (op === '+') {
|
|
271
|
+
left = (typeof left === 'string' || typeof right === 'string')
|
|
272
|
+
? String(left ?? '') + String(right ?? '')
|
|
273
|
+
: Number(left) + Number(right);
|
|
274
|
+
} else {
|
|
275
|
+
this.requireNumber(left, '-'); this.requireNumber(right, '-');
|
|
276
|
+
left = Number(left) - Number(right);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return left;
|
|
280
|
+
}
|
|
281
|
+
parseMultiplicative() {
|
|
282
|
+
let left = this.parseUnary();
|
|
283
|
+
while (this.peek() && this.peek().type === 'operator' && ['*', '/', '%'].includes(this.peek().value)) {
|
|
284
|
+
const op = this.take().value;
|
|
285
|
+
const right = this.parseUnary();
|
|
286
|
+
this.requireNumber(left, op); this.requireNumber(right, op);
|
|
287
|
+
if (op === '*') left = Number(left) * Number(right);
|
|
288
|
+
else if (op === '/') {
|
|
289
|
+
if (Number(right) === 0) throw new NoahLangError('division by zero', this.line);
|
|
290
|
+
left = Number(left) / Number(right);
|
|
291
|
+
} else {
|
|
292
|
+
if (Number(right) === 0) throw new NoahLangError('modulo by zero', this.line);
|
|
293
|
+
left = Number(left) % Number(right);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return left;
|
|
297
|
+
}
|
|
298
|
+
parseUnary() {
|
|
299
|
+
if (this.peek() && this.peek().type === 'operator' && ['-', '+'].includes(this.peek().value)) {
|
|
300
|
+
const op = this.take().value;
|
|
301
|
+
const value = Number(this.parseUnary());
|
|
302
|
+
return op === '-' ? -value : value;
|
|
303
|
+
}
|
|
304
|
+
return this.parsePostfix();
|
|
305
|
+
}
|
|
306
|
+
parsePostfix() {
|
|
307
|
+
let value = this.parsePrimary();
|
|
308
|
+
while (this.peek() && (this.peek().type === '[' || this.peek().type === 'dot')) {
|
|
309
|
+
if (this.peek().type === 'dot') {
|
|
310
|
+
this.take();
|
|
311
|
+
const field = this.take();
|
|
312
|
+
if (!field || field.type !== 'identifier') throw new NoahLangError('expected field name after .', this.line, field ? field.column : null);
|
|
313
|
+
if (typeof value !== 'object' || value === null) throw new NoahLangError(`cannot read field '${field.value}' on ${describeType(value)}`, this.line);
|
|
314
|
+
value = value[field.value];
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
this.take();
|
|
318
|
+
const idx = this.parseOr();
|
|
319
|
+
this.expect(']', ']');
|
|
320
|
+
if (Array.isArray(value)) {
|
|
321
|
+
const n = Number(idx);
|
|
322
|
+
if (!Number.isInteger(n) || n < 0 || n >= value.length) throw new NoahLangError(`index ${stringify(idx)} out of range for array of length ${value.length}`, this.line);
|
|
323
|
+
value = value[n];
|
|
324
|
+
} else if (typeof value === 'string') {
|
|
325
|
+
const n = Number(idx);
|
|
326
|
+
if (!Number.isInteger(n) || n < 0 || n >= value.length) throw new NoahLangError(`index ${stringify(idx)} out of range for string of length ${value.length}`, this.line);
|
|
327
|
+
value = value[n];
|
|
328
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
329
|
+
const key = String(idx);
|
|
330
|
+
if (!(key in value)) throw new NoahLangError(`record has no field '${key}'`, this.line);
|
|
331
|
+
value = value[key];
|
|
332
|
+
} else {
|
|
333
|
+
throw new NoahLangError(`cannot index ${describeType(value)}`, this.line);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
parsePrimary() {
|
|
339
|
+
const token = this.take();
|
|
340
|
+
if (!token) throw new NoahLangError('incomplete expression', this.line);
|
|
341
|
+
|
|
342
|
+
// NEW: list literal
|
|
343
|
+
if (token.type === '[') {
|
|
344
|
+
const items = [];
|
|
345
|
+
if (!this.peek() || this.peek().type !== ']') {
|
|
346
|
+
while (true) {
|
|
347
|
+
items.push(this.parsePipeline());
|
|
348
|
+
if (!this.peek() || this.peek().type !== ',') break;
|
|
349
|
+
this.take();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.expect(']', ']');
|
|
353
|
+
return items;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (token.type === 'literal' && this.peek() && this.peek().type === 'range') {
|
|
357
|
+
this.take();
|
|
358
|
+
const end = this.parseOr();
|
|
359
|
+
return new NoahRange(Number(token.value), Number(end));
|
|
360
|
+
}
|
|
361
|
+
if (token.type === 'literal') return token.value;
|
|
362
|
+
if (token.type === 'string') {
|
|
363
|
+
let out = '';
|
|
364
|
+
for (const part of token.parts) {
|
|
365
|
+
if (part.type === 'text') out += part.value;
|
|
366
|
+
else { const value = this.interpreter.evaluateExpression(part.value, this.env, this.line); out += stringify(value); }
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
if (token.type === '(') { const value = this.parsePipeline(); this.expect(')', ')'); return value; }
|
|
371
|
+
if (token.type === 'identifier') {
|
|
372
|
+
const isCall = this.peek() && this.peek().type === '(';
|
|
373
|
+
if (isCall) {
|
|
374
|
+
this.take();
|
|
375
|
+
const args = [];
|
|
376
|
+
if (!this.peek() || this.peek().type !== ')') {
|
|
377
|
+
while (true) {
|
|
378
|
+
args.push(this.parsePipeline());
|
|
379
|
+
if (!this.peek() || this.peek().type !== ',') break;
|
|
380
|
+
this.take();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
this.expect(')', ')');
|
|
384
|
+
const found = this.env.lookup(token.value);
|
|
385
|
+
if (found && found.value && found.value.__noahFunction) {
|
|
386
|
+
return this.interpreter.callFunction(found.value.__noahFunction, args, this.line, this.env);
|
|
387
|
+
}
|
|
388
|
+
return this.interpreter.callFunction(token.value, args, this.line, this.env);
|
|
389
|
+
}
|
|
390
|
+
const found = this.env.lookup(token.value);
|
|
391
|
+
if (!found) throw new NoahLangError(`undefined variable '${token.value}'`, this.line, token.column);
|
|
392
|
+
if (this.peek() && this.peek().type === 'range') {
|
|
393
|
+
this.take();
|
|
394
|
+
const end = this.parseOr();
|
|
395
|
+
return new NoahRange(Number(found.value), Number(end));
|
|
396
|
+
}
|
|
397
|
+
return found.value;
|
|
398
|
+
}
|
|
399
|
+
throw new NoahLangError(`unexpected '${token.value}' in expression`, this.line, token.column);
|
|
400
|
+
}
|
|
401
|
+
requireNumber(value, op) {
|
|
402
|
+
if (typeof value === 'number') return;
|
|
403
|
+
if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) return;
|
|
404
|
+
throw new NoahLangError(`operator '${op}' requires numbers, got ${describeType(value)}`, this.line);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------
|
|
409
|
+
// ENVIRONMENT
|
|
410
|
+
// ---------------------------------------------
|
|
411
|
+
class Environment {
|
|
412
|
+
constructor(parent = null) { this.vars = new Map(); this.parent = parent; }
|
|
413
|
+
hasOwn(name) { return this.vars.has(name); }
|
|
414
|
+
has(name) { let s = this; while (s) { if (s.vars.has(name)) return true; s = s.parent; } return false; }
|
|
415
|
+
lookup(name) {
|
|
416
|
+
let s = this;
|
|
417
|
+
while (s) { if (s.vars.has(name)) return { value: s.vars.get(name), scope: s }; s = s.parent; }
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
get(name) { const f = this.lookup(name); return f ? f.value : undefined; }
|
|
421
|
+
define(name, value) { this.vars.set(name, value); }
|
|
422
|
+
assign(name, value) {
|
|
423
|
+
const f = this.lookup(name);
|
|
424
|
+
if (!f) return false;
|
|
425
|
+
f.scope.vars.set(name, value);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const RETURN_SIGNAL = Symbol('return');
|
|
431
|
+
const BREAK_SIGNAL = Symbol('break');
|
|
432
|
+
const CONTINUE_SIGNAL = Symbol('continue');
|
|
433
|
+
const RELOAD_SCRIPT = `<script>(function(){try{var s=new EventSource('/__reload');s.onmessage=function(e){if(e.data==='reload'){location.reload();}};}catch(e){}})();</script>`;
|
|
434
|
+
let browserOpenedGlobally = false;
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------
|
|
437
|
+
// INTERPRETER
|
|
438
|
+
// ---------------------------------------------
|
|
439
|
+
class Interpreter {
|
|
440
|
+
constructor(options = {}) {
|
|
441
|
+
this.globalEnv = new Environment(null);
|
|
442
|
+
this.functions = new Map();
|
|
443
|
+
this.inputQueue = Array.isArray(options.input) ? [...options.input] : [];
|
|
444
|
+
this.stdin = typeof options.stdin === 'string' ? options.stdin : null;
|
|
445
|
+
this.output = typeof options.output === 'function' ? options.output : (line) => console.log(line);
|
|
446
|
+
this.maxDepth = Number.isInteger(options.maxDepth) && options.maxDepth > 0 ? options.maxDepth : 1000;
|
|
447
|
+
this.callDepth = 0;
|
|
448
|
+
this.baseDir = options.baseDir || process.cwd();
|
|
449
|
+
this.currentDir = this.baseDir;
|
|
450
|
+
this.pulledFiles = new Set();
|
|
451
|
+
this.pullStack = [];
|
|
452
|
+
this.skipEvaluation = false;
|
|
453
|
+
this.eventHandlers = new Map();
|
|
454
|
+
this.httpServer = null;
|
|
455
|
+
this.reloadClients = new Set();
|
|
456
|
+
this.onFileRead = typeof options.onFileRead === 'function' ? options.onFileRead : null;
|
|
457
|
+
this.installBuiltins();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
runEventHandler(id) {
|
|
461
|
+
const registered = [...this.eventHandlers.keys()];
|
|
462
|
+
this.output(`[dispatch] id=${JSON.stringify(id)} registered=[${registered.join(', ')}]`);
|
|
463
|
+
if (typeof id !== 'string') throw new NoahLangError('dispatch: id must be a string', 0);
|
|
464
|
+
const name = this.eventHandlers.get(id);
|
|
465
|
+
if (!name) {
|
|
466
|
+
this.output(`[dispatch] NO HANDLER for id '${id}'`);
|
|
467
|
+
throw new NoahLangError(`dispatch: no handler for id '${id}'`, 0);
|
|
468
|
+
}
|
|
469
|
+
this.output(`[dispatch] calling '${name}'`);
|
|
470
|
+
try {
|
|
471
|
+
const result = this.callFunction(name, [], 0, this.globalEnv);
|
|
472
|
+
this.output(`[dispatch] '${name}' returned ${stringify(result)}`);
|
|
473
|
+
return result;
|
|
474
|
+
} catch (err) {
|
|
475
|
+
this.output(`[dispatch] '${name}' threw: ${err.message}`);
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
installBuiltins() {
|
|
481
|
+
const def = (name, impl) => this.globalEnv.define(name, impl);
|
|
482
|
+
def('write', (...args) => { if (!this.skipEvaluation) this.output(args.map(stringify).join(' ')); return null; });
|
|
483
|
+
def('print', (...args) => { if (!this.skipEvaluation) this.output(args.map(stringify).join(' ')); return null; });
|
|
484
|
+
def('len', (value) => {
|
|
485
|
+
if (typeof value === 'string' || Array.isArray(value)) return value.length;
|
|
486
|
+
if (value == null) return 0;
|
|
487
|
+
if (typeof value === 'object') return Object.keys(value).length;
|
|
488
|
+
throw new NoahLangError('len() expects a string, array, or record', 0);
|
|
489
|
+
});
|
|
490
|
+
def('str', (value) => stringify(value));
|
|
491
|
+
def('num', (value) => {
|
|
492
|
+
const n = Number(value);
|
|
493
|
+
if (Number.isNaN(n)) throw new NoahLangError(`num() cannot convert '${stringify(value)}'`, 0);
|
|
494
|
+
return n;
|
|
495
|
+
});
|
|
496
|
+
def('bool', (value) => Boolean(value));
|
|
497
|
+
def('append', (arr, value) => {
|
|
498
|
+
if (!Array.isArray(arr)) throw new NoahLangError('append() expects an array', 0);
|
|
499
|
+
return arr.concat([value]);
|
|
500
|
+
});
|
|
501
|
+
def('remove', (arr, index) => {
|
|
502
|
+
if (!Array.isArray(arr)) throw new NoahLangError('remove() expects an array', 0);
|
|
503
|
+
const n = Number(index);
|
|
504
|
+
if (!Number.isInteger(n) || n < 0 || n >= arr.length) throw new NoahLangError(`remove(): index ${stringify(index)} out of range`, 0);
|
|
505
|
+
return arr.slice(0, n).concat(arr.slice(n + 1));
|
|
506
|
+
});
|
|
507
|
+
def('keys', (record) => {
|
|
508
|
+
if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new NoahLangError('keys() expects a record', 0);
|
|
509
|
+
return Object.keys(record);
|
|
510
|
+
});
|
|
511
|
+
def('has', (record, key) => {
|
|
512
|
+
if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new NoahLangError('has() expects a record', 0);
|
|
513
|
+
return String(key) in record;
|
|
514
|
+
});
|
|
515
|
+
def('writeToFile', (filePath, content) => {
|
|
516
|
+
if (typeof filePath !== 'string') throw new NoahLangError('writeToFile: path must be a string', 0);
|
|
517
|
+
fs.writeFileSync(path.resolve(this.currentDir, filePath), String(content ?? ''), 'utf8');
|
|
518
|
+
return null;
|
|
519
|
+
});
|
|
520
|
+
def('readFromFile', (filePath) => {
|
|
521
|
+
if (typeof filePath !== 'string') throw new NoahLangError('readFromFile: path must be a string', 0);
|
|
522
|
+
return fs.readFileSync(path.resolve(this.currentDir, filePath), 'utf8');
|
|
523
|
+
});
|
|
524
|
+
def('openInBrowser', (target) => {
|
|
525
|
+
if (typeof target !== 'string') throw new NoahLangError('openInBrowser: needs a file path or URL', 0);
|
|
526
|
+
const url = /^https?:\/\//.test(target) ? target : 'file://' + path.resolve(this.currentDir, target);
|
|
527
|
+
this._openBrowser(url);
|
|
528
|
+
return null;
|
|
529
|
+
});
|
|
530
|
+
def('bindEvent', (id, fnValue) => {
|
|
531
|
+
if (typeof id !== 'string') throw new NoahLangError('bindEvent: id must be a string', 0);
|
|
532
|
+
if (!fnValue || !fnValue.__noahFunction) throw new NoahLangError('bindEvent: second argument must be a NoahLang function', 0);
|
|
533
|
+
this.eventHandlers.set(id, fnValue.__noahFunction);
|
|
534
|
+
this.output(`[bindEvent] registered id=${JSON.stringify(id)} -> ${fnValue.__noahFunction}`);
|
|
535
|
+
return null;
|
|
536
|
+
});
|
|
537
|
+
def('dispatch', (id) => this.runEventHandler(id));
|
|
538
|
+
def('serve', (port) => {
|
|
539
|
+
const portNumber = Number(port);
|
|
540
|
+
if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) throw new NoahLangError('serve: port must be a number between 1 and 65535', 0);
|
|
541
|
+
if (this.httpServer) { this.output(`Already serving at http://localhost:${this.httpServer.address().port}/`); return null; }
|
|
542
|
+
const root = this.currentDir;
|
|
543
|
+
const mime = {
|
|
544
|
+
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
|
545
|
+
'.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8',
|
|
546
|
+
'.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
547
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
548
|
+
'.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf'
|
|
549
|
+
};
|
|
550
|
+
const server = http.createServer((req, res) => {
|
|
551
|
+
const url = new URL(req.url, 'http://localhost');
|
|
552
|
+
if (url.pathname === '/__reload') {
|
|
553
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
554
|
+
this.reloadClients.add(res);
|
|
555
|
+
req.on('close', () => { this.reloadClients.delete(res); });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (url.pathname === '/__event') {
|
|
559
|
+
const id = url.searchParams.get('id') || '';
|
|
560
|
+
try { this.runEventHandler(id); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }
|
|
561
|
+
catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: err.message })); }
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
let safePath;
|
|
565
|
+
try { safePath = decodeURIComponent(url.pathname); }
|
|
566
|
+
catch { res.writeHead(400); res.end('Bad request'); return; }
|
|
567
|
+
if (safePath === '/') safePath = '/index.html';
|
|
568
|
+
const filePath = path.normalize(path.join(root, safePath));
|
|
569
|
+
if (!filePath.startsWith(root)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
570
|
+
fs.readFile(filePath, (err, data) => {
|
|
571
|
+
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); return; }
|
|
572
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
573
|
+
let body = data;
|
|
574
|
+
if (ext === '.html') {
|
|
575
|
+
let html = data.toString('utf8');
|
|
576
|
+
if (!html.includes('/__reload')) {
|
|
577
|
+
if (html.includes('</body>')) html = html.replace('</body>', RELOAD_SCRIPT + '</body>');
|
|
578
|
+
else html += RELOAD_SCRIPT;
|
|
579
|
+
}
|
|
580
|
+
body = Buffer.from(html, 'utf8');
|
|
581
|
+
}
|
|
582
|
+
res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' });
|
|
583
|
+
res.end(body);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
server.on('error', (err) => {
|
|
587
|
+
if (err.code === 'EADDRINUSE') {
|
|
588
|
+
this.output(`[serve] port ${portNumber} is already in use; trying the next port`);
|
|
589
|
+
const retry = portNumber + 1;
|
|
590
|
+
if (retry <= 65535) { try { server.close(); } catch {} setImmediate(() => this._serveWith(server, retry, root, mime)); }
|
|
591
|
+
else this.output(`[serve] no available port near ${portNumber}`);
|
|
592
|
+
} else this.output(`[serve] error: ${err.message}`);
|
|
593
|
+
});
|
|
594
|
+
server.listen(portNumber, () => {
|
|
595
|
+
this.httpServer = server;
|
|
596
|
+
const url = `http://localhost:${portNumber}/`;
|
|
597
|
+
this.output(`[serve] listening at ${url} (root: ${root})`);
|
|
598
|
+
this.output('Press Ctrl+C to stop.');
|
|
599
|
+
if (!browserOpenedGlobally) { browserOpenedGlobally = true; this._openBrowser(url); }
|
|
600
|
+
});
|
|
601
|
+
return null;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
_serveWith(server, port, root, mime) {
|
|
606
|
+
server.listen(port, () => {
|
|
607
|
+
this.httpServer = server;
|
|
608
|
+
const url = `http://localhost:${port}/`;
|
|
609
|
+
this.output(`[serve] listening at ${url} (root: ${root})`);
|
|
610
|
+
this.output('Press Ctrl+C to stop.');
|
|
611
|
+
if (!browserOpenedGlobally) { browserOpenedGlobally = true; this._openBrowser(url); }
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
_openBrowser(target) {
|
|
616
|
+
const platform = process.platform;
|
|
617
|
+
let command;
|
|
618
|
+
if (platform === 'win32') command = `start "" "${target}"`;
|
|
619
|
+
else if (platform === 'darwin') command = `open "${target}"`;
|
|
620
|
+
else command = `xdg-open "${target}"`;
|
|
621
|
+
exec(command, (err) => { if (err) console.error(`NoahLang: could not open browser: ${err.message}`); });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
notifyReload() {
|
|
625
|
+
for (const res of this.reloadClients) {
|
|
626
|
+
try { res.write('data: reload\n\n'); } catch { this.reloadClients.delete(res); }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
shutdown() {
|
|
631
|
+
for (const res of this.reloadClients) { try { res.end(); } catch {} }
|
|
632
|
+
this.reloadClients.clear();
|
|
633
|
+
if (this.httpServer) { try { this.httpServer.close(); } catch {} this.httpServer = null; }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
evaluateExpression(expression, env, line) {
|
|
637
|
+
const tokens = tokenizeExpression(expression, line);
|
|
638
|
+
return new ExpressionParser(tokens, this, env, line).parse();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
callFunction(name, args, line, callerEnv = this.globalEnv) {
|
|
642
|
+
if (name === 'receive') {
|
|
643
|
+
if (this.inputQueue.length) {
|
|
644
|
+
const next = this.inputQueue.shift();
|
|
645
|
+
return typeof next === 'string' ? next : String(next ?? '');
|
|
646
|
+
}
|
|
647
|
+
if (this.stdin != null) {
|
|
648
|
+
const idx = this.stdin.indexOf('\n');
|
|
649
|
+
if (idx === -1) { const out = this.stdin; this.stdin = ''; return out.replace(/\r$/, ''); }
|
|
650
|
+
const out = this.stdin.slice(0, idx);
|
|
651
|
+
this.stdin = this.stdin.slice(idx + 1);
|
|
652
|
+
return out.replace(/\r$/, '');
|
|
653
|
+
}
|
|
654
|
+
if (typeof this.readInput === 'function') return this.readInput();
|
|
655
|
+
throw new NoahLangError('receive() needs input; pass `options.stdin`, `options.input`, or set `interpreter.readInput`', line);
|
|
656
|
+
}
|
|
657
|
+
if (name === 'collect') return args.slice();
|
|
658
|
+
if (name === 'fail') throw new NoahLangError(stringify(args[0] ?? 'failed'), line);
|
|
659
|
+
|
|
660
|
+
const builtin = this.globalEnv.lookup(name);
|
|
661
|
+
if (builtin && typeof builtin.value === 'function' && !this.functions.has(name)) {
|
|
662
|
+
try { return builtin.value(...args); }
|
|
663
|
+
catch (err) { if (err instanceof NoahLangError) throw err; throw new NoahLangError(err.message, line); }
|
|
664
|
+
}
|
|
665
|
+
const fn = this.functions.get(name);
|
|
666
|
+
if (!fn) throw new NoahLangError(`undefined function '${name}'`, line);
|
|
667
|
+
if (this.callDepth >= this.maxDepth) throw new NoahLangError(`maximum call depth (${this.maxDepth}) exceeded in '${name}'`, line);
|
|
668
|
+
|
|
669
|
+
this.callDepth++;
|
|
670
|
+
try {
|
|
671
|
+
for (const clause of fn.clauses) {
|
|
672
|
+
if (clause.params.length !== args.length) continue;
|
|
673
|
+
const localEnv = new Environment(clause.closure);
|
|
674
|
+
let matched = true;
|
|
675
|
+
for (let k = 0; k < clause.params.length; k++) {
|
|
676
|
+
const pattern = clause.patterns[k];
|
|
677
|
+
const value = args[k];
|
|
678
|
+
if (pattern.kind === 'literal') { if (pattern.value !== value) { matched = false; break; } }
|
|
679
|
+
else if (pattern.kind === 'wildcard') { /* bind nothing */ }
|
|
680
|
+
else localEnv.define(pattern.name, value);
|
|
681
|
+
}
|
|
682
|
+
if (!matched) continue;
|
|
683
|
+
const result = this.executeBlock(clause.body, localEnv, clause.baseIndent, { allowNested: true, functionDepth: 1 });
|
|
684
|
+
if (result.signal === RETURN_SIGNAL) return result.value;
|
|
685
|
+
return result.value;
|
|
686
|
+
}
|
|
687
|
+
throw new NoahLangError(`no clause of '${name}' matched ${args.length} argument(s): ` + args.map(stringify).join(', '), line);
|
|
688
|
+
} finally {
|
|
689
|
+
this.callDepth--;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
run(code, options = {}) {
|
|
694
|
+
try {
|
|
695
|
+
const lines = this._parseLines(code);
|
|
696
|
+
this.executeBlock(lines, this.globalEnv, 0, { allowNested: false, functionDepth: 0 });
|
|
697
|
+
return options.safe ? { ok: true, error: null } : undefined;
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (options.safe) return { ok: false, error: err };
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
runString(code, options = {}) { return this.run(code, options); }
|
|
705
|
+
|
|
706
|
+
_parseLines(code) {
|
|
707
|
+
return code.split(/\r?\n/).map((raw, index) => {
|
|
708
|
+
const withoutComment = removeComment(raw);
|
|
709
|
+
const text = withoutComment.trim();
|
|
710
|
+
const indentText = raw.match(/^[ \t]*/)[0];
|
|
711
|
+
const indent = indentText.replace(/\t/g, ' ').length;
|
|
712
|
+
return { text, indent, line: index + 1, raw };
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
_pull(target, env, line) {
|
|
717
|
+
const os = require('os');
|
|
718
|
+
const noahHome = process.env.NOAHLANG_HOME || path.join(os.homedir(), '.noahlang');
|
|
719
|
+
const candidates = [
|
|
720
|
+
path.resolve(this.currentDir, target),
|
|
721
|
+
path.resolve(noahHome, target),
|
|
722
|
+
path.resolve(noahHome, 'lib', target),
|
|
723
|
+
];
|
|
724
|
+
|
|
725
|
+
let resolved = null;
|
|
726
|
+
for (const c of candidates) {
|
|
727
|
+
if (fs.existsSync(c)) { resolved = c; break; }
|
|
728
|
+
}
|
|
729
|
+
if (!resolved) {
|
|
730
|
+
throw new NoahLangError(
|
|
731
|
+
`cannot pull '${target}': not found in project, ~/.noahlang/, or ~/.noahlang/lib/`,
|
|
732
|
+
line
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (this.pulledFiles.has(resolved)) return;
|
|
737
|
+
if (this.pullStack.includes(resolved)) {
|
|
738
|
+
throw new NoahLangError(`circular pull of '${target}'`, line);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.pullStack.push(resolved);
|
|
742
|
+
this.pulledFiles.add(resolved);
|
|
743
|
+
if (this.onFileRead) { try { this.onFileRead(resolved); } catch {} }
|
|
744
|
+
|
|
745
|
+
const previousDir = this.currentDir;
|
|
746
|
+
this.currentDir = path.dirname(resolved);
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const code = fs.readFileSync(resolved, 'utf8');
|
|
750
|
+
const lines = this._parseLines(code);
|
|
751
|
+
this.executeBlock(lines, env, 0, { allowNested: false, functionDepth: 0 });
|
|
752
|
+
} finally {
|
|
753
|
+
this.currentDir = previousDir;
|
|
754
|
+
this.pullStack.pop();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
_capitalize(name) {
|
|
759
|
+
if (!name) return name;
|
|
760
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
_callEndFunction(name, line) {
|
|
764
|
+
const candidates = ['end' + this._capitalize(name), name.replace(/Start$/, 'End'), name + 'End'];
|
|
765
|
+
for (const closerName of candidates) {
|
|
766
|
+
const builtin = this.globalEnv.lookup(closerName);
|
|
767
|
+
if (builtin && typeof builtin.value === 'function' && !this.functions.has(closerName)) {
|
|
768
|
+
try { return builtin.value(); }
|
|
769
|
+
catch (err) { if (err instanceof NoahLangError) throw err; throw new NoahLangError(err.message, line); }
|
|
770
|
+
}
|
|
771
|
+
const fn = this.functions.get(closerName);
|
|
772
|
+
if (fn) return this.callFunction(closerName, [], line, this.globalEnv);
|
|
773
|
+
}
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
_scanBlock(lines, start, baseIndent, closerRegex, description, startLine) {
|
|
778
|
+
let bodyEnd = start + 1;
|
|
779
|
+
while (bodyEnd < lines.length && (!lines[bodyEnd].text || lines[bodyEnd].indent > baseIndent)) bodyEnd++;
|
|
780
|
+
const body = lines.slice(start + 1, bodyEnd).filter(e => e.text);
|
|
781
|
+
|
|
782
|
+
let probe = bodyEnd;
|
|
783
|
+
while (probe < lines.length && !lines[probe].text) probe++;
|
|
784
|
+
if (probe >= lines.length) throw new NoahLangError(`${description} (line ${startLine}) was never closed`, startLine);
|
|
785
|
+
const closerItem = lines[probe];
|
|
786
|
+
if (closerItem.indent !== baseIndent) throw new NoahLangError(`${description} (line ${startLine}) must be closed at the same indentation`, closerItem.line, null, closerItem.raw);
|
|
787
|
+
const m = closerItem.text.match(closerRegex);
|
|
788
|
+
if (!m) throw new NoahLangError(`${description} (line ${startLine}) must be followed by a closer, found '${closerItem.text}'`, closerItem.line, null, closerItem.raw);
|
|
789
|
+
if (!body.length) throw new NoahLangError(`${description} (line ${startLine}) must have an indented body`, startLine);
|
|
790
|
+
const bodyIndent = body[0].indent;
|
|
791
|
+
if (body.some(e => e.indent < bodyIndent)) throw new NoahLangError(`invalid indentation in ${description} body`, startLine);
|
|
792
|
+
return { body, bodyIndent, closerIndex: probe, closerMatch: m, closerItem };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
executeBlock(lines, env, baseIndent, ctx) {
|
|
796
|
+
const allowNested = Boolean(ctx && ctx.allowNested);
|
|
797
|
+
const functionDepth = (ctx && ctx.functionDepth) || 0;
|
|
798
|
+
let i = 0;
|
|
799
|
+
let lastValue = null;
|
|
800
|
+
|
|
801
|
+
while (i < lines.length) {
|
|
802
|
+
const item = lines[i];
|
|
803
|
+
if (!item.text) { i++; continue; }
|
|
804
|
+
if (item.indent < baseIndent) break;
|
|
805
|
+
if (item.indent > baseIndent && !allowNested) throw new NoahLangError('unexpected indentation', item.line, null, item.raw);
|
|
806
|
+
const statement = item.text;
|
|
807
|
+
|
|
808
|
+
if (statement === 'record') {
|
|
809
|
+
const obj = {};
|
|
810
|
+
let j = i + 1;
|
|
811
|
+
while (j < lines.length) {
|
|
812
|
+
const sub = lines[j];
|
|
813
|
+
if (!sub.text) { j++; continue; }
|
|
814
|
+
if (sub.text === 'end record' && sub.indent === item.indent) break;
|
|
815
|
+
const m = sub.text.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/);
|
|
816
|
+
if (!m) throw new NoahLangError(`invalid record field; expected 'name: value' or 'end record', got '${sub.text}'`, sub.line, null, sub.raw);
|
|
817
|
+
obj[m[1]] = this.evaluateExpression(m[2], env, sub.line);
|
|
818
|
+
j++;
|
|
819
|
+
}
|
|
820
|
+
if (j >= lines.length) throw new NoahLangError('record must be closed with end record', item.line, null, item.raw);
|
|
821
|
+
lastValue = obj;
|
|
822
|
+
i = j + 1;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const forMatch = statement.match(/^for\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+at\s+([A-Za-z_][A-Za-z0-9_]*))?\s+in\s+(.+)$/);
|
|
827
|
+
if (forMatch) {
|
|
828
|
+
const name = forMatch[1];
|
|
829
|
+
const indexName = forMatch[2] || null;
|
|
830
|
+
const iterExpr = forMatch[3];
|
|
831
|
+
const { body, bodyIndent, closerIndex } = this._scanBlock(lines, i, item.indent, /^end\s+for\s*$/, 'for', item.line);
|
|
832
|
+
const iterable = this.evaluateExpression(iterExpr, env, item.line);
|
|
833
|
+
let items;
|
|
834
|
+
if (Array.isArray(iterable)) items = iterable;
|
|
835
|
+
else if (typeof iterable === 'string') items = iterable.split('');
|
|
836
|
+
else if (iterable instanceof NoahRange) {
|
|
837
|
+
const arr = [];
|
|
838
|
+
for (let n = iterable.start; n < iterable.end; n++) arr.push(n);
|
|
839
|
+
items = arr;
|
|
840
|
+
} else throw new NoahLangError(`for expects a list, string, or range; got ${describeType(iterable)}`, item.line);
|
|
841
|
+
for (let idx = 0; idx < items.length; idx++) {
|
|
842
|
+
const loopEnv = new Environment(env);
|
|
843
|
+
loopEnv.define(name, items[idx]);
|
|
844
|
+
if (indexName) loopEnv.define(indexName, idx);
|
|
845
|
+
const result = this.executeBlock(body, loopEnv, bodyIndent, { allowNested: true, functionDepth });
|
|
846
|
+
if (result.signal === BREAK_SIGNAL) break;
|
|
847
|
+
if (result.signal === CONTINUE_SIGNAL) continue;
|
|
848
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
849
|
+
}
|
|
850
|
+
i = closerIndex + 1;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (/^while\s+/.test(statement)) {
|
|
855
|
+
const m = statement.match(/^while\s+(.+)$/);
|
|
856
|
+
if (!m) throw new NoahLangError("invalid while; expected 'while condition'", item.line, null, item.raw);
|
|
857
|
+
const condExpr = m[1];
|
|
858
|
+
const { body, bodyIndent, closerIndex } = this._scanBlock(lines, i, item.indent, /^end\s+while\s*$/, 'while', item.line);
|
|
859
|
+
while (true) {
|
|
860
|
+
const cond = this.evaluateExpression(condExpr, env, item.line);
|
|
861
|
+
if (!cond) break;
|
|
862
|
+
const loopEnv = new Environment(env);
|
|
863
|
+
const result = this.executeBlock(body, loopEnv, bodyIndent, { allowNested: true, functionDepth });
|
|
864
|
+
if (result.signal === BREAK_SIGNAL) break;
|
|
865
|
+
if (result.signal === CONTINUE_SIGNAL) continue;
|
|
866
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
867
|
+
}
|
|
868
|
+
i = closerIndex + 1;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const repMatch = statement.match(/^repeat\s+(.+?)\s+times(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/);
|
|
873
|
+
if (repMatch) {
|
|
874
|
+
const countExpr = repMatch[1];
|
|
875
|
+
const indexName = repMatch[2] || null;
|
|
876
|
+
const { body, bodyIndent, closerIndex } = this._scanBlock(lines, i, item.indent, /^end\s+repeat\s*$/, 'repeat', item.line);
|
|
877
|
+
const count = Number(this.evaluateExpression(countExpr, env, item.line));
|
|
878
|
+
if (!Number.isInteger(count) || count < 0) throw new NoahLangError(`repeat expects a non-negative integer, got ${stringify(countExpr)}`, item.line);
|
|
879
|
+
for (let idx = 0; idx < count; idx++) {
|
|
880
|
+
const loopEnv = new Environment(env);
|
|
881
|
+
if (indexName) loopEnv.define(indexName, idx);
|
|
882
|
+
const result = this.executeBlock(body, loopEnv, bodyIndent, { allowNested: true, functionDepth });
|
|
883
|
+
if (result.signal === BREAK_SIGNAL) break;
|
|
884
|
+
if (result.signal === CONTINUE_SIGNAL) continue;
|
|
885
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
886
|
+
}
|
|
887
|
+
i = closerIndex + 1;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (statement === 'attempt') {
|
|
892
|
+
let bodyEnd = i + 1;
|
|
893
|
+
while (bodyEnd < lines.length && (!lines[bodyEnd].text || lines[bodyEnd].indent > item.indent)) bodyEnd++;
|
|
894
|
+
const body = lines.slice(i + 1, bodyEnd).filter(e => e.text);
|
|
895
|
+
if (!body.length) throw new NoahLangError(`attempt must have an indented body`, item.line, null, item.raw);
|
|
896
|
+
const bodyIndent = body[0].indent;
|
|
897
|
+
let probe = bodyEnd;
|
|
898
|
+
while (probe < lines.length && !lines[probe].text) probe++;
|
|
899
|
+
if (probe >= lines.length) throw new NoahLangError(`attempt must be closed with end attempt`, item.line, null, item.raw);
|
|
900
|
+
let recoverVar = null;
|
|
901
|
+
let recoverBody = null;
|
|
902
|
+
let recoverIndent = 0;
|
|
903
|
+
let endProbe = probe;
|
|
904
|
+
const recMatch = lines[probe].text.match(/^recover\s+with\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/);
|
|
905
|
+
if (recMatch) {
|
|
906
|
+
recoverVar = recMatch[1];
|
|
907
|
+
let rEnd = probe + 1;
|
|
908
|
+
while (rEnd < lines.length && (!lines[rEnd].text || lines[rEnd].indent > lines[probe].indent)) rEnd++;
|
|
909
|
+
recoverBody = lines.slice(probe + 1, rEnd).filter(e => e.text);
|
|
910
|
+
if (!recoverBody.length) throw new NoahLangError(`recover with must have an indented body`, lines[probe].line, null, lines[probe].raw);
|
|
911
|
+
recoverIndent = recoverBody[0].indent;
|
|
912
|
+
let p2 = rEnd;
|
|
913
|
+
while (p2 < lines.length && !lines[p2].text) p2++;
|
|
914
|
+
if (p2 >= lines.length || !/^end\s+attempt\s*$/.test(lines[p2].text)) throw new NoahLangError(`attempt must be closed with end attempt`, item.line, null, item.raw);
|
|
915
|
+
endProbe = p2;
|
|
916
|
+
} else if (/^end\s+attempt\s*$/.test(lines[probe].text)) {
|
|
917
|
+
endProbe = probe;
|
|
918
|
+
} else {
|
|
919
|
+
throw new NoahLangError(`attempt must be followed by 'recover with <err>' or 'end attempt'`, lines[probe].line, null, lines[probe].raw);
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
const result = this.executeBlock(body, env, bodyIndent, { allowNested: true, functionDepth });
|
|
923
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
924
|
+
if (result.signal === BREAK_SIGNAL) return { signal: BREAK_SIGNAL };
|
|
925
|
+
if (result.signal === CONTINUE_SIGNAL) return { signal: CONTINUE_SIGNAL };
|
|
926
|
+
} catch (err) {
|
|
927
|
+
if (!(err instanceof NoahLangError)) throw err;
|
|
928
|
+
if (!recoverBody) throw err;
|
|
929
|
+
const catchEnv = new Environment(env);
|
|
930
|
+
catchEnv.define(recoverVar, err.rawMessage || err.message);
|
|
931
|
+
const result = this.executeBlock(recoverBody, catchEnv, recoverIndent, { allowNested: true, functionDepth });
|
|
932
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
933
|
+
if (result.signal === BREAK_SIGNAL) return { signal: BREAK_SIGNAL };
|
|
934
|
+
if (result.signal === CONTINUE_SIGNAL) return { signal: CONTINUE_SIGNAL };
|
|
935
|
+
}
|
|
936
|
+
i = endProbe + 1;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const startMatch = statement.match(/^start\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\(.*)$/);
|
|
941
|
+
if (startMatch) {
|
|
942
|
+
const name = startMatch[1];
|
|
943
|
+
const callText = name + startMatch[2];
|
|
944
|
+
const { body, bodyIndent, closerIndex, closerMatch } = this._scanBlock(lines, i, item.indent, /^end\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/, 'start ' + name, item.line);
|
|
945
|
+
if (closerMatch[1] !== name) throw new NoahLangError(`end ${closerMatch[1]} but expected end ${name} (started at line ${item.line})`, lines[closerIndex].line, null, lines[closerIndex].raw);
|
|
946
|
+
this.evaluateExpression(callText, env, item.line);
|
|
947
|
+
const result = this.executeBlock(body, env, bodyIndent, { allowNested: true, functionDepth });
|
|
948
|
+
this._callEndFunction(name, item.line);
|
|
949
|
+
if (result.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: result.value };
|
|
950
|
+
if (result.signal === BREAK_SIGNAL) return { signal: BREAK_SIGNAL };
|
|
951
|
+
if (result.signal === CONTINUE_SIGNAL) return { signal: CONTINUE_SIGNAL };
|
|
952
|
+
i = closerIndex + 1;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (/^if\s+/.test(statement)) {
|
|
957
|
+
const chain = this.parseConditionalChain(lines, i, env, functionDepth);
|
|
958
|
+
i = chain.nextIndex;
|
|
959
|
+
if (chain.signal === RETURN_SIGNAL) return { signal: RETURN_SIGNAL, value: chain.value };
|
|
960
|
+
if (chain.signal === BREAK_SIGNAL) return { signal: BREAK_SIGNAL };
|
|
961
|
+
if (chain.signal === CONTINUE_SIGNAL) return { signal: CONTINUE_SIGNAL };
|
|
962
|
+
if (chain.result !== undefined) lastValue = chain.result;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (/^else\s*:/.test(statement) || /^else\s+if\s+/.test(statement) || statement === 'else') {
|
|
966
|
+
throw new NoahLangError("'else' must immediately follow an 'if' block", item.line, null, item.raw);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (/^define\s+/.test(statement)) {
|
|
970
|
+
const match = statement.match(/^define\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*$/);
|
|
971
|
+
if (!match) throw new NoahLangError("invalid function definition; expected 'define name(parameters)'", item.line, null, item.raw);
|
|
972
|
+
const name = match[1];
|
|
973
|
+
const paramSrc = match[2].trim();
|
|
974
|
+
const rawParams = paramSrc ? paramSrc.split(',').map(p => p.trim()) : [];
|
|
975
|
+
const patterns = rawParams.map(p => parsePattern(p, item.line));
|
|
976
|
+
const { body, bodyIndent, closerIndex } = this._scanBlock(lines, i, item.indent, /^end\s+define\s*$/, 'define ' + name, item.line);
|
|
977
|
+
const clause = { params: rawParams, patterns, body, baseIndent: bodyIndent, line: item.line, closure: env };
|
|
978
|
+
if (this.functions.has(name)) {
|
|
979
|
+
const existing = this.functions.get(name);
|
|
980
|
+
const dupKey = patterns.map(p => p.kind === 'literal' ? `lit:${stringify(p.value)}` : p.kind === 'wildcard' ? 'any' : 'var').join('|');
|
|
981
|
+
const existingKeys = new Set(existing.clauses.map(c => c.patterns.map(p => p.kind === 'literal' ? `lit:${stringify(p.value)}` : p.kind === 'wildcard' ? 'any' : 'var').join('|')));
|
|
982
|
+
if (existingKeys.has(dupKey)) throw new NoahLangError(`duplicate clause for '${name}'`, item.line, null, item.raw);
|
|
983
|
+
existing.clauses.push(clause);
|
|
984
|
+
} else {
|
|
985
|
+
this.functions.set(name, { clauses: [clause] });
|
|
986
|
+
env.define(name, { __noahFunction: name });
|
|
987
|
+
}
|
|
988
|
+
i = closerIndex + 1;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (/^pull\s+/.test(statement)) {
|
|
993
|
+
const match = statement.match(/^pull\s+(["'])(.+?)\1\s*$/);
|
|
994
|
+
if (!match) throw new NoahLangError('invalid pull; expected pull "file.noah"', item.line, null, item.raw);
|
|
995
|
+
this._pull(match[2], env, item.line);
|
|
996
|
+
i++; continue;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (statement === 'break') return { signal: BREAK_SIGNAL };
|
|
1000
|
+
if (statement === 'continue') return { signal: CONTINUE_SIGNAL };
|
|
1001
|
+
|
|
1002
|
+
if (statement.startsWith('let ')) {
|
|
1003
|
+
const match = statement.match(/^let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
1004
|
+
if (!match) throw new NoahLangError("invalid variable declaration; expected 'let name = expression'", item.line, null, item.raw);
|
|
1005
|
+
const name = match[1];
|
|
1006
|
+
if (env.hasOwn(name)) throw new NoahLangError(`variable '${name}' is already declared in this scope; use 'set ${name} = ...' to reassign`, item.line, null, item.raw);
|
|
1007
|
+
|
|
1008
|
+
// NEW: RHS is `record` — parse a record block
|
|
1009
|
+
if (match[2].trim() === 'record') {
|
|
1010
|
+
const obj = {};
|
|
1011
|
+
let j = i + 1;
|
|
1012
|
+
while (j < lines.length) {
|
|
1013
|
+
const sub = lines[j];
|
|
1014
|
+
if (!sub.text) { j++; continue; }
|
|
1015
|
+
if (sub.text === 'end record' && sub.indent === item.indent) break;
|
|
1016
|
+
const m = sub.text.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/);
|
|
1017
|
+
if (!m) throw new NoahLangError(`invalid record field; expected 'name: value' or 'end record', got '${sub.text}'`, sub.line, null, sub.raw);
|
|
1018
|
+
obj[m[1]] = this.evaluateExpression(m[2], env, sub.line);
|
|
1019
|
+
j++;
|
|
1020
|
+
}
|
|
1021
|
+
if (j >= lines.length) throw new NoahLangError('record must be closed with end record', item.line, null, item.raw);
|
|
1022
|
+
env.define(name, obj);
|
|
1023
|
+
i = j + 1;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
env.define(name, this.evaluateExpression(match[2], env, item.line));
|
|
1028
|
+
i++; continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (statement.startsWith('set ')) {
|
|
1031
|
+
const match = statement.match(/^set\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
1032
|
+
if (!match) throw new NoahLangError("invalid assignment; expected 'set name = expression'", item.line, null, item.raw);
|
|
1033
|
+
const name = match[1];
|
|
1034
|
+
if (!env.has(name)) throw new NoahLangError(`cannot reassign undefined variable '${name}'; declare it first using 'let ${name} = ...'`, item.line, null, item.raw);
|
|
1035
|
+
const value = this.evaluateExpression(match[2], env, item.line);
|
|
1036
|
+
env.assign(name, value);
|
|
1037
|
+
i++; continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (/^fail\s+with\s+/.test(statement)) {
|
|
1040
|
+
const expr = statement.replace(/^fail\s+with\s+/, '');
|
|
1041
|
+
const msg = this.evaluateExpression(expr, env, item.line);
|
|
1042
|
+
throw new NoahLangError(stringify(msg), item.line, null, item.raw);
|
|
1043
|
+
}
|
|
1044
|
+
if (/^write\s+/.test(statement)) {
|
|
1045
|
+
const expr = statement.replace(/^write\s+/, '');
|
|
1046
|
+
this.output(stringify(this.evaluateExpression(expr, env, item.line)));
|
|
1047
|
+
i++; continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (/^return(?:\s|$)/.test(statement)) {
|
|
1050
|
+
if (functionDepth <= 0) throw new NoahLangError("'return' can only be used inside a function", item.line, null, item.raw);
|
|
1051
|
+
const expr = statement.replace(/^return\s*/, '');
|
|
1052
|
+
const value = expr ? this.evaluateExpression(expr, env, item.line) : null;
|
|
1053
|
+
return { signal: RETURN_SIGNAL, value };
|
|
1054
|
+
}
|
|
1055
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*\s*[\(\[]/.test(statement) ||
|
|
1056
|
+
/^[\("'\-+\d]/.test(statement) ||
|
|
1057
|
+
/^true\b|^false\b|^null\b/.test(statement) ||
|
|
1058
|
+
/^\[/.test(statement)) {
|
|
1059
|
+
lastValue = this.evaluateExpression(statement, env, item.line);
|
|
1060
|
+
i++; continue;
|
|
1061
|
+
}
|
|
1062
|
+
throw new NoahLangError(`unrecognized statement '${statement}'`, item.line, null, item.raw);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return { signal: undefined, value: lastValue };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
parseConditionalChain(lines, start, env, functionDepth) {
|
|
1069
|
+
const branches = [];
|
|
1070
|
+
const baseIndent = lines[start].indent;
|
|
1071
|
+
let i = start;
|
|
1072
|
+
let first = true;
|
|
1073
|
+
let closerIndex = -1;
|
|
1074
|
+
|
|
1075
|
+
while (i < lines.length) {
|
|
1076
|
+
const item = lines[i];
|
|
1077
|
+
let kind = null;
|
|
1078
|
+
if (first) {
|
|
1079
|
+
if (/^if\s+/.test(item.text)) kind = 'if';
|
|
1080
|
+
else break;
|
|
1081
|
+
} else {
|
|
1082
|
+
if (/^else\s+if\s+/.test(item.text)) kind = 'else-if';
|
|
1083
|
+
else if (item.text === 'else') kind = 'else';
|
|
1084
|
+
else break;
|
|
1085
|
+
}
|
|
1086
|
+
if (item.indent !== baseIndent) break;
|
|
1087
|
+
let conditionExpr = null;
|
|
1088
|
+
if (kind === 'if') {
|
|
1089
|
+
const m = item.text.match(/^if\s+(.+)$/);
|
|
1090
|
+
if (!m) throw new NoahLangError("invalid conditional; expected 'if condition'", item.line, null, item.raw);
|
|
1091
|
+
conditionExpr = m[1];
|
|
1092
|
+
} else if (kind === 'else-if') {
|
|
1093
|
+
const m = item.text.match(/^else\s+if\s+(.+)$/);
|
|
1094
|
+
if (!m) throw new NoahLangError("invalid conditional; expected 'else if condition'", item.line, null, item.raw);
|
|
1095
|
+
conditionExpr = m[1];
|
|
1096
|
+
}
|
|
1097
|
+
let bodyEnd = i + 1;
|
|
1098
|
+
while (bodyEnd < lines.length && (!lines[bodyEnd].text || lines[bodyEnd].indent > item.indent)) bodyEnd++;
|
|
1099
|
+
const body = lines.slice(i + 1, bodyEnd).filter(e => e.text);
|
|
1100
|
+
if (!body.length) throw new NoahLangError(`'${kind === 'else' ? 'else' : 'if'}' must have an indented body`, item.line, null, item.raw);
|
|
1101
|
+
const bodyIndent = body[0].indent;
|
|
1102
|
+
if (body.some(e => e.indent < bodyIndent)) throw new NoahLangError('invalid conditional indentation', item.line, null, item.raw);
|
|
1103
|
+
branches.push({ kind, conditionExpr, body, bodyIndent, line: item.line });
|
|
1104
|
+
i = bodyEnd;
|
|
1105
|
+
first = false;
|
|
1106
|
+
|
|
1107
|
+
let probe = i;
|
|
1108
|
+
while (probe < lines.length && !lines[probe].text) probe++;
|
|
1109
|
+
if (probe >= lines.length) { i = probe; break; }
|
|
1110
|
+
const nextItem = lines[probe];
|
|
1111
|
+
if (nextItem.indent !== baseIndent) { i = probe; break; }
|
|
1112
|
+
if (/^else\s+if\s+/.test(nextItem.text) || nextItem.text === 'else') { i = probe; continue; }
|
|
1113
|
+
if (/^end\s+if\s*$/.test(nextItem.text)) { closerIndex = probe; i = probe + 1; break; }
|
|
1114
|
+
i = probe; break;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (closerIndex === -1) throw new NoahLangError(`if (line ${start + 1}) must be closed with end if`, start + 1);
|
|
1118
|
+
|
|
1119
|
+
for (const branch of branches) {
|
|
1120
|
+
let chosen = (branch.kind === 'else');
|
|
1121
|
+
if (!chosen) {
|
|
1122
|
+
const cond = this.evaluateExpression(branch.conditionExpr, env, branch.line);
|
|
1123
|
+
chosen = Boolean(cond);
|
|
1124
|
+
}
|
|
1125
|
+
if (chosen) {
|
|
1126
|
+
const branchEnv = new Environment(env);
|
|
1127
|
+
const result = this.executeBlock(branch.body, branchEnv, branch.bodyIndent, { allowNested: true, functionDepth });
|
|
1128
|
+
if (result.signal === RETURN_SIGNAL) return { nextIndex: closerIndex + 1, signal: RETURN_SIGNAL, value: result.value };
|
|
1129
|
+
if (result.signal === BREAK_SIGNAL || result.signal === CONTINUE_SIGNAL) return { nextIndex: closerIndex + 1, signal: result.signal };
|
|
1130
|
+
return { nextIndex: closerIndex + 1, result: result.value };
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return { nextIndex: closerIndex + 1, result: undefined };
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ---------------------------------------------
|
|
1138
|
+
// PATTERN PARSING / HELPERS
|
|
1139
|
+
// ---------------------------------------------
|
|
1140
|
+
function parsePattern(src, line) {
|
|
1141
|
+
if (src === '_') return { kind: 'wildcard' };
|
|
1142
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(src)) return { kind: 'var', name: src };
|
|
1143
|
+
if (/^-?\d+(?:\.\d+)?$/.test(src)) return { kind: 'literal', value: Number(src) };
|
|
1144
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(src) || /^'(?:[^'\\]|\\.)*'$/.test(src)) {
|
|
1145
|
+
const toks = tokenizeExpression(src, line);
|
|
1146
|
+
if (toks.length === 1 && toks[0].type === 'string') {
|
|
1147
|
+
const parts = toks[0].parts;
|
|
1148
|
+
if (parts.length === 1 && parts[0].type === 'text') return { kind: 'literal', value: parts[0].value };
|
|
1149
|
+
}
|
|
1150
|
+
throw new NoahLangError('patterns cannot use string interpolation', line);
|
|
1151
|
+
}
|
|
1152
|
+
if (src === 'true') return { kind: 'literal', value: true };
|
|
1153
|
+
if (src === 'false') return { kind: 'literal', value: false };
|
|
1154
|
+
if (src === 'null') return { kind: 'literal', value: null };
|
|
1155
|
+
throw new NoahLangError(`invalid parameter pattern '${src}'`, line);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function stringify(value) {
|
|
1159
|
+
if (value === null || value === undefined) return 'null';
|
|
1160
|
+
if (Array.isArray(value)) return '[' + value.map(stringify).join(', ') + ']';
|
|
1161
|
+
if (value instanceof NoahRange) return value.start + '..' + value.end;
|
|
1162
|
+
if (typeof value === 'object') {
|
|
1163
|
+
const parts = Object.keys(value).map(k => k + ': ' + stringify(value[k]));
|
|
1164
|
+
return '{' + parts.join(', ') + '}';
|
|
1165
|
+
}
|
|
1166
|
+
return String(value);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ---------------------------------------------
|
|
1170
|
+
// CLI
|
|
1171
|
+
// ---------------------------------------------
|
|
1172
|
+
if (require.main === module) {
|
|
1173
|
+
const args = process.argv.slice(2);
|
|
1174
|
+
if (args.includes('--version') || args.includes('-v')) { console.log('NoahLang 2.0.2'); process.exit(0); }
|
|
1175
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
1176
|
+
console.log('NoahLang 2.0.1');
|
|
1177
|
+
console.log('Usage: noah [--watch] <file.noah>');
|
|
1178
|
+
console.log('');
|
|
1179
|
+
console.log('Statements:');
|
|
1180
|
+
console.log(' for x in list ... end for');
|
|
1181
|
+
console.log(' for x at i in list ... end for');
|
|
1182
|
+
console.log(' for i in 0..10 ... end for');
|
|
1183
|
+
console.log(' while cond ... end while');
|
|
1184
|
+
console.log(' repeat N times ... end repeat');
|
|
1185
|
+
console.log(' repeat N times as i ... end repeat');
|
|
1186
|
+
console.log(' if cond ... else if cond ... else ... end if');
|
|
1187
|
+
console.log(' define name(args) ... end define');
|
|
1188
|
+
console.log(' attempt ... recover with err ... end attempt');
|
|
1189
|
+
console.log(' fail with "message"');
|
|
1190
|
+
console.log(' record ... field: value ... end record');
|
|
1191
|
+
console.log(' start name(...) ... end name');
|
|
1192
|
+
console.log('');
|
|
1193
|
+
console.log('Lists: [1, 2, 3], ["a", "b"], []');
|
|
1194
|
+
console.log('Ranges: 0..10 (exclusive end)');
|
|
1195
|
+
console.log('Access: record.field, record["field"], list[i]');
|
|
1196
|
+
console.log('');
|
|
1197
|
+
console.log(' --watch Re-run on file changes (with browser auto-reload)');
|
|
1198
|
+
process.exit(0);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const watch = args.includes('--watch');
|
|
1202
|
+
let file = null;
|
|
1203
|
+
let endOfFlags = false;
|
|
1204
|
+
for (const arg of args) {
|
|
1205
|
+
if (!endOfFlags && arg === '--') { endOfFlags = true; continue; }
|
|
1206
|
+
if (!endOfFlags && arg.startsWith('-')) continue;
|
|
1207
|
+
file = arg; break;
|
|
1208
|
+
}
|
|
1209
|
+
if (!file) { console.log('NoahLang 2.0.1'); console.log('Usage: noah [--watch] <file.noah>'); process.exit(1); }
|
|
1210
|
+
if (!fs.existsSync(file)) { console.error(`NoahLang error: file not found: ${file}`); process.exit(1); }
|
|
1211
|
+
|
|
1212
|
+
const filePath = path.resolve(file);
|
|
1213
|
+
const baseDir = path.dirname(filePath);
|
|
1214
|
+
const state = { interpreter: null, watched: new Set(), dirWatchers: new Map(), reloadTimer: null };
|
|
1215
|
+
|
|
1216
|
+
let stdinText = null;
|
|
1217
|
+
if (!process.stdin.isTTY) { try { stdinText = fs.readFileSync(0, 'utf8'); } catch { stdinText = null; } }
|
|
1218
|
+
|
|
1219
|
+
function makeReadInput() {
|
|
1220
|
+
return () => {
|
|
1221
|
+
const buf = Buffer.alloc(1);
|
|
1222
|
+
let line = '';
|
|
1223
|
+
while (true) {
|
|
1224
|
+
let n;
|
|
1225
|
+
try { n = fs.readSync(0, buf, 0, 1, null); }
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
if (err.code === 'EAGAIN') throw new NoahLangError('receive() could not read input (try piping input)', 0);
|
|
1228
|
+
throw err;
|
|
1229
|
+
}
|
|
1230
|
+
if (n === 0) break;
|
|
1231
|
+
const ch = buf.toString('utf8');
|
|
1232
|
+
if (ch === '\n') break;
|
|
1233
|
+
if (ch === '\r') continue;
|
|
1234
|
+
line += ch;
|
|
1235
|
+
}
|
|
1236
|
+
return line;
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function watchFile(p) {
|
|
1241
|
+
if (state.watched.has(p)) return;
|
|
1242
|
+
state.watched.add(p);
|
|
1243
|
+
const dir = path.dirname(p);
|
|
1244
|
+
if (state.dirWatchers.has(dir)) return;
|
|
1245
|
+
try {
|
|
1246
|
+
const w = fs.watch(dir, { persistent: true }, (eventType, filename) => {
|
|
1247
|
+
if (!filename) return;
|
|
1248
|
+
const full = path.join(dir, filename);
|
|
1249
|
+
if (!state.watched.has(full)) return;
|
|
1250
|
+
scheduleReload(full);
|
|
1251
|
+
});
|
|
1252
|
+
state.dirWatchers.set(dir, w);
|
|
1253
|
+
} catch (err) { console.error(`[watch] could not watch directory ${dir}: ${err.message}`); }
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function scheduleReload(changedPath) {
|
|
1257
|
+
if (state.reloadTimer) clearTimeout(state.reloadTimer);
|
|
1258
|
+
state.reloadTimer = setTimeout(() => {
|
|
1259
|
+
console.log(`[watch] ${path.basename(changedPath)} changed — reloading`);
|
|
1260
|
+
doReload();
|
|
1261
|
+
}, 120);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function closeAllWatchers() {
|
|
1265
|
+
for (const w of state.dirWatchers.values()) { try { w.close(); } catch {} }
|
|
1266
|
+
state.dirWatchers.clear();
|
|
1267
|
+
state.watched.clear();
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function doReload() {
|
|
1271
|
+
const old = state.interpreter;
|
|
1272
|
+
if (old) { try { old.notifyReload(); } catch {} }
|
|
1273
|
+
setTimeout(() => { runOnce(true); }, 150);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function runOnce(isReload) {
|
|
1277
|
+
if (state.interpreter) state.interpreter.shutdown();
|
|
1278
|
+
if (isReload) closeAllWatchers();
|
|
1279
|
+
|
|
1280
|
+
const interpreter = new Interpreter({
|
|
1281
|
+
stdin: stdinText,
|
|
1282
|
+
baseDir,
|
|
1283
|
+
onFileRead: (p) => { if (watch) watchFile(p); }
|
|
1284
|
+
});
|
|
1285
|
+
if (!stdinText) interpreter.readInput = makeReadInput();
|
|
1286
|
+
state.interpreter = interpreter;
|
|
1287
|
+
if (watch) watchFile(filePath);
|
|
1288
|
+
|
|
1289
|
+
try {
|
|
1290
|
+
const code = fs.readFileSync(filePath, 'utf8');
|
|
1291
|
+
interpreter.run(code);
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
if (error instanceof NoahLangError) {
|
|
1294
|
+
console.error(`NoahLang error: ${error.message}`);
|
|
1295
|
+
if (error.sourceLine) {
|
|
1296
|
+
const line = error.sourceLine;
|
|
1297
|
+
console.error(` > ${line.trim()}`);
|
|
1298
|
+
if (error.column != null) {
|
|
1299
|
+
const pad = ' '.repeat(Math.max(0, error.column - 1 + (line.length - line.trimStart().length)));
|
|
1300
|
+
console.error(` ${pad}^`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} else console.error(`NoahLang error: ${error.message}`);
|
|
1304
|
+
if (!watch) process.exitCode = 1;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
runOnce(false);
|
|
1309
|
+
if (watch) console.log(`[watch] watching ${file} (Ctrl+C to stop)`);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
module.exports = {
|
|
1313
|
+
Interpreter,
|
|
1314
|
+
NoahLangError,
|
|
1315
|
+
Environment,
|
|
1316
|
+
removeComment,
|
|
1317
|
+
tokenizeExpression,
|
|
1318
|
+
stringify
|
|
1319
|
+
};
|