Skip to content

Commit 30c411e

Browse files
authored
Add arrow function syntax for inline functions (#27)
* Add arrow function support for inline functions * Address code review feedback: bounds check and documentation * Update documentation with arrow function syntax
1 parent e8eefaf commit 30c411e

9 files changed

Lines changed: 555 additions & 8 deletions

File tree

docs/syntax.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The parser accepts a pretty basic grammar. It's similar to normal JavaScript exp
1717
| and | Left | Logical AND |
1818
| or | Left | Logical OR |
1919
| x ? y : z | Right | Ternary conditional (if x then y else z) |
20+
| => | Right | Arrow function (e.g., x => x * 2) |
2021
| = | Right | Variable assignment |
2122
| ; | Left | Expression separator |
2223

@@ -345,6 +346,59 @@ You can also define the functions inline:
345346
filter(isEven(x) = x % 2 == 0, [1, 2, 3, 4, 5])
346347
```
347348

349+
### Arrow Functions
350+
351+
Arrow functions provide a concise syntax for inline functions, similar to JavaScript arrow functions. They are particularly useful when passing functions to higher-order functions like `map`, `filter`, and `fold`.
352+
353+
**Single parameter (no parentheses required):**
354+
355+
```js
356+
map(x => x * 2, [1, 2, 3]) // [2, 4, 6]
357+
filter(x => x > 2, [1, 2, 3, 4]) // [3, 4]
358+
map(x => x.name, users) // Extract property from objects
359+
```
360+
361+
**Multiple parameters (parentheses required):**
362+
363+
```js
364+
fold((acc, x) => acc + x, 0, [1, 2, 3, 4, 5]) // 15 (sum)
365+
fold((acc, x) => acc * x, 1, [1, 2, 3, 4, 5]) // 120 (product)
366+
map((val, idx) => val + idx, [10, 20, 30]) // [10, 21, 32]
367+
filter((x, i) => i >= 1, [10, 20, 30]) // [20, 30]
368+
```
369+
370+
**Zero parameters:**
371+
372+
```js
373+
(() => 42)() // 42
374+
```
375+
376+
**Assignment to variable:**
377+
378+
Arrow functions can be assigned to variables for reuse:
379+
380+
```js
381+
fn = x => x * 2; map(fn, [1, 2, 3]) // [2, 4, 6]
382+
double = x => x * 2; triple = x => x * 3; map(double, map(triple, [1, 2])) // [6, 12]
383+
```
384+
385+
**Nested arrow functions:**
386+
387+
```js
388+
map(row => map(x => x * 2, row), [[1, 2], [3, 4]]) // [[2, 4], [6, 8]]
389+
```
390+
391+
**With member access and complex expressions:**
392+
393+
```js
394+
filter(x => x.age > 25, users) // Filter objects by property
395+
map(x => x.value * 2 + 1, items) // Complex transformations
396+
filter(x => x > 0 and x < 10, numbers) // Using logical operators
397+
map(x => x > 5 ? "high" : "low", [3, 7, 2, 9]) // Using ternary operator
398+
```
399+
400+
> **Note:** Arrow functions share the same `fndef` operator flag as traditional function definitions. If function definitions are disabled via parser options, arrow functions will also be disabled.
401+
348402
## Custom JavaScript Functions
349403

350404
If you need additional functions that aren't supported out of the box, you can easily add them in your own code. Instances of the `Parser` class have a property called `functions` that's simply an object with all the functions that are in scope. You can add, replace, or delete any of the properties to customize what's available in the expressions. For example:

src/core/evaluate.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
* It uses a stack-based interpreter to evaluate instruction sequences produced by the parser.
66
*/
77

8-
import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IEXPR, IEXPREVAL, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, IWHENMATCH, ICASEELSE, ICASECOND, IWHENCOND, IOBJECT, IPROPERTY, IOBJECTEND } from '../parsing/instruction.js';
8+
import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IARROW, IEXPR, IEXPREVAL, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, IWHENMATCH, ICASEELSE, ICASECOND, IWHENCOND, IOBJECT, IPROPERTY, IOBJECTEND } from '../parsing/instruction.js';
99
import type { Instruction } from '../parsing/instruction.js';
1010
import type { Expression } from './expression.js';
1111
import type { Value, Values, VariableResolveResult } from '../types/values.js';
1212
import { VariableError } from '../types/errors.js';
1313
import { ExpressionValidator } from '../validation/expression-validator.js';
1414

15-
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY
15+
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY IARROW
1616
// cSpell:words IFUNDEF IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
1717
// cSpell:words IOBJECT IOBJECTEND
1818
// cSpell:words nstack
@@ -261,6 +261,34 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
261261
values[functionName] = userDefinedFunction;
262262
return userDefinedFunction;
263263
})());
264+
} else if (type === IARROW) {
265+
// Create anonymous arrow function closure
266+
// Stack contains: param1, param2, ..., paramN, expression
267+
// token.value is the parameter count
268+
nstack.push((function () {
269+
const expressionToEvaluate = nstack.pop();
270+
const functionParams: string[] = [];
271+
let parameterCount = token.value as number;
272+
while (parameterCount-- > 0) {
273+
functionParams.unshift(nstack.pop());
274+
}
275+
const arrowFunction = function (...functionArguments: any[]) {
276+
const localScope = Object.assign({}, values);
277+
for (let i = 0, len = functionParams.length; i < len; i++) {
278+
localScope[functionParams[i]] = functionArguments[i];
279+
}
280+
return evaluate(expressionToEvaluate, expr, localScope);
281+
};
282+
// Set function name for debugging (anonymous arrow function)
283+
Object.defineProperty(arrowFunction, 'name', {
284+
value: '(arrow)',
285+
writable: false
286+
});
287+
// Security: Register the arrow function as allowed using a unique counter-based key
288+
const uniqueKey = `__inline_fn_${inlineFunctionCounter++}__`;
289+
expr.functions[uniqueKey] = arrowFunction;
290+
return arrowFunction;
291+
})());
264292
} else if (type === IEXPR) {
265293
nstack.push(createExpressionEvaluator(token, expr));
266294
} else if (type === IEXPREVAL) {

src/core/expression-to-string.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY
1+
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY IARROW
22
// cSpell:words IFUNDEF IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
33
// cSpell:words IOBJECT IOBJECTEND
44
// cSpell:words nstack
55

6-
import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IEXPR, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, ICASECOND, IWHENCOND, IWHENMATCH, ICASEELSE, IOBJECT, IOBJECTEND, IPROPERTY } from '../parsing/instruction.js';
6+
import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IARROW, IEXPR, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, ICASECOND, IWHENCOND, IWHENMATCH, ICASEELSE, IOBJECT, IOBJECTEND, IPROPERTY } from '../parsing/instruction.js';
77
import type { Instruction } from '../parsing/instruction.js';
88

99
export default function expressionToString(tokens: Instruction[], toJS?: boolean): string {
@@ -101,6 +101,22 @@ export default function expressionToString(tokens: Instruction[], toJS?: boolean
101101
} else {
102102
nstack.push('(' + n1 + '(' + args.join(', ') + ') = ' + n2 + ')');
103103
}
104+
} else if (type === IARROW) {
105+
n2 = nstack.pop()!;
106+
argCount = item.value as number;
107+
args = [];
108+
while (argCount-- > 0) {
109+
args.unshift(nstack.pop()!);
110+
}
111+
if (toJS) {
112+
nstack.push('((' + args.join(', ') + ') => ' + n2 + ')');
113+
} else {
114+
if (args.length === 1) {
115+
nstack.push('(' + args[0] + ' => ' + n2 + ')');
116+
} else {
117+
nstack.push('((' + args.join(', ') + ') => ' + n2 + ')');
118+
}
119+
}
104120
} else if (type === IMEMBER) {
105121
n1 = nstack.pop()!;
106122
nstack.push(n1 + '.' + item.value);

src/parsing/instruction.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const IVARNAME = 'IVARNAME' as const;
3333
export const IFUNCALL = 'IFUNCALL' as const;
3434
/** Function definition instruction */
3535
export const IFUNDEF = 'IFUNDEF' as const;
36+
/** Arrow function instruction (anonymous inline function) */
37+
export const IARROW = 'IARROW' as const;
3638
/** Expression instruction (for lazy evaluation) */
3739
export const IEXPR = 'IEXPR' as const;
3840
/** Expression evaluator instruction (compiled expression) */
@@ -74,6 +76,7 @@ export type InstructionType =
7476
| typeof IVARNAME
7577
| typeof IFUNCALL
7678
| typeof IFUNDEF
79+
| typeof IARROW
7780
| typeof IEXPR
7881
| typeof IEXPREVAL
7982
| typeof IMEMBER
@@ -132,6 +135,11 @@ export interface FunctionDefInstruction {
132135
value: number; // parameter count
133136
}
134137

138+
export interface ArrowFunctionInstruction {
139+
type: typeof IARROW;
140+
value: number; // parameter count
141+
}
142+
135143
export interface ExpressionInstruction {
136144
type: typeof IEXPR;
137145
value: Instruction[];
@@ -212,6 +220,7 @@ export type TypedInstruction =
212220
| VarNameInstruction
213221
| FunctionCallInstruction
214222
| FunctionDefInstruction
223+
| ArrowFunctionInstruction
215224
| ExpressionInstruction
216225
| ExpressionEvalInstruction
217226
| MemberInstruction
@@ -268,6 +277,8 @@ export class Instruction {
268277
return 'CALL ' + this.value;
269278
case IFUNDEF:
270279
return 'DEF ' + this.value;
280+
case IARROW:
281+
return 'ARROW ' + this.value;
271282
case IARRAY:
272283
return 'ARRAY ' + this.value;
273284
case IMEMBER:

src/parsing/parser-state.ts

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
// cSpell:words TEOF TNUMBER TSTRING TCONST TPAREN TBRACKET TCOMMA TNAME TSEMICOLON TUNDEFINED TKEYWORD TBRACE
2-
// cSpell:words ISCALAR IVAR IFUNCALL IEXPREVAL IMEMBER IARRAY
2+
// cSpell:words ISCALAR IVAR IFUNCALL IEXPREVAL IMEMBER IARRAY IARROW
33
// cSpell:words IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
44
// cSpell:words IOBJECT IOBJECTEND
55

66
import {
77
TOP, TNUMBER, TSTRING, TPAREN, TBRACKET, TCOMMA, TNAME, TSEMICOLON, TEOF, TKEYWORD, TBRACE, Token, TokenType,
88
TCONST
99
} from './token.js';
10-
import { Instruction, ISCALAR, IVAR, IFUNCALL, IMEMBER, IARRAY, IUNDEFINED, binaryInstruction, unaryInstruction, IWHENMATCH, ICASEMATCH, ICASEELSE, ICASECOND, IWHENCOND, IPROPERTY, IOBJECT, IOBJECTEND, InstructionType } from './instruction.js';
10+
import { Instruction, ISCALAR, IVAR, IFUNCALL, IMEMBER, IARRAY, IUNDEFINED, binaryInstruction, unaryInstruction, IWHENMATCH, ICASEMATCH, ICASEELSE, ICASECOND, IWHENCOND, IPROPERTY, IOBJECT, IOBJECTEND, InstructionType, IVARNAME, IEXPR, IARROW } from './instruction.js';
1111
import contains from '../core/contains.js';
1212
import { TokenStream } from './token-stream.js';
1313
import { ParseError, AccessError } from '../types/errors.js';
@@ -119,11 +119,21 @@ export class ParserState {
119119
// undefined is a reserved work that evaluates to JavaScript undefined.
120120
instr.push(new Instruction(IUNDEFINED));
121121
} else {
122-
instr.push(new Instruction(IVAR, this.current!.value));
122+
// Check if this is a single-parameter arrow function: x => expr
123+
if (this.nextToken!.type === TOP && this.nextToken!.value === '=>') {
124+
this.parseArrowFunctionFromParameter(instr, this.current!.value as string);
125+
} else {
126+
instr.push(new Instruction(IVAR, this.current!.value));
127+
}
123128
}
124129
} else if (this.accept(TNUMBER) || this.accept(TSTRING) || this.accept(TCONST)) {
125130
instr.push(new Instruction(ISCALAR, this.current!.value));
126131
} else if (this.accept(TPAREN, '(')) {
132+
// Check if this is a multi-parameter arrow function: (x, y) => expr
133+
if (this.tryParseArrowFunction(instr)) {
134+
// Arrow function was parsed successfully
135+
return;
136+
}
127137
this.parseExpression(instr);
128138
this.expect(TPAREN, ')');
129139
} else if (this.accept(TBRACE, '{')) {
@@ -150,6 +160,136 @@ export class ParserState {
150160
}
151161
}
152162

163+
/**
164+
* Parses an arrow function when we already have a single parameter name.
165+
* Called when we detect: `paramName =>` pattern
166+
*/
167+
private parseArrowFunctionFromParameter(instr: Instruction[], paramName: string): void {
168+
// Validate that arrow functions are enabled
169+
if (!this.parser.isOperatorEnabled('=>')) {
170+
const coords = this.tokens.getCoordinates();
171+
throw new ParseError(
172+
'Arrow function syntax is not permitted',
173+
{
174+
position: { line: coords.line, column: coords.column },
175+
expression: this.tokens.expression
176+
}
177+
);
178+
}
179+
180+
// Consume the '=>' operator
181+
this.expect(TOP, '=>');
182+
183+
// Parse the function body expression. We use parseConditionalExpression instead of
184+
// parseExpression because arrow function bodies should be single expressions and
185+
// should NOT consume semicolons. The semicolon terminates the arrow function
186+
// definition, allowing patterns like: `fn = x => x * 2; map(fn, arr)`
187+
// If we used parseExpression, the semicolon and subsequent statements would be
188+
// incorrectly included in the arrow function body.
189+
const bodyInstr: Instruction[] = [];
190+
this.parseConditionalExpression(bodyInstr);
191+
192+
// Build the arrow function: push param name, body expression, and IARROW instruction
193+
instr.push(new Instruction(IVARNAME, paramName));
194+
instr.push(new Instruction(IEXPR, bodyInstr));
195+
instr.push(new Instruction(IARROW, 1));
196+
}
197+
198+
/**
199+
* Attempts to parse an arrow function after seeing '('.
200+
* Returns true if it successfully parsed an arrow function, false otherwise.
201+
* Uses lookahead to detect arrow function syntax without consuming tokens on failure.
202+
*/
203+
private tryParseArrowFunction(instr: Instruction[]): boolean {
204+
// Save current position for backtracking
205+
this.save();
206+
207+
// Try to parse parameter list
208+
const params: string[] = [];
209+
210+
// Check for empty parameter list: () => expr
211+
if (this.accept(TPAREN, ')')) {
212+
// Check if followed by =>
213+
if (this.accept(TOP, '=>')) {
214+
// Validate that arrow functions are enabled
215+
if (!this.parser.isOperatorEnabled('=>')) {
216+
this.restore();
217+
return false;
218+
}
219+
// Parse the function body
220+
const bodyInstr: Instruction[] = [];
221+
this.parseExpression(bodyInstr);
222+
223+
// Build the arrow function with no parameters
224+
instr.push(new Instruction(IEXPR, bodyInstr));
225+
instr.push(new Instruction(IARROW, 0));
226+
return true;
227+
}
228+
// Not an arrow function, restore and return false
229+
this.restore();
230+
return false;
231+
}
232+
233+
// Try to parse comma-separated parameter names
234+
if (!this.accept(TNAME)) {
235+
// Not a parameter list, restore and return false
236+
this.restore();
237+
return false;
238+
}
239+
params.push(this.current!.value as string);
240+
241+
// Parse additional parameters separated by commas
242+
while (this.accept(TCOMMA)) {
243+
if (!this.accept(TNAME)) {
244+
// Invalid parameter, restore and return false
245+
this.restore();
246+
return false;
247+
}
248+
params.push(this.current!.value as string);
249+
}
250+
251+
// Expect closing parenthesis
252+
if (!this.accept(TPAREN, ')')) {
253+
// Not a parameter list, restore and return false
254+
this.restore();
255+
return false;
256+
}
257+
258+
// Check for arrow operator
259+
if (!this.accept(TOP, '=>')) {
260+
// Not an arrow function, restore and return false
261+
this.restore();
262+
return false;
263+
}
264+
265+
// Validate that arrow functions are enabled
266+
if (!this.parser.isOperatorEnabled('=>')) {
267+
const coords = this.tokens.getCoordinates();
268+
throw new ParseError(
269+
'Arrow function syntax is not permitted',
270+
{
271+
position: { line: coords.line, column: coords.column },
272+
expression: this.tokens.expression
273+
}
274+
);
275+
}
276+
277+
// Parse the function body expression. We use parseConditionalExpression instead of
278+
// parseExpression because arrow function bodies should be single expressions and
279+
// should NOT consume semicolons. This allows patterns like: `fn = (a, b) => a + b; map(fn, arr)`
280+
const bodyInstr: Instruction[] = [];
281+
this.parseConditionalExpression(bodyInstr);
282+
283+
// Build the arrow function: push param names, body expression, and IARROW instruction
284+
for (const param of params) {
285+
instr.push(new Instruction(IVARNAME, param));
286+
}
287+
instr.push(new Instruction(IEXPR, bodyInstr));
288+
instr.push(new Instruction(IARROW, params.length));
289+
290+
return true;
291+
}
292+
153293
parseExpression(instr: Instruction[]): void {
154294
const exprInstr: Instruction[] = [];
155295

src/parsing/parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ export class Parser {
327327
'=': 'assignment',
328328
'[': 'array',
329329
'()=': 'fndef',
330+
'=>': 'fndef',
330331
'??': 'coalesce',
331332
'as': 'conversion'
332333
} as const;

0 commit comments

Comments
 (0)