@chestnutlabs/gcode-parser 0.17.0 → 0.19.0
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/dist/parse.d.ts +25 -0
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +88 -2
- package/dist/rs274-flow.d.ts +69 -0
- package/dist/rs274-flow.d.ts.map +1 -0
- package/dist/rs274-flow.js +702 -0
- package/dist/rs274.d.ts +107 -0
- package/dist/rs274.d.ts.map +1 -0
- package/dist/rs274.js +723 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +5 -0
- package/package.json +5 -5
package/dist/rs274.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* RS274NGC parametric programs — Phase 1: parameters + expressions (DD-017, phase 7 of #189).
|
|
3
|
+
*
|
|
4
|
+
* A pure, self-contained interpreter for the RS274NGC *programming* layer that FDM slicers never use:
|
|
5
|
+
* numbered/named parameters (`#100`, `#<name>`, `#<_global>`), assignment (`#n = <expr>`), and bracket
|
|
6
|
+
* expressions (`[ ... ]`) usable wherever a numeric word value is expected (`X[#1+2]`, `F[#feed]`).
|
|
7
|
+
*
|
|
8
|
+
* SCOPE (Phase 1): parameters + expressions ONLY. O-word control flow (`if`/`while`/`sub`/`call`) is
|
|
9
|
+
* Phase 2 — this module executes straight-line, in source order. There is **no I/O and no `eval`**: a
|
|
10
|
+
* fixed-grammar recursive-descent evaluator over `f64`, with a depth guard, so a hostile program can
|
|
11
|
+
* waste only bounded CPU (DD-017 §4.5). Malformed input never throws out of the parse — the offending
|
|
12
|
+
* word is dropped with a disclosure and parsing continues (DD-017 §4.3/§4.6).
|
|
13
|
+
*
|
|
14
|
+
* Semantics follow RS274NGC / LinuxCNC *behaviour* (NIST IR 6556; behavioural parity, no spec text
|
|
15
|
+
* copied — RR-004 §6). Notably: trig is in **degrees**; `MOD` is floored (LinuxCNC, sign of divisor);
|
|
16
|
+
* `EQ`/`NE` compare within a small tolerance; `FIX` = floor, `FUP` = ceil; booleans are `1`/`0`.
|
|
17
|
+
*
|
|
18
|
+
* This module is imported only by the parser engine and depends on nothing outside the package.
|
|
19
|
+
*/
|
|
20
|
+
/** Warning codes surfaced to the IR (DD-017 §4.6). */
|
|
21
|
+
export const RS274_WARN = {
|
|
22
|
+
uninitializedParam: 'rs274-uninitialized-param',
|
|
23
|
+
badExpression: 'rs274-bad-expression',
|
|
24
|
+
nonFiniteValue: 'rs274-non-finite-value',
|
|
25
|
+
unsupportedSysParam: 'rs274-unsupported-sysparam',
|
|
26
|
+
paramLimit: 'rs274-param-limit'
|
|
27
|
+
};
|
|
28
|
+
const MAX_EXPR_DEPTH = 64; // recursive-descent nesting guard for adversarial `[[[[…]]]]`
|
|
29
|
+
const MAX_NAMED_PARAMS = 5000; // bound the named-parameter store
|
|
30
|
+
const NUMBERED_LEN = 5400; // `#1`..`#5399` (index 0 unused); `#5400`+ are system params
|
|
31
|
+
const EQ_TOLERANCE = 1e-4; // LinuxCNC compares EQ/NE within a small tolerance
|
|
32
|
+
const DEG = Math.PI / 180;
|
|
33
|
+
/** A thrown control signal used internally to abort a malformed expression; never escapes the module. */
|
|
34
|
+
class Rs274EvalError extends Error {
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* True when a line's code section (comment already stripped) uses a parametric construct. A cheap
|
|
38
|
+
* character scan — the parser runs this per line so a non-parametric line (every FDM line) takes the
|
|
39
|
+
* untouched fast path and stays byte-identical.
|
|
40
|
+
*/
|
|
41
|
+
export function lineUsesParametric(codeSection) {
|
|
42
|
+
return codeSection.indexOf('#') !== -1 || codeSection.indexOf('[') !== -1;
|
|
43
|
+
}
|
|
44
|
+
/** Strip RS274NGC `( … )` comments from a code section (they are comments, never expressions). */
|
|
45
|
+
export function stripParenComments(s) {
|
|
46
|
+
let out = '';
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (let i = 0; i < s.length; i++) {
|
|
49
|
+
const c = s[i];
|
|
50
|
+
if (c === '(')
|
|
51
|
+
depth++;
|
|
52
|
+
else if (c === ')') {
|
|
53
|
+
if (depth > 0)
|
|
54
|
+
depth--;
|
|
55
|
+
}
|
|
56
|
+
else if (depth === 0)
|
|
57
|
+
out += c;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const LOCAL_NUMBERED_MAX = 30; // `#1`..`#30` are call-local (args); `#31`+ are global (LinuxCNC)
|
|
62
|
+
/**
|
|
63
|
+
* The parameter store + expression evaluator for one program. A scope STACK (DD-017 Phase 3): the base
|
|
64
|
+
* scope is the main program; each subroutine `call` pushes a frame (`#1`–`#30` args + `#<local>` names)
|
|
65
|
+
* and `return`/`endsub` pops it. `#31`+ numbered and `#<_global>` names are shared across frames.
|
|
66
|
+
* Numbered/named params default to `0` on read-before-write (with a one-time disclosure). A read-only
|
|
67
|
+
* allow-list resolves a few system parameters (current position) from live engine state; any other
|
|
68
|
+
* system parameter reads `0` and is disclosed. With no subroutines the base scope reduces to the single
|
|
69
|
+
* flat store of Phases 1–2 (byte-identical).
|
|
70
|
+
*/
|
|
71
|
+
export class Rs274Context {
|
|
72
|
+
warn;
|
|
73
|
+
position;
|
|
74
|
+
// Global numbered store `#31`..`#5399` (indices ≤30 live per-scope; slots 1..30 here are unused).
|
|
75
|
+
numbered = new Float64Array(NUMBERED_LEN);
|
|
76
|
+
// 1 = the slot was assigned. Separate from the value so a legitimately computed NaN/Inf (e.g.
|
|
77
|
+
// `#31 = SQRT[-1]`) is distinguishable from "never written" (which reads 0 + discloses).
|
|
78
|
+
numberedWritten = new Uint8Array(NUMBERED_LEN);
|
|
79
|
+
globalNamed = new Map(); // underscore-named `#<_global>`
|
|
80
|
+
// The scope stack. The base scope (index 0) is the main program; `call` pushes, `return`/endsub pops.
|
|
81
|
+
scopes = [
|
|
82
|
+
{
|
|
83
|
+
localNumbered: new Float64Array(LOCAL_NUMBERED_MAX + 1),
|
|
84
|
+
localWritten: new Uint8Array(LOCAL_NUMBERED_MAX + 1),
|
|
85
|
+
named: new Map()
|
|
86
|
+
}
|
|
87
|
+
];
|
|
88
|
+
uninitWarned = false;
|
|
89
|
+
paramLimitWarned = false;
|
|
90
|
+
/** True once any parametric construct was executed — drives the `parametricProgram` capability. */
|
|
91
|
+
used = false;
|
|
92
|
+
/**
|
|
93
|
+
* True once execution was degraded relative to a clean run: a hit resource limit (iteration cap),
|
|
94
|
+
* an unbalanced/unsupported O-word, or a malformed control expression (DD-017 §4.6). The
|
|
95
|
+
* O-word flow interpreter (Phase 2) sets this; it downgrades `parametricProgram` from `known` to
|
|
96
|
+
* `approximated` (still disclosed via a specific warning — never silently wrong).
|
|
97
|
+
*/
|
|
98
|
+
degraded = false;
|
|
99
|
+
constructor(warn, position) {
|
|
100
|
+
this.warn = warn;
|
|
101
|
+
this.position = position;
|
|
102
|
+
}
|
|
103
|
+
warnUninit(srcByte) {
|
|
104
|
+
if (this.uninitWarned)
|
|
105
|
+
return;
|
|
106
|
+
this.uninitWarned = true;
|
|
107
|
+
this.warn(RS274_WARN.uninitializedParam, 'read of an uninitialized parameter (RS274NGC default 0)', srcByte);
|
|
108
|
+
}
|
|
109
|
+
get scope() {
|
|
110
|
+
return this.scopes[this.scopes.length - 1];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Push a fresh call-frame scope with `args` bound to `#1`..`#30` (DD-017 Phase 3 `call`). Args beyond
|
|
114
|
+
* 30 are ignored (RS274NGC passes at most 30). Balanced by `popScope` on `return`/`endsub`.
|
|
115
|
+
*/
|
|
116
|
+
pushScope(args) {
|
|
117
|
+
const localNumbered = new Float64Array(LOCAL_NUMBERED_MAX + 1);
|
|
118
|
+
const localWritten = new Uint8Array(LOCAL_NUMBERED_MAX + 1);
|
|
119
|
+
for (let i = 0; i < args.length && i < LOCAL_NUMBERED_MAX; i++) {
|
|
120
|
+
localNumbered[i + 1] = args[i];
|
|
121
|
+
localWritten[i + 1] = 1;
|
|
122
|
+
}
|
|
123
|
+
this.scopes.push({ localNumbered, localWritten, named: new Map() });
|
|
124
|
+
}
|
|
125
|
+
/** Pop the current call-frame scope (never the base/main scope). */
|
|
126
|
+
popScope() {
|
|
127
|
+
if (this.scopes.length > 1)
|
|
128
|
+
this.scopes.pop();
|
|
129
|
+
}
|
|
130
|
+
readNumbered(n, srcByte) {
|
|
131
|
+
const idx = Math.round(n);
|
|
132
|
+
if (idx >= NUMBERED_LEN)
|
|
133
|
+
return this.readSystem(idx, srcByte);
|
|
134
|
+
if (idx < 1) {
|
|
135
|
+
this.warnUninit(srcByte);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
if (idx <= LOCAL_NUMBERED_MAX) {
|
|
139
|
+
const s = this.scope;
|
|
140
|
+
if (s.localWritten[idx] === 0) {
|
|
141
|
+
this.warnUninit(srcByte);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
return s.localNumbered[idx];
|
|
145
|
+
}
|
|
146
|
+
if (this.numberedWritten[idx] === 0) {
|
|
147
|
+
this.warnUninit(srcByte);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
return this.numbered[idx];
|
|
151
|
+
}
|
|
152
|
+
readSystem(n, srcByte) {
|
|
153
|
+
// Read-only allow-list (DD-017 §4.2): current commanded position, origin-relative to match how the
|
|
154
|
+
// engine tracks it. Anything else is honestly unsupported → 0 + disclosure.
|
|
155
|
+
const p = this.position();
|
|
156
|
+
if (n === 5420)
|
|
157
|
+
return p.x;
|
|
158
|
+
if (n === 5421)
|
|
159
|
+
return p.y;
|
|
160
|
+
if (n === 5422)
|
|
161
|
+
return p.z;
|
|
162
|
+
this.warn(RS274_WARN.unsupportedSysParam, `system parameter #${n} is not supported; read as 0`, srcByte);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
/** The map a named parameter lives in: underscore-prefixed (`#<_global>`) → global, else scope-local. */
|
|
166
|
+
namedMap(name) {
|
|
167
|
+
return name[0] === '_' ? this.globalNamed : this.scope.named;
|
|
168
|
+
}
|
|
169
|
+
readNamed(name, srcByte) {
|
|
170
|
+
const v = this.namedMap(name).get(name);
|
|
171
|
+
if (v === undefined) {
|
|
172
|
+
this.warnUninit(srcByte);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
writeNumbered(n, value) {
|
|
178
|
+
const idx = Math.round(n);
|
|
179
|
+
if (idx < 1 || idx >= NUMBERED_LEN)
|
|
180
|
+
return;
|
|
181
|
+
if (idx <= LOCAL_NUMBERED_MAX) {
|
|
182
|
+
const s = this.scope;
|
|
183
|
+
s.localNumbered[idx] = value;
|
|
184
|
+
s.localWritten[idx] = 1;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.numbered[idx] = value;
|
|
188
|
+
this.numberedWritten[idx] = 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
writeNamed(name, value) {
|
|
192
|
+
const map = this.namedMap(name);
|
|
193
|
+
if (map.has(name) || map.size < MAX_NAMED_PARAMS)
|
|
194
|
+
map.set(name, value);
|
|
195
|
+
else if (!this.paramLimitWarned) {
|
|
196
|
+
this.paramLimitWarned = true;
|
|
197
|
+
this.warn(RS274_WARN.paramLimit, `named-parameter limit (${MAX_NAMED_PARAMS}) reached; further new names ignored`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** `EXISTS[#<name>]` → 1 if the named parameter has been assigned in its scope, else 0. */
|
|
201
|
+
exists(name) {
|
|
202
|
+
return this.namedMap(name).has(name) ? 1 : 0;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Lex one RAW parametric line — either apply a `#… = …` assignment, or resolve every word value into a
|
|
206
|
+
* plain-number `Cmd` for the engine's existing dispatch. Strips `( … )` comments BEFORE the `;` comment
|
|
207
|
+
* (RS274NGC order, so a `;` inside parens doesn't truncate the line). Malformed constructs are dropped +
|
|
208
|
+
* disclosed; the line still parses as far as it can.
|
|
209
|
+
*/
|
|
210
|
+
lexLine(rawLine, srcByte) {
|
|
211
|
+
const body = stripParenComments(rawLine).split(';')[0].trim();
|
|
212
|
+
if (body === '')
|
|
213
|
+
return { kind: 'empty' };
|
|
214
|
+
// The `#`/`[` was only inside a comment — this is a plain line; let the normal lexer own it.
|
|
215
|
+
if (!lineUsesParametric(body))
|
|
216
|
+
return { kind: 'plain' };
|
|
217
|
+
this.used = true;
|
|
218
|
+
// Assignment: `[N<line>] #<param> = <expr>` (a leading N line-number is allowed, LinuxCNC).
|
|
219
|
+
const assignBody = body.replace(/^n\d+\s*/i, '');
|
|
220
|
+
if (assignBody[0] === '#') {
|
|
221
|
+
const eq = topLevelEqIndex(assignBody);
|
|
222
|
+
if (eq !== -1) {
|
|
223
|
+
try {
|
|
224
|
+
const p = new Evaluator(assignBody, this, srcByte);
|
|
225
|
+
p.skipWs();
|
|
226
|
+
const target = p.readParamTarget(); // where to store
|
|
227
|
+
p.skipWs();
|
|
228
|
+
p.expect('=');
|
|
229
|
+
const value = p.parseExpr(0);
|
|
230
|
+
p.skipWsToEnd(); // trailing garbage → malformed
|
|
231
|
+
target(value);
|
|
232
|
+
return { kind: 'assign' };
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
if (e instanceof Rs274EvalError) {
|
|
236
|
+
this.warn(RS274_WARN.badExpression, `malformed parameter assignment dropped: ${e.message}`, srcByte);
|
|
237
|
+
return { kind: 'assign' }; // consumed the line; nothing stored
|
|
238
|
+
}
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Motion/command line: walk words, resolving `#ref` / `[expr]` values to numbers.
|
|
244
|
+
const codes = [];
|
|
245
|
+
const params = {};
|
|
246
|
+
let sawCommand = false;
|
|
247
|
+
const ev = new Evaluator(body, this, srcByte);
|
|
248
|
+
ev.skipWs();
|
|
249
|
+
while (!ev.atEnd()) {
|
|
250
|
+
const key = ev.readWordLetter();
|
|
251
|
+
if (key === null) {
|
|
252
|
+
ev.dropToNextLetter(); // skip an unexpected char, keep going
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let value;
|
|
256
|
+
try {
|
|
257
|
+
value = ev.readWordValue();
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
if (e instanceof Rs274EvalError) {
|
|
261
|
+
this.warn(RS274_WARN.badExpression, `malformed word value for '${key.toUpperCase()}' dropped`, srcByte);
|
|
262
|
+
ev.dropToNextLetter();
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
if (!Number.isFinite(value)) {
|
|
268
|
+
// A well-formed expression that evaluated to NaN/Infinity (e.g. `X[1/0]`, `X[SQRT[-1]]`) — the
|
|
269
|
+
// word is dropped, but disclosed (DD-017 §4.6), never silent.
|
|
270
|
+
this.warn(RS274_WARN.nonFiniteValue, `word '${key.toUpperCase()}' evaluated to a non-finite value; dropped`, srcByte);
|
|
271
|
+
ev.skipWs();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (key === 'g' || key === 'm') {
|
|
275
|
+
codes.push(`${key}${value}`);
|
|
276
|
+
sawCommand = true;
|
|
277
|
+
}
|
|
278
|
+
else if (key === 't' && !sawCommand) {
|
|
279
|
+
codes.push(`t${value}`);
|
|
280
|
+
sawCommand = true;
|
|
281
|
+
}
|
|
282
|
+
else if (key === 'n') {
|
|
283
|
+
// line number — ignored
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
params[key] = value;
|
|
287
|
+
}
|
|
288
|
+
ev.skipWs();
|
|
289
|
+
}
|
|
290
|
+
return { kind: 'cmd', codes, params };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The `parametricProgram` capability tier (DD-017 §4.6). `unavailable` for a non-parametric file
|
|
294
|
+
* (all FDM); `known` when params/expressions/O-word flow ran deterministically within limits;
|
|
295
|
+
* `approximated` when execution was degraded (a hit iteration cap, an unbalanced/unsupported O-word,
|
|
296
|
+
* or a malformed control expression) — always paired with a specific disclosure, never silently wrong.
|
|
297
|
+
*/
|
|
298
|
+
parametricConfidence() {
|
|
299
|
+
if (!this.used)
|
|
300
|
+
return 'unavailable';
|
|
301
|
+
return this.degraded ? 'approximated' : 'known';
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Evaluate a standalone expression (an O-word condition `[#1 GT 2]` or a `repeat` count `[5]`) against
|
|
305
|
+
* the shared parameter store, for the Phase 2 flow interpreter. Returns the numeric result, or `null`
|
|
306
|
+
* when the expression is malformed or evaluates to a non-finite value — disclosed either way (the
|
|
307
|
+
* caller treats `null` as false / zero iterations). Accepts a bracketed expression, a bare `#ref`, or a
|
|
308
|
+
* literal; trailing garbage is a malformed expression. No `eval`, depth-guarded like every other path.
|
|
309
|
+
*/
|
|
310
|
+
evalExpression(text, srcByte) {
|
|
311
|
+
this.used = true;
|
|
312
|
+
try {
|
|
313
|
+
const ev = new Evaluator(text, this, srcByte);
|
|
314
|
+
ev.skipWs();
|
|
315
|
+
const v = ev.readWordValue(); // a leading `[expr]`, `#ref`, or number — exactly a condition/count
|
|
316
|
+
ev.skipWsToEnd();
|
|
317
|
+
if (!Number.isFinite(v)) {
|
|
318
|
+
this.warn(RS274_WARN.nonFiniteValue, 'control expression evaluated to a non-finite value', srcByte);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return v;
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
if (e instanceof Rs274EvalError) {
|
|
325
|
+
this.warn(RS274_WARN.badExpression, `malformed control expression dropped: ${e.message}`, srcByte);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// --- operand hooks used by the Evaluator ---
|
|
332
|
+
_readParam(kind, key, srcByte) {
|
|
333
|
+
return kind === 'numbered' ? this.readNumbered(key, srcByte) : this.readNamed(key, srcByte);
|
|
334
|
+
}
|
|
335
|
+
_writeParam(kind, key, value) {
|
|
336
|
+
if (kind === 'numbered')
|
|
337
|
+
this.writeNumbered(key, value);
|
|
338
|
+
else
|
|
339
|
+
this.writeNamed(key, value);
|
|
340
|
+
}
|
|
341
|
+
_exists(name) {
|
|
342
|
+
return this.exists(name);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** Index of a top-level `=` (not inside brackets), or -1. */
|
|
346
|
+
function topLevelEqIndex(s) {
|
|
347
|
+
let depth = 0;
|
|
348
|
+
for (let i = 0; i < s.length; i++) {
|
|
349
|
+
const c = s[i];
|
|
350
|
+
if (c === '[')
|
|
351
|
+
depth++;
|
|
352
|
+
else if (c === ']')
|
|
353
|
+
depth--;
|
|
354
|
+
else if (c === '=' && depth === 0)
|
|
355
|
+
return i;
|
|
356
|
+
}
|
|
357
|
+
return -1;
|
|
358
|
+
}
|
|
359
|
+
const FUNCS = new Set([
|
|
360
|
+
'abs',
|
|
361
|
+
'acos',
|
|
362
|
+
'asin',
|
|
363
|
+
'cos',
|
|
364
|
+
'sin',
|
|
365
|
+
'tan',
|
|
366
|
+
'exp',
|
|
367
|
+
'fix',
|
|
368
|
+
'fup',
|
|
369
|
+
'ln',
|
|
370
|
+
'round',
|
|
371
|
+
'sqrt',
|
|
372
|
+
'atan',
|
|
373
|
+
'exists'
|
|
374
|
+
]);
|
|
375
|
+
/** A cursor-based recursive-descent evaluator over one line's body. */
|
|
376
|
+
class Evaluator {
|
|
377
|
+
s;
|
|
378
|
+
ctx;
|
|
379
|
+
srcByte;
|
|
380
|
+
i = 0;
|
|
381
|
+
depth = 0;
|
|
382
|
+
constructor(s, ctx, srcByte) {
|
|
383
|
+
this.s = s;
|
|
384
|
+
this.ctx = ctx;
|
|
385
|
+
this.srcByte = srcByte;
|
|
386
|
+
}
|
|
387
|
+
atEnd() {
|
|
388
|
+
return this.i >= this.s.length;
|
|
389
|
+
}
|
|
390
|
+
skipWs() {
|
|
391
|
+
while (this.i < this.s.length && /\s/.test(this.s[this.i]))
|
|
392
|
+
this.i++;
|
|
393
|
+
}
|
|
394
|
+
skipWsToEnd() {
|
|
395
|
+
this.skipWs();
|
|
396
|
+
if (!this.atEnd())
|
|
397
|
+
throw new Rs274EvalError(`trailing '${this.s.slice(this.i)}'`);
|
|
398
|
+
}
|
|
399
|
+
expect(ch) {
|
|
400
|
+
if (this.s[this.i] !== ch)
|
|
401
|
+
throw new Rs274EvalError(`expected '${ch}'`);
|
|
402
|
+
this.i++;
|
|
403
|
+
}
|
|
404
|
+
dropToNextLetter() {
|
|
405
|
+
// Recover from a malformed word: advance to the next a-z letter (or end).
|
|
406
|
+
if (this.i < this.s.length)
|
|
407
|
+
this.i++;
|
|
408
|
+
while (this.i < this.s.length && !isLetter(this.s[this.i]))
|
|
409
|
+
this.i++;
|
|
410
|
+
}
|
|
411
|
+
/** Read a single command/axis letter (a–z), or null if the cursor isn't on a letter. */
|
|
412
|
+
readWordLetter() {
|
|
413
|
+
this.skipWs();
|
|
414
|
+
const c = this.s[this.i];
|
|
415
|
+
if (c !== undefined && isLetter(c)) {
|
|
416
|
+
this.i++;
|
|
417
|
+
return c.toLowerCase();
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
/** Read a word's value: a `#`param-ref, a `[expr]`, or a numeric literal. */
|
|
422
|
+
readWordValue() {
|
|
423
|
+
this.skipWs();
|
|
424
|
+
const c = this.s[this.i];
|
|
425
|
+
if (c === '#')
|
|
426
|
+
return this.readParamRef();
|
|
427
|
+
if (c === '[')
|
|
428
|
+
return this.parseBracket();
|
|
429
|
+
return this.readNumber();
|
|
430
|
+
}
|
|
431
|
+
/** Read a parameter *reference* as an operand and return its value. Depth-guarded (bounds `##…` chains). */
|
|
432
|
+
readParamRef() {
|
|
433
|
+
if (++this.depth > MAX_EXPR_DEPTH)
|
|
434
|
+
throw new Rs274EvalError('reference nested too deep');
|
|
435
|
+
try {
|
|
436
|
+
this.expect('#');
|
|
437
|
+
this.skipWs();
|
|
438
|
+
const c = this.s[this.i];
|
|
439
|
+
if (c === '[')
|
|
440
|
+
return this.ctx._readParam('numbered', this.parseBracket(), this.srcByte); // `#[expr]`
|
|
441
|
+
if (c === '#')
|
|
442
|
+
return this.ctx._readParam('numbered', this.readParamRef(), this.srcByte); // `##ref`
|
|
443
|
+
if (c === '<')
|
|
444
|
+
return this.ctx._readParam('named', this.readNamedRef(), this.srcByte);
|
|
445
|
+
return this.ctx._readParam('numbered', this.readNumber(), this.srcByte); // `#N`
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
this.depth--;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** Read the assignment *target* and return a setter that stores a value there. */
|
|
452
|
+
readParamTarget() {
|
|
453
|
+
this.expect('#');
|
|
454
|
+
this.skipWs();
|
|
455
|
+
const c = this.s[this.i];
|
|
456
|
+
if (c === '[') {
|
|
457
|
+
const n = this.parseBracket();
|
|
458
|
+
return (v) => this.ctx._writeParam('numbered', n, v);
|
|
459
|
+
}
|
|
460
|
+
if (c === '#') {
|
|
461
|
+
const n = this.readParamRef();
|
|
462
|
+
return (v) => this.ctx._writeParam('numbered', n, v);
|
|
463
|
+
}
|
|
464
|
+
if (c === '<') {
|
|
465
|
+
const name = this.readNamedRef();
|
|
466
|
+
return (v) => this.ctx._writeParam('named', name, v);
|
|
467
|
+
}
|
|
468
|
+
const n = this.readNumber();
|
|
469
|
+
return (v) => this.ctx._writeParam('numbered', n, v);
|
|
470
|
+
}
|
|
471
|
+
readNamedRef() {
|
|
472
|
+
this.expect('<');
|
|
473
|
+
let name = '';
|
|
474
|
+
while (this.i < this.s.length && this.s[this.i] !== '>')
|
|
475
|
+
name += this.s[this.i++];
|
|
476
|
+
this.expect('>');
|
|
477
|
+
return name.trim().toLowerCase();
|
|
478
|
+
}
|
|
479
|
+
readNumber() {
|
|
480
|
+
this.skipWs();
|
|
481
|
+
const start = this.i;
|
|
482
|
+
if (this.s[this.i] === '+' || this.s[this.i] === '-')
|
|
483
|
+
this.i++;
|
|
484
|
+
let sawDigit = false;
|
|
485
|
+
while (this.i < this.s.length && /[0-9]/.test(this.s[this.i])) {
|
|
486
|
+
this.i++;
|
|
487
|
+
sawDigit = true;
|
|
488
|
+
}
|
|
489
|
+
if (this.s[this.i] === '.') {
|
|
490
|
+
this.i++;
|
|
491
|
+
while (this.i < this.s.length && /[0-9]/.test(this.s[this.i])) {
|
|
492
|
+
this.i++;
|
|
493
|
+
sawDigit = true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!sawDigit)
|
|
497
|
+
throw new Rs274EvalError(`expected a number at '${this.s.slice(start, start + 8)}'`);
|
|
498
|
+
return Number(this.s.slice(start, this.i));
|
|
499
|
+
}
|
|
500
|
+
parseBracket() {
|
|
501
|
+
if (++this.depth > MAX_EXPR_DEPTH)
|
|
502
|
+
throw new Rs274EvalError('expression nested too deep');
|
|
503
|
+
this.expect('[');
|
|
504
|
+
const v = this.parseExpr(0);
|
|
505
|
+
this.skipWs();
|
|
506
|
+
this.expect(']');
|
|
507
|
+
this.depth--;
|
|
508
|
+
return v;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Precedence-climbing expression parser. RS274NGC precedence, tightest → loosest:
|
|
512
|
+
* `**` > `* / MOD` > `+ -` > comparisons > logical. Implemented as levels 0..4 (0 = loosest).
|
|
513
|
+
*/
|
|
514
|
+
parseExpr(level) {
|
|
515
|
+
if (level >= LEVELS.length)
|
|
516
|
+
return this.parseUnary();
|
|
517
|
+
let left = this.parseExpr(level + 1);
|
|
518
|
+
for (;;) {
|
|
519
|
+
this.skipWs();
|
|
520
|
+
const op = this.peekOperator(LEVELS[level]);
|
|
521
|
+
if (op === null)
|
|
522
|
+
return left;
|
|
523
|
+
this.i += op.length;
|
|
524
|
+
const right = this.parseExpr(level + 1);
|
|
525
|
+
left = applyBinary(op, left, right);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
parseUnary() {
|
|
529
|
+
this.skipWs();
|
|
530
|
+
const c = this.s[this.i];
|
|
531
|
+
if (c === '+' || c === '-') {
|
|
532
|
+
if (++this.depth > MAX_EXPR_DEPTH)
|
|
533
|
+
throw new Rs274EvalError('expression nested too deep'); // bounds `----…` chains
|
|
534
|
+
this.i++;
|
|
535
|
+
const v = c === '-' ? -this.parseUnary() : this.parseUnary();
|
|
536
|
+
this.depth--;
|
|
537
|
+
return v;
|
|
538
|
+
}
|
|
539
|
+
return this.parsePower();
|
|
540
|
+
}
|
|
541
|
+
/** `**` binds tighter than everything and is right-associative (`2**3**2` = `2**9`). */
|
|
542
|
+
parsePower() {
|
|
543
|
+
const base = this.parseAtom();
|
|
544
|
+
this.skipWs();
|
|
545
|
+
if (this.s[this.i] === '*' && this.s[this.i + 1] === '*') {
|
|
546
|
+
this.i += 2;
|
|
547
|
+
const exp = this.parseUnary(); // right-assoc + allows `2 ** -1`
|
|
548
|
+
return Math.pow(base, exp);
|
|
549
|
+
}
|
|
550
|
+
return base;
|
|
551
|
+
}
|
|
552
|
+
parseAtom() {
|
|
553
|
+
this.skipWs();
|
|
554
|
+
const c = this.s[this.i];
|
|
555
|
+
if (c === '#')
|
|
556
|
+
return this.readParamRef();
|
|
557
|
+
if (c === '[')
|
|
558
|
+
return this.parseBracket();
|
|
559
|
+
if (isLetter(c)) {
|
|
560
|
+
const name = this.readIdentifier();
|
|
561
|
+
if (FUNCS.has(name))
|
|
562
|
+
return this.callFunction(name);
|
|
563
|
+
throw new Rs274EvalError(`unexpected '${name}'`);
|
|
564
|
+
}
|
|
565
|
+
return this.readNumber();
|
|
566
|
+
}
|
|
567
|
+
readIdentifier() {
|
|
568
|
+
let id = '';
|
|
569
|
+
while (this.i < this.s.length && isLetter(this.s[this.i]))
|
|
570
|
+
id += this.s[this.i++];
|
|
571
|
+
return id.toLowerCase();
|
|
572
|
+
}
|
|
573
|
+
callFunction(name) {
|
|
574
|
+
if (name === 'exists')
|
|
575
|
+
return this.parseExistsArg();
|
|
576
|
+
if (name === 'atan') {
|
|
577
|
+
// `ATAN[y]/[x]` — degrees.
|
|
578
|
+
const y = this.parseBracket();
|
|
579
|
+
this.skipWs();
|
|
580
|
+
this.expect('/');
|
|
581
|
+
const x = this.parseBracket();
|
|
582
|
+
return Math.atan2(y, x) / DEG;
|
|
583
|
+
}
|
|
584
|
+
return applyFunction(name, this.parseBracket());
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* `EXISTS[#<name>]` — test a named parameter's existence WITHOUT reading it (no uninitialized read).
|
|
588
|
+
* RS274NGC/LinuxCNC defines EXISTS only for a named parameter; any other argument is not an existence
|
|
589
|
+
* query and returns 0 (its content is skipped, not evaluated — so it triggers no read/side effect).
|
|
590
|
+
*/
|
|
591
|
+
parseExistsArg() {
|
|
592
|
+
if (++this.depth > MAX_EXPR_DEPTH)
|
|
593
|
+
throw new Rs274EvalError('expression nested too deep');
|
|
594
|
+
this.expect('[');
|
|
595
|
+
this.skipWs();
|
|
596
|
+
let result;
|
|
597
|
+
if (this.s[this.i] === '#' && this.s[this.i + 1] === '<') {
|
|
598
|
+
this.i++; // consume '#'
|
|
599
|
+
result = this.ctx._exists(this.readNamedRef());
|
|
600
|
+
this.skipWs();
|
|
601
|
+
this.expect(']');
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
this.skipToCloseBracket();
|
|
605
|
+
result = 0;
|
|
606
|
+
}
|
|
607
|
+
this.depth--;
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
/** Consume up to and including the matching `]` (for a non-evaluated EXISTS argument). */
|
|
611
|
+
skipToCloseBracket() {
|
|
612
|
+
let d = 1;
|
|
613
|
+
while (this.i < this.s.length && d > 0) {
|
|
614
|
+
const c = this.s[this.i++];
|
|
615
|
+
if (c === '[')
|
|
616
|
+
d++;
|
|
617
|
+
else if (c === ']')
|
|
618
|
+
d--;
|
|
619
|
+
}
|
|
620
|
+
if (d !== 0)
|
|
621
|
+
throw new Rs274EvalError("unbalanced '[' in EXISTS");
|
|
622
|
+
}
|
|
623
|
+
/** Peek an operator belonging to `ops`; return its lexeme or null. Word ops must be word-bounded. */
|
|
624
|
+
peekOperator(ops) {
|
|
625
|
+
for (const op of ops) {
|
|
626
|
+
if (op === '**')
|
|
627
|
+
continue; // power handled in parsePower
|
|
628
|
+
const isWord = /^[a-z]+$/.test(op);
|
|
629
|
+
if (isWord) {
|
|
630
|
+
const seg = this.s.slice(this.i, this.i + op.length).toLowerCase();
|
|
631
|
+
if (seg === op) {
|
|
632
|
+
const after = this.s[this.i + op.length];
|
|
633
|
+
if (after === undefined || !isLetter(after))
|
|
634
|
+
return op;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else if (this.s.startsWith(op, this.i)) {
|
|
638
|
+
if (op === '*' && this.s[this.i + 1] === '*')
|
|
639
|
+
continue; // don't eat `**` as `*`
|
|
640
|
+
return op;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Operator levels, loosest (0) → tightest. `**` lives at the power level handled specially.
|
|
647
|
+
const LEVELS = [
|
|
648
|
+
['and', 'or', 'xor'],
|
|
649
|
+
['eq', 'ne', 'gt', 'ge', 'lt', 'le'],
|
|
650
|
+
['+', '-'],
|
|
651
|
+
['*', '/', 'mod']
|
|
652
|
+
];
|
|
653
|
+
function isLetter(c) {
|
|
654
|
+
if (c === undefined)
|
|
655
|
+
return false;
|
|
656
|
+
const cc = c.charCodeAt(0);
|
|
657
|
+
return (cc >= 65 && cc <= 90) || (cc >= 97 && cc <= 122);
|
|
658
|
+
}
|
|
659
|
+
const bool = (b) => (b ? 1 : 0);
|
|
660
|
+
function applyBinary(op, a, b) {
|
|
661
|
+
switch (op) {
|
|
662
|
+
case '+':
|
|
663
|
+
return a + b;
|
|
664
|
+
case '-':
|
|
665
|
+
return a - b;
|
|
666
|
+
case '*':
|
|
667
|
+
return a * b;
|
|
668
|
+
case '/':
|
|
669
|
+
return a / b;
|
|
670
|
+
case 'mod':
|
|
671
|
+
return a - b * Math.floor(a / b); // LinuxCNC: floored, sign of divisor
|
|
672
|
+
case 'and':
|
|
673
|
+
return bool(a !== 0 && b !== 0);
|
|
674
|
+
case 'or':
|
|
675
|
+
return bool(a !== 0 || b !== 0);
|
|
676
|
+
case 'xor':
|
|
677
|
+
return bool((a !== 0) !== (b !== 0));
|
|
678
|
+
case 'eq':
|
|
679
|
+
return bool(Math.abs(a - b) < EQ_TOLERANCE);
|
|
680
|
+
case 'ne':
|
|
681
|
+
return bool(Math.abs(a - b) >= EQ_TOLERANCE);
|
|
682
|
+
case 'gt':
|
|
683
|
+
return bool(a > b);
|
|
684
|
+
case 'ge':
|
|
685
|
+
return bool(a >= b);
|
|
686
|
+
case 'lt':
|
|
687
|
+
return bool(a < b);
|
|
688
|
+
case 'le':
|
|
689
|
+
return bool(a <= b);
|
|
690
|
+
default:
|
|
691
|
+
throw new Rs274EvalError(`unknown operator '${op}'`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function applyFunction(name, x) {
|
|
695
|
+
switch (name) {
|
|
696
|
+
case 'abs':
|
|
697
|
+
return Math.abs(x);
|
|
698
|
+
case 'acos':
|
|
699
|
+
return Math.acos(x) / DEG;
|
|
700
|
+
case 'asin':
|
|
701
|
+
return Math.asin(x) / DEG;
|
|
702
|
+
case 'cos':
|
|
703
|
+
return Math.cos(x * DEG);
|
|
704
|
+
case 'sin':
|
|
705
|
+
return Math.sin(x * DEG);
|
|
706
|
+
case 'tan':
|
|
707
|
+
return Math.tan(x * DEG);
|
|
708
|
+
case 'exp':
|
|
709
|
+
return Math.exp(x);
|
|
710
|
+
case 'fix':
|
|
711
|
+
return Math.floor(x); // round toward -inf
|
|
712
|
+
case 'fup':
|
|
713
|
+
return Math.ceil(x); // round toward +inf
|
|
714
|
+
case 'ln':
|
|
715
|
+
return Math.log(x);
|
|
716
|
+
case 'round':
|
|
717
|
+
return Math.round(x);
|
|
718
|
+
case 'sqrt':
|
|
719
|
+
return Math.sqrt(x);
|
|
720
|
+
default:
|
|
721
|
+
throw new Rs274EvalError(`unknown function '${name}'`);
|
|
722
|
+
}
|
|
723
|
+
}
|