bare-script 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/lib/parser.js ADDED
@@ -0,0 +1,725 @@
1
+ // Licensed under the MIT License
2
+ // https://github.com/craigahobbs/bare-script/blob/main/LICENSE
3
+
4
+ /** @module lib/parser */
5
+
6
+
7
+ // BareScript regex
8
+ const rScriptLineSplit = /\r?\n/;
9
+ const rScriptContinuation = /\\\s*$/;
10
+ const rScriptComment = /^\s*(?:#.*)?$/;
11
+ const rScriptAssignment = /^\s*(?<name>[A-Za-z_]\w*)\s*=\s*(?<expr>.+)$/;
12
+ const rScriptFunctionBegin =
13
+ /^\s*(?:(?<async>async)\s+)?function\s+(?<name>[A-Za-z_]\w*)\s*\(\s*(?<args>[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)?\s*\)\s*$/;
14
+ const rScriptFunctionArgSplit = /\s*,\s*/;
15
+ const rScriptFunctionEnd = /^\s*endfunction\s*$/;
16
+ const rScriptLabel = /^\s*(?<name>[A-Za-z_]\w*)\s*:\s*$/;
17
+ const rScriptJump = /^(?<jump>\s*(?:jump|jumpif\s*\((?<expr>.+)\)))\s+(?<name>[A-Za-z_]\w*)\s*$/;
18
+ const rScriptReturn = /^(?<return>\s*return(?:\s+(?<expr>.+))?)\s*$/;
19
+ const rScriptInclude = /^\s*include\s+(?<delim>')(?<url>(?:\\'|[^'])*)'\s*$/;
20
+ const rScriptIncludeSystem = /^\s*include\s+(?<delim><)(?<url>[^>]*)>\s*$/;
21
+ const rScriptIfBegin = /^\s*if\s+(?<expr>.+)\s*:\s*$/;
22
+ const rScriptIfElseIf = /^\s*elif\s+(?<expr>.+)\s*:\s*$/;
23
+ const rScriptIfElse = /^\s*else\s*:\s*$/;
24
+ const rScriptIfEnd = /^\s*endif\s*$/;
25
+ const rScriptForBegin = /^\s*for\s+(?<value>[A-Za-z_]\w*)(?:\s*,\s*(?<index>[A-Za-z_]\w*))?\s+in\s+(?<values>.+)\s*:\s*$/;
26
+ const rScriptForEnd = /^\s*endfor\s*$/;
27
+ const rScriptWhileBegin = /^\s*while\s+(?<expr>.+)\s*:\s*$/;
28
+ const rScriptWhileEnd = /^\s*endwhile\s*$/;
29
+ const rScriptBreak = /^\s*break\s*$/;
30
+ const rScriptContinue = /^\s*continue\s*$/;
31
+
32
+
33
+ /**
34
+ * Parse a BareScript script
35
+ *
36
+ * @param {string|string[]} scriptText - The [script text]{@link https://craigahobbs.github.io/bare-script/language/}
37
+ * @param {number} [startLineNumber = 1] - The script's starting line number
38
+ * @returns {Object} The [BareScript model]{@link https://craigahobbs.github.io/bare-script/model/#var.vName='BareScript'}
39
+ * @throws [BareScriptParserError]{@link module:lib/parser.BareScriptParserError}
40
+ */
41
+ export function parseScript(scriptText, startLineNumber = 1) {
42
+ const script = {'statements': []};
43
+
44
+ // Line-split all script text
45
+ const lines = [];
46
+ if (typeof scriptText === 'string') {
47
+ lines.push(...scriptText.split(rScriptLineSplit));
48
+ } else {
49
+ for (const scriptTextPart of scriptText) {
50
+ lines.push(...scriptTextPart.split(rScriptLineSplit));
51
+ }
52
+ }
53
+
54
+ // Process each line
55
+ const lineContinuation = [];
56
+ let functionDef = null;
57
+ const labelDefs = [];
58
+ let labelIndex = 0;
59
+ let ixLine;
60
+ for (const [ixLinePart, linePart] of lines.entries()) {
61
+ const statements = (functionDef !== null ? functionDef.function.statements : script.statements);
62
+
63
+ // Set the line index
64
+ const isContinued = (lineContinuation.length !== 0);
65
+ if (!isContinued) {
66
+ ixLine = ixLinePart;
67
+ }
68
+
69
+ // Line continuation?
70
+ const linePartNoContinuation = linePart.replace(rScriptContinuation, '');
71
+ if (linePart !== linePartNoContinuation) {
72
+ lineContinuation.push(lineContinuation.length === 0 ? linePartNoContinuation.trimEnd() : linePartNoContinuation.trim());
73
+ continue;
74
+ } else if (isContinued) {
75
+ lineContinuation.push(linePartNoContinuation.trim());
76
+ }
77
+
78
+ // Join the continued script lines, if necessary
79
+ let line;
80
+ if (isContinued) {
81
+ line = lineContinuation.join(' ');
82
+ lineContinuation.length = 0;
83
+ } else {
84
+ line = linePart;
85
+ }
86
+
87
+ // Comment?
88
+ if (line.match(rScriptComment) !== null) {
89
+ continue;
90
+ }
91
+
92
+ // Assignment?
93
+ const matchAssignment = line.match(rScriptAssignment);
94
+ if (matchAssignment !== null) {
95
+ try {
96
+ const exprStatement = {
97
+ 'expr': {
98
+ 'name': matchAssignment.groups.name,
99
+ 'expr': parseExpression(matchAssignment.groups.expr)
100
+ }
101
+ };
102
+ statements.push(exprStatement);
103
+ continue;
104
+ } catch (error) {
105
+ const columnNumber = line.length - matchAssignment.groups.expr.length + error.columnNumber;
106
+ throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine);
107
+ }
108
+ }
109
+
110
+ // Function definition begin?
111
+ const matchFunctionBegin = line.match(rScriptFunctionBegin);
112
+ if (matchFunctionBegin !== null) {
113
+ // Nested function definitions are not allowed
114
+ if (functionDef !== null) {
115
+ throw new BareScriptParserError('Nested function definition', line, 1, startLineNumber + ixLine);
116
+ }
117
+
118
+ // Add the function definition statement
119
+ functionDef = {
120
+ 'function': {
121
+ 'name': matchFunctionBegin.groups.name,
122
+ 'args': typeof matchFunctionBegin.groups.args !== 'undefined'
123
+ ? matchFunctionBegin.groups.args.split(rScriptFunctionArgSplit) : [],
124
+ 'statements': []
125
+ }
126
+ };
127
+ if (matchFunctionBegin.groups.async === 'async') {
128
+ functionDef.function.async = true;
129
+ }
130
+ statements.push(functionDef);
131
+ continue;
132
+ }
133
+
134
+ // Function definition end?
135
+ const matchFunctionEnd = line.match(rScriptFunctionEnd);
136
+ if (matchFunctionEnd !== null) {
137
+ if (functionDef === null) {
138
+ throw new BareScriptParserError('No matching function definition', line, 1, startLineNumber + ixLine);
139
+ }
140
+ functionDef = null;
141
+ continue;
142
+ }
143
+
144
+ // If-then begin?
145
+ const matchIfBegin = line.match(rScriptIfBegin);
146
+ if (matchIfBegin !== null) {
147
+ // Add the if-then label definition
148
+ const ifthen = {
149
+ 'jump': {
150
+ 'label': `__bareScriptIf${labelIndex}`,
151
+ 'expr': {'unary': {'op': '!', 'expr': parseExpression(matchIfBegin.groups.expr)}}
152
+ },
153
+ 'done': `__bareScriptDone${labelIndex}`,
154
+ 'hasElse': false,
155
+ line,
156
+ 'lineNumber': startLineNumber + ixLine
157
+ };
158
+ labelDefs.push({'if': ifthen});
159
+ labelIndex += 1;
160
+
161
+ // Add the if-then header statement
162
+ statements.push({'jump': ifthen.jump});
163
+ continue;
164
+ }
165
+
166
+ // Else-if-then?
167
+ const matchIfElseIf = line.match(rScriptIfElseIf);
168
+ if (matchIfElseIf !== null) {
169
+ // Get the if-then definition
170
+ const ifthen = (labelDefs.length > 0 ? (labelDefs[labelDefs.length - 1].if ?? null) : null);
171
+ if (ifthen === null) {
172
+ throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine);
173
+ }
174
+
175
+ // Cannot come after the else-then statement
176
+ if (ifthen.hasElse) {
177
+ throw new BareScriptParserError('Elif statement following else statement', line, 1, startLineNumber + ixLine);
178
+ }
179
+
180
+ // Generate the next if-then jump statement
181
+ const prevLabel = ifthen.jump.label;
182
+ ifthen.jump = {
183
+ 'label': `__bareScriptIf${labelIndex}`,
184
+ 'expr': {'unary': {'op': '!', 'expr': parseExpression(matchIfElseIf.groups.expr)}}
185
+ };
186
+ labelIndex += 1;
187
+
188
+ // Add the if-then else statements
189
+ statements.push(
190
+ {'jump': {'label': ifthen.done}},
191
+ {'label': prevLabel},
192
+ {'jump': ifthen.jump}
193
+ );
194
+ continue;
195
+ }
196
+
197
+ // Else-then?
198
+ const matchIfElse = line.match(rScriptIfElse);
199
+ if (matchIfElse !== null) {
200
+ // Get the if-then definition
201
+ const ifthen = (labelDefs.length > 0 ? (labelDefs[labelDefs.length - 1].if ?? null) : null);
202
+ if (ifthen === null) {
203
+ throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine);
204
+ }
205
+
206
+ // Cannot have multiple else-then statements
207
+ if (ifthen.hasElse) {
208
+ throw new BareScriptParserError('Multiple else statements', line, 1, startLineNumber + ixLine);
209
+ }
210
+ ifthen.hasElse = true;
211
+
212
+ // Add the if-then else statements
213
+ statements.push(
214
+ {'jump': {'label': ifthen.done}},
215
+ {'label': ifthen.jump.label}
216
+ );
217
+ continue;
218
+ }
219
+
220
+ // If-then end?
221
+ const matchIfEnd = line.match(rScriptIfEnd);
222
+ if (matchIfEnd !== null) {
223
+ // Pop the if-then definition
224
+ const ifthen = (labelDefs.length > 0 ? (labelDefs.pop().if ?? null) : null);
225
+ if (ifthen === null) {
226
+ throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine);
227
+ }
228
+
229
+ // Update the previous jump statement's label, if necessary
230
+ if (!ifthen.hasElse) {
231
+ ifthen.jump.label = ifthen.done;
232
+ }
233
+
234
+ // Add the if-then footer statement
235
+ statements.push({'label': ifthen.done});
236
+ continue;
237
+ }
238
+
239
+ // While-do begin?
240
+ const matchWhileBegin = line.match(rScriptWhileBegin);
241
+ if (matchWhileBegin !== null) {
242
+ // Add the while-do label
243
+ const whiledo = {
244
+ 'loop': `__bareScriptLoop${labelIndex}`,
245
+ 'continue': `__bareScriptLoop${labelIndex}`,
246
+ 'done': `__bareScriptDone${labelIndex}`,
247
+ 'expr': parseExpression(matchWhileBegin.groups.expr),
248
+ line,
249
+ 'lineNumber': startLineNumber + ixLine
250
+ };
251
+ labelDefs.push({'while': whiledo});
252
+ labelIndex += 1;
253
+
254
+ // Add the while-do header statements
255
+ statements.push(
256
+ {'jump': {'label': whiledo.done, 'expr': {'unary': {'op': '!', 'expr': whiledo.expr}}}},
257
+ {'label': whiledo.loop}
258
+ );
259
+ continue;
260
+ }
261
+
262
+ // While-do end?
263
+ const matchWhileEnd = line.match(rScriptWhileEnd);
264
+ if (matchWhileEnd !== null) {
265
+ // Pop the while-do definition
266
+ const whiledo = (labelDefs.length > 0 ? (labelDefs.pop().while ?? null) : null);
267
+ if (whiledo === null) {
268
+ throw new BareScriptParserError('No matching while statement', line, 1, startLineNumber + ixLine);
269
+ }
270
+
271
+ // Add the while-do footer statements
272
+ statements.push(
273
+ {'jump': {'label': whiledo.loop, 'expr': whiledo.expr}},
274
+ {'label': whiledo.done}
275
+ );
276
+ continue;
277
+ }
278
+
279
+ // For-each begin?
280
+ const matchForBegin = line.match(rScriptForBegin);
281
+ if (matchForBegin !== null) {
282
+ // Add the for-each label
283
+ const foreach = {
284
+ 'loop': `__bareScriptLoop${labelIndex}`,
285
+ 'continue': `__bareScriptContinue${labelIndex}`,
286
+ 'done': `__bareScriptDone${labelIndex}`,
287
+ 'index': matchForBegin.groups.index ?? `__bareScriptIndex${labelIndex}`,
288
+ 'values': `__bareScriptValues${labelIndex}`,
289
+ 'length': `__bareScriptLength${labelIndex}`,
290
+ 'value': matchForBegin.groups.value,
291
+ line,
292
+ 'lineNumber': startLineNumber + ixLine
293
+ };
294
+ labelDefs.push({'for': foreach});
295
+ labelIndex += 1;
296
+
297
+ // Add the for-each header statements
298
+ statements.push(
299
+ {'expr': {'name': foreach.values, 'expr': parseExpression(matchForBegin.groups.values)}},
300
+ {'expr': {'name': foreach.length, 'expr': {'function': {'name': 'arrayLength', 'args': [{'variable': foreach.values}]}}}},
301
+ {'jump': {'label': foreach.done, 'expr': {'unary': {'op': '!', 'expr': {'variable': foreach.length}}}}},
302
+ {'expr': {'name': foreach.index, 'expr': {'number': 0}}},
303
+ {'label': foreach.loop},
304
+ {'expr': {
305
+ 'name': foreach.value,
306
+ 'expr': {'function': {'name': 'arrayGet', 'args': [{'variable': foreach.values}, {'variable': foreach.index}]}}
307
+ }}
308
+ );
309
+ continue;
310
+ }
311
+
312
+ // For-each end?
313
+ const matchForEnd = line.match(rScriptForEnd);
314
+ if (matchForEnd !== null) {
315
+ // Pop the foreach definition
316
+ const foreach = (labelDefs.length > 0 ? (labelDefs.pop().for ?? null) : null);
317
+ if (foreach === null) {
318
+ throw new BareScriptParserError('No matching for statement', line, 1, startLineNumber + ixLine);
319
+ }
320
+
321
+ // Add the for-each footer statements
322
+ if (foreach.hasContinue) {
323
+ statements.push({'label': foreach.continue});
324
+ }
325
+ statements.push(
326
+ {'expr': {
327
+ 'name': foreach.index,
328
+ 'expr': {'binary': {'op': '+', 'left': {'variable': foreach.index}, 'right': {'number': 1}}}
329
+ }},
330
+ {'jump': {
331
+ 'label': foreach.loop,
332
+ 'expr': {'binary': {'op': '<', 'left': {'variable': foreach.index}, 'right': {'variable': foreach.length}}}
333
+ }},
334
+ {'label': foreach.done}
335
+ );
336
+ continue;
337
+ }
338
+
339
+ // Break statement?
340
+ const matchBreak = line.match(rScriptBreak);
341
+ if (matchBreak !== null) {
342
+ // Get the loop definition
343
+ const labelDef = (labelDefs.length > 0 ? arrayFindLast(labelDefs, (def) => !('if' in def)) : null);
344
+ if (labelDef === null) {
345
+ throw new BareScriptParserError('Break statement outside of loop', line, 1, startLineNumber + ixLine);
346
+ }
347
+ const [labelKey] = Object.keys(labelDef);
348
+ const loopDef = labelDef[labelKey];
349
+
350
+ // Add the break jump statement
351
+ statements.push({'jump': {'label': loopDef.done}});
352
+ continue;
353
+ }
354
+
355
+ // Continue statement?
356
+ const matchContinue = line.match(rScriptContinue);
357
+ if (matchContinue !== null) {
358
+ // Get the loop definition
359
+ const labelDef = (labelDefs.length > 0 ? arrayFindLast(labelDefs, (def) => !('if' in def)) : null);
360
+ if (labelDef === null) {
361
+ throw new BareScriptParserError('Continue statement outside of loop', line, 1, startLineNumber + ixLine);
362
+ }
363
+ const [labelKey] = Object.keys(labelDef);
364
+ const loopDef = labelDef[labelKey];
365
+
366
+ // Add the continue jump statement
367
+ loopDef.hasContinue = true;
368
+ statements.push({'jump': {'label': loopDef.continue}});
369
+ continue;
370
+ }
371
+
372
+ // Label definition?
373
+ const matchLabel = line.match(rScriptLabel);
374
+ if (matchLabel !== null) {
375
+ statements.push({'label': matchLabel.groups.name});
376
+ continue;
377
+ }
378
+
379
+ // Jump definition?
380
+ const matchJump = line.match(rScriptJump);
381
+ if (matchJump !== null) {
382
+ const jumpStatement = {'jump': {'label': matchJump.groups.name}};
383
+ if (typeof matchJump.groups.expr !== 'undefined') {
384
+ try {
385
+ jumpStatement.jump.expr = parseExpression(matchJump.groups.expr);
386
+ } catch (error) {
387
+ const columnNumber = matchJump.groups.jump.length - matchJump.groups.expr.length - 1 + error.columnNumber;
388
+ throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine);
389
+ }
390
+ }
391
+ statements.push(jumpStatement);
392
+ continue;
393
+ }
394
+
395
+ // Return definition?
396
+ const matchReturn = line.match(rScriptReturn);
397
+ if (matchReturn !== null) {
398
+ const returnStatement = {'return': {}};
399
+ if (typeof matchReturn.groups.expr !== 'undefined') {
400
+ try {
401
+ returnStatement.return.expr = parseExpression(matchReturn.groups.expr);
402
+ } catch (error) {
403
+ const columnNumber = matchReturn.groups.return.length - matchReturn.groups.expr.length + error.columnNumber;
404
+ throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine);
405
+ }
406
+ }
407
+ statements.push(returnStatement);
408
+ continue;
409
+ }
410
+
411
+ // Include definition?
412
+ const matchInclude = line.match(rScriptInclude) || line.match(rScriptIncludeSystem);
413
+ if (matchInclude !== null) {
414
+ const {delim} = matchInclude.groups;
415
+ const url = (delim === '<' ? matchInclude.groups.url : matchInclude.groups.url.replace(rExprStringEscape, '$1'));
416
+ let includeStatement = (statements.length ? statements[statements.length - 1] : null);
417
+ if (includeStatement === null || !('include' in includeStatement)) {
418
+ includeStatement = {'include': {'includes': []}};
419
+ statements.push(includeStatement);
420
+ }
421
+ includeStatement.include.includes.push(delim === '<' ? {url, 'system': true} : {url});
422
+ continue;
423
+ }
424
+
425
+ // Expression
426
+ try {
427
+ const exprStatement = {'expr': {'expr': parseExpression(line)}};
428
+ statements.push(exprStatement);
429
+ } catch (error) {
430
+ throw new BareScriptParserError(error.error, line, error.columnNumber, startLineNumber + ixLine);
431
+ }
432
+ }
433
+
434
+ // Dangling label definitions?
435
+ if (labelDefs.length > 0) {
436
+ const labelDef = labelDefs.pop();
437
+ const [defKey] = Object.keys(labelDef);
438
+ const def = labelDef[defKey];
439
+ throw new BareScriptParserError(`Missing end${defKey} statement`, def.line, 1, def.lineNumber);
440
+ }
441
+
442
+ return script;
443
+ }
444
+
445
+
446
+ // Firefox versions prior to 103 are missing Array.findLast - Debian Bookworm has Firefox 102
447
+ function arrayFindLast(array, findFn) {
448
+ let ixValue = array.length - 1;
449
+ while (ixValue >= 0) {
450
+ const value = array[ixValue];
451
+ if (findFn(value)) {
452
+ return value;
453
+ }
454
+ ixValue -= 1;
455
+ }
456
+ return null;
457
+ }
458
+
459
+
460
+ // BareScript expression regex
461
+ const rExprBinaryOp = /^\s*(\*\*|\*|\/|%|\+|-|<=|<|>=|>|==|!=|&&|\|\|)/;
462
+ const rExprUnaryOp = /^\s*(!|-)/;
463
+ const rExprFunctionOpen = /^\s*([A-Za-z_]\w+)\s*\(/;
464
+ const rExprFunctionSeparator = /^\s*,/;
465
+ const rExprFunctionClose = /^\s*\)/;
466
+ const rExprGroupOpen = /^\s*\(/;
467
+ const rExprGroupClose = /^\s*\)/;
468
+ const rExprNumber = /^\s*([+-]?\d+(?:\.\d*)?(?:e[+-]\d+)?)/;
469
+ const rExprString = /^\s*'((?:\\\\|\\'|[^'])*)'/;
470
+ const rExprStringEscape = /\\([\\'])/g;
471
+ const rExprStringDouble = /^\s*"((?:\\\\|\\"|[^"])*)"/;
472
+ const rExprStringDoubleEscape = /\\([\\"])/g;
473
+ const rExprVariable = /^\s*([A-Za-z_]\w*)/;
474
+ const rExprVariableEx = /^\s*\[\s*((?:\\\]|[^\]])+)\s*\]/;
475
+ const rExprVariableExEscape = /\\([\\\]])/g;
476
+
477
+
478
+ // Binary operator re-order map
479
+ const binaryReorder = {
480
+ '**': new Set(['*', '/', '%', '+', '-', '<=', '<', '>=', '>', '==', '!=', '&&', '||']),
481
+ '*': new Set(['+', '-', '<=', '<', '>=', '>', '==', '!=', '&&', '||']),
482
+ '/': new Set(['+', '-', '<=', '<', '>=', '>', '==', '!=', '&&', '||']),
483
+ '%': new Set(['+', '-', '<=', '<', '>=', '>', '==', '!=', '&&', '||']),
484
+ '+': new Set(['<=', '<', '>=', '>', '==', '!=', '&&', '||']),
485
+ '-': new Set(['<=', '<', '>=', '>', '==', '!=', '&&', '||']),
486
+ '<=': new Set(['==', '!=', '&&', '||']),
487
+ '<': new Set(['==', '!=', '&&', '||']),
488
+ '>=': new Set(['==', '!=', '&&', '||']),
489
+ '>': new Set(['==', '!=', '&&', '||']),
490
+ '==': new Set(['&&', '||']),
491
+ '!=': new Set(['&&', '||']),
492
+ '&&': new Set(['||']),
493
+ '||': new Set([])
494
+ };
495
+
496
+
497
+ /**
498
+ * Parse a BareScript expression
499
+ *
500
+ * @param {string} exprText - The [expression text]{@link https://craigahobbs.github.io/bare-script/language/#Expressions}
501
+ * @returns {Object} The [expression model]{@link https://craigahobbs.github.io/bare-script/model/#var.vName='Expression'}
502
+ * @throws [BareScriptParserError]{@link module:lib/parser.BareScriptParserError}
503
+ */
504
+ export function parseExpression(exprText) {
505
+ try {
506
+ const [expr, nextText] = parseBinaryExpression(exprText);
507
+ if (nextText.trim() !== '') {
508
+ throw new BareScriptParserError('Syntax error', nextText);
509
+ }
510
+ return expr;
511
+ } catch (error) {
512
+ const columnNumber = exprText.length - error.line.length + 1;
513
+ throw new BareScriptParserError(error.error, exprText, columnNumber);
514
+ }
515
+ }
516
+
517
+
518
+ // Helper function to parse a binary operator expression chain
519
+ function parseBinaryExpression(exprText, binLeftExpr = null) {
520
+ // Parse the binary operator's left unary expression if none was passed
521
+ let leftExpr;
522
+ let binText;
523
+ if (binLeftExpr !== null) {
524
+ binText = exprText;
525
+ leftExpr = binLeftExpr;
526
+ } else {
527
+ [leftExpr, binText] = parseUnaryExpression(exprText);
528
+ }
529
+
530
+ // Match a binary operator - if not found, return the left expression
531
+ const matchBinaryOp = binText.match(rExprBinaryOp);
532
+ if (matchBinaryOp === null) {
533
+ return [leftExpr, binText];
534
+ }
535
+ const [, binOp] = matchBinaryOp;
536
+ const rightText = binText.slice(matchBinaryOp[0].length);
537
+
538
+ // Parse the right sub-expression
539
+ const [rightExpr, nextText] = parseUnaryExpression(rightText);
540
+
541
+ // Create the binary expression - re-order for binary operators as necessary
542
+ let binExpr;
543
+ if (Object.keys(leftExpr)[0] === 'binary' && binaryReorder[binOp].has(leftExpr.binary.op)) {
544
+ // Left expression has lower precendence - find where to put this expression within the left expression
545
+ binExpr = leftExpr;
546
+ let reorderExpr = leftExpr;
547
+ while (Object.keys(reorderExpr.binary.right)[0] === 'binary' &&
548
+ binaryReorder[binOp].has(reorderExpr.binary.right.binary.op)) {
549
+ reorderExpr = reorderExpr.binary.right;
550
+ }
551
+ reorderExpr.binary.right = {'binary': {'op': binOp, 'left': reorderExpr.binary.right, 'right': rightExpr}};
552
+ } else {
553
+ binExpr = {'binary': {'op': binOp, 'left': leftExpr, 'right': rightExpr}};
554
+ }
555
+
556
+ // Parse the next binary expression in the chain
557
+ return parseBinaryExpression(nextText, binExpr);
558
+ }
559
+
560
+
561
+ // Helper function to parse a unary expression
562
+ function parseUnaryExpression(exprText) {
563
+ // Group open?
564
+ const matchGroupOpen = exprText.match(rExprGroupOpen);
565
+ if (matchGroupOpen !== null) {
566
+ const groupText = exprText.slice(matchGroupOpen[0].length);
567
+ const [expr, nextText] = parseBinaryExpression(groupText);
568
+ const matchGroupClose = nextText.match(rExprGroupClose);
569
+ if (matchGroupClose === null) {
570
+ throw new BareScriptParserError('Unmatched parenthesis', exprText);
571
+ }
572
+ return [{'group': expr}, nextText.slice(matchGroupClose[0].length)];
573
+ }
574
+
575
+ // Unary operator?
576
+ const matchUnary = exprText.match(rExprUnaryOp);
577
+ if (matchUnary !== null) {
578
+ const unaryText = exprText.slice(matchUnary[0].length);
579
+ const [expr, nextText] = parseUnaryExpression(unaryText);
580
+ const unaryExpr = {
581
+ 'unary': {
582
+ 'op': matchUnary[1],
583
+ expr
584
+ }
585
+ };
586
+ return [unaryExpr, nextText];
587
+ }
588
+
589
+ // Function?
590
+ const matchFunctionOpen = exprText.match(rExprFunctionOpen);
591
+ if (matchFunctionOpen !== null) {
592
+ let argText = exprText.slice(matchFunctionOpen[0].length);
593
+ const args = [];
594
+ // eslint-disable-next-line no-constant-condition
595
+ while (true) {
596
+ // Function close?
597
+ const matchFunctionClose = argText.match(rExprFunctionClose);
598
+ if (matchFunctionClose !== null) {
599
+ argText = argText.slice(matchFunctionClose[0].length);
600
+ break;
601
+ }
602
+
603
+ // Function argument separator
604
+ if (args.length !== 0) {
605
+ const matchFunctionSeparator = argText.match(rExprFunctionSeparator);
606
+ if (matchFunctionSeparator === null) {
607
+ throw new BareScriptParserError('Syntax error', argText);
608
+ }
609
+ argText = argText.slice(matchFunctionSeparator[0].length);
610
+ }
611
+
612
+ // Get the argument
613
+ const [argExpr, nextArgText] = parseBinaryExpression(argText);
614
+ args.push(argExpr);
615
+ argText = nextArgText;
616
+ }
617
+
618
+ const fnExpr = {
619
+ 'function': {
620
+ 'name': matchFunctionOpen[1],
621
+ 'args': args
622
+ }
623
+ };
624
+ return [fnExpr, argText];
625
+ }
626
+
627
+ // Number?
628
+ const matchNumber = exprText.match(rExprNumber);
629
+ if (matchNumber !== null) {
630
+ const number = parseFloat(matchNumber[1]);
631
+ const expr = {'number': number};
632
+ return [expr, exprText.slice(matchNumber[0].length)];
633
+ }
634
+
635
+ // String?
636
+ const matchString = exprText.match(rExprString);
637
+ if (matchString !== null) {
638
+ const string = matchString[1].replace(rExprStringEscape, '$1');
639
+ const expr = {'string': string};
640
+ return [expr, exprText.slice(matchString[0].length)];
641
+ }
642
+
643
+ // String (double quotes)?
644
+ const matchStringDouble = exprText.match(rExprStringDouble);
645
+ if (matchStringDouble !== null) {
646
+ const string = matchStringDouble[1].replace(rExprStringDoubleEscape, '$1');
647
+ const expr = {'string': string};
648
+ return [expr, exprText.slice(matchStringDouble[0].length)];
649
+ }
650
+
651
+ // Variable?
652
+ const matchVariable = exprText.match(rExprVariable);
653
+ if (matchVariable !== null) {
654
+ const expr = {'variable': matchVariable[1]};
655
+ return [expr, exprText.slice(matchVariable[0].length)];
656
+ }
657
+
658
+ // Variable (brackets)?
659
+ const matchVariableEx = exprText.match(rExprVariableEx);
660
+ if (matchVariableEx !== null) {
661
+ const variableName = matchVariableEx[1].replace(rExprVariableExEscape, '$1');
662
+ const expr = {'variable': variableName};
663
+ return [expr, exprText.slice(matchVariableEx[0].length)];
664
+ }
665
+
666
+ throw new BareScriptParserError('Syntax error', exprText);
667
+ }
668
+
669
+
670
+ /**
671
+ * A BareScript parser error
672
+ *
673
+ * @extends {Error}
674
+ * @property {string} error - The error description
675
+ * @property {string} line - The line text
676
+ * @property {number} columnNumber - The error column number
677
+ * @property {?number} lineNumber - The error line number
678
+ */
679
+ export class BareScriptParserError extends Error {
680
+ /**
681
+ * Create a BareScript parser error
682
+ *
683
+ * @param {string} error - The error description
684
+ * @param {string} line - The line text
685
+ * @param {number} [columnNumber=1] - The error column number
686
+ * @param {?number} [lineNumber=null] - The error line number
687
+ * @param {?string} [prefix=null] - The error message prefix line
688
+ */
689
+ constructor(error, line, columnNumber = 1, lineNumber = null, prefix = null) {
690
+ // Parser error constants
691
+ const lineLengthMax = 120;
692
+ const lineSuffix = ' ...';
693
+ const linePrefix = '... ';
694
+
695
+ // Trim the error line, if necessary
696
+ let lineError = line;
697
+ let lineColumn = columnNumber;
698
+ if (line.length > lineLengthMax) {
699
+ const lineLeft = columnNumber - 1 - 0.5 * lineLengthMax;
700
+ const lineRight = lineLeft + lineLengthMax;
701
+ if (lineLeft < 0) {
702
+ lineError = line.slice(0, lineLengthMax) + lineSuffix;
703
+ } else if (lineRight > line.length) {
704
+ lineError = linePrefix + line.slice(line.length - lineLengthMax);
705
+ lineColumn -= lineLeft - linePrefix.length - (lineRight - line.length);
706
+ } else {
707
+ lineError = linePrefix + line.slice(lineLeft, lineRight) + lineSuffix;
708
+ lineColumn -= lineLeft - linePrefix.length;
709
+ }
710
+ }
711
+
712
+ // Format the message
713
+ const message = `\
714
+ ${prefix !== null ? `${prefix}\n` : ''}${error}${lineNumber !== null ? `, line number ${lineNumber}` : ''}:
715
+ ${lineError}
716
+ ${' '.repeat(lineColumn - 1)}^
717
+ `;
718
+ super(message);
719
+ this.name = this.constructor.name;
720
+ this.error = error;
721
+ this.line = line;
722
+ this.columnNumber = columnNumber;
723
+ this.lineNumber = lineNumber;
724
+ }
725
+ }