Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The parser accepts a pretty basic grammar. It's similar to normal JavaScript exp
| and | Left | Logical AND |
| or | Left | Logical OR |
| x ? y : z | Right | Ternary conditional (if x then y else z) |
| => | Right | Arrow function (e.g., x => x * 2) |
| = | Right | Variable assignment |
| ; | Left | Expression separator |

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

### Arrow Functions

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`.

**Single parameter (no parentheses required):**

```js
map(x => x * 2, [1, 2, 3]) // [2, 4, 6]
filter(x => x > 2, [1, 2, 3, 4]) // [3, 4]
map(x => x.name, users) // Extract property from objects
```

**Multiple parameters (parentheses required):**

```js
fold((acc, x) => acc + x, 0, [1, 2, 3, 4, 5]) // 15 (sum)
fold((acc, x) => acc * x, 1, [1, 2, 3, 4, 5]) // 120 (product)
map((val, idx) => val + idx, [10, 20, 30]) // [10, 21, 32]
filter((x, i) => i >= 1, [10, 20, 30]) // [20, 30]
```

**Zero parameters:**

```js
(() => 42)() // 42
```

**Assignment to variable:**

Arrow functions can be assigned to variables for reuse:

```js
fn = x => x * 2; map(fn, [1, 2, 3]) // [2, 4, 6]
double = x => x * 2; triple = x => x * 3; map(double, map(triple, [1, 2])) // [6, 12]
```

**Nested arrow functions:**

```js
map(row => map(x => x * 2, row), [[1, 2], [3, 4]]) // [[2, 4], [6, 8]]
```

**With member access and complex expressions:**

```js
filter(x => x.age > 25, users) // Filter objects by property
map(x => x.value * 2 + 1, items) // Complex transformations
filter(x => x > 0 and x < 10, numbers) // Using logical operators
map(x => x > 5 ? "high" : "low", [3, 7, 2, 9]) // Using ternary operator
```

> **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.

## Custom JavaScript Functions

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:
Expand Down
32 changes: 30 additions & 2 deletions src/core/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
* It uses a stack-based interpreter to evaluate instruction sequences produced by the parser.
*/

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';
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';
import type { Instruction } from '../parsing/instruction.js';
import type { Expression } from './expression.js';
import type { Value, Values, VariableResolveResult } from '../types/values.js';
import { VariableError } from '../types/errors.js';
import { ExpressionValidator } from '../validation/expression-validator.js';

// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY IARROW
// cSpell:words IFUNDEF IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
// cSpell:words IOBJECT IOBJECTEND
// cSpell:words nstack
Expand Down Expand Up @@ -261,6 +261,34 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok
values[functionName] = userDefinedFunction;
return userDefinedFunction;
})());
} else if (type === IARROW) {
// Create anonymous arrow function closure
// Stack contains: param1, param2, ..., paramN, expression
// token.value is the parameter count
nstack.push((function () {
const expressionToEvaluate = nstack.pop();
const functionParams: string[] = [];
let parameterCount = token.value as number;
while (parameterCount-- > 0) {
functionParams.unshift(nstack.pop());
}
const arrowFunction = function (...functionArguments: any[]) {
const localScope = Object.assign({}, values);
for (let i = 0, len = functionParams.length; i < len; i++) {
localScope[functionParams[i]] = functionArguments[i];
}
return evaluate(expressionToEvaluate, expr, localScope);
};
// Set function name for debugging (anonymous arrow function)
Object.defineProperty(arrowFunction, 'name', {
value: '(arrow)',
writable: false
});
// Security: Register the arrow function as allowed using a unique counter-based key
const uniqueKey = `__inline_fn_${inlineFunctionCounter++}__`;
expr.functions[uniqueKey] = arrowFunction;
return arrowFunction;
})());
} else if (type === IEXPR) {
nstack.push(createExpressionEvaluator(token, expr));
} else if (type === IEXPREVAL) {
Expand Down
20 changes: 18 additions & 2 deletions src/core/expression-to-string.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY
// cSpell:words ISCALAR IVAR IVARNAME IFUNCALL IEXPR IEXPREVAL IMEMBER IENDSTATEMENT IARRAY IARROW
// cSpell:words IFUNDEF IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
// cSpell:words IOBJECT IOBJECTEND
// cSpell:words nstack

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';
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';
import type { Instruction } from '../parsing/instruction.js';

export default function expressionToString(tokens: Instruction[], toJS?: boolean): string {
Expand Down Expand Up @@ -101,6 +101,22 @@ export default function expressionToString(tokens: Instruction[], toJS?: boolean
} else {
nstack.push('(' + n1 + '(' + args.join(', ') + ') = ' + n2 + ')');
}
} else if (type === IARROW) {
n2 = nstack.pop()!;
argCount = item.value as number;
args = [];
while (argCount-- > 0) {
args.unshift(nstack.pop()!);
}
if (toJS) {
nstack.push('((' + args.join(', ') + ') => ' + n2 + ')');
} else {
if (args.length === 1) {
nstack.push('(' + args[0] + ' => ' + n2 + ')');
} else {
nstack.push('((' + args.join(', ') + ') => ' + n2 + ')');
}
}
} else if (type === IMEMBER) {
n1 = nstack.pop()!;
nstack.push(n1 + '.' + item.value);
Expand Down
11 changes: 11 additions & 0 deletions src/parsing/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const IVARNAME = 'IVARNAME' as const;
export const IFUNCALL = 'IFUNCALL' as const;
/** Function definition instruction */
export const IFUNDEF = 'IFUNDEF' as const;
/** Arrow function instruction (anonymous inline function) */
export const IARROW = 'IARROW' as const;
/** Expression instruction (for lazy evaluation) */
export const IEXPR = 'IEXPR' as const;
/** Expression evaluator instruction (compiled expression) */
Expand Down Expand Up @@ -74,6 +76,7 @@ export type InstructionType =
| typeof IVARNAME
| typeof IFUNCALL
| typeof IFUNDEF
| typeof IARROW
| typeof IEXPR
| typeof IEXPREVAL
| typeof IMEMBER
Expand Down Expand Up @@ -132,6 +135,11 @@ export interface FunctionDefInstruction {
value: number; // parameter count
}

export interface ArrowFunctionInstruction {
type: typeof IARROW;
value: number; // parameter count
}

export interface ExpressionInstruction {
type: typeof IEXPR;
value: Instruction[];
Expand Down Expand Up @@ -212,6 +220,7 @@ export type TypedInstruction =
| VarNameInstruction
| FunctionCallInstruction
| FunctionDefInstruction
| ArrowFunctionInstruction
| ExpressionInstruction
| ExpressionEvalInstruction
| MemberInstruction
Expand Down Expand Up @@ -268,6 +277,8 @@ export class Instruction {
return 'CALL ' + this.value;
case IFUNDEF:
return 'DEF ' + this.value;
case IARROW:
return 'ARROW ' + this.value;
case IARRAY:
return 'ARRAY ' + this.value;
case IMEMBER:
Expand Down
146 changes: 143 additions & 3 deletions src/parsing/parser-state.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// cSpell:words TEOF TNUMBER TSTRING TCONST TPAREN TBRACKET TCOMMA TNAME TSEMICOLON TUNDEFINED TKEYWORD TBRACE
// cSpell:words ISCALAR IVAR IFUNCALL IEXPREVAL IMEMBER IARRAY
// cSpell:words ISCALAR IVAR IFUNCALL IEXPREVAL IMEMBER IARRAY IARROW
// cSpell:words IUNDEFINED ICASEMATCH ICASECOND IWHENCOND IWHENMATCH ICASEELSE IPROPERTY
// cSpell:words IOBJECT IOBJECTEND

import {
TOP, TNUMBER, TSTRING, TPAREN, TBRACKET, TCOMMA, TNAME, TSEMICOLON, TEOF, TKEYWORD, TBRACE, Token, TokenType,
TCONST
} from './token.js';
import { Instruction, ISCALAR, IVAR, IFUNCALL, IMEMBER, IARRAY, IUNDEFINED, binaryInstruction, unaryInstruction, IWHENMATCH, ICASEMATCH, ICASEELSE, ICASECOND, IWHENCOND, IPROPERTY, IOBJECT, IOBJECTEND, InstructionType } from './instruction.js';
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';
import contains from '../core/contains.js';
import { TokenStream } from './token-stream.js';
import { ParseError, AccessError } from '../types/errors.js';
Expand Down Expand Up @@ -119,11 +119,21 @@ export class ParserState {
// undefined is a reserved work that evaluates to JavaScript undefined.
instr.push(new Instruction(IUNDEFINED));
} else {
instr.push(new Instruction(IVAR, this.current!.value));
// Check if this is a single-parameter arrow function: x => expr
if (this.nextToken!.type === TOP && this.nextToken!.value === '=>') {
this.parseArrowFunctionFromParameter(instr, this.current!.value as string);
} else {
instr.push(new Instruction(IVAR, this.current!.value));
}
}
} else if (this.accept(TNUMBER) || this.accept(TSTRING) || this.accept(TCONST)) {
instr.push(new Instruction(ISCALAR, this.current!.value));
} else if (this.accept(TPAREN, '(')) {
// Check if this is a multi-parameter arrow function: (x, y) => expr
if (this.tryParseArrowFunction(instr)) {
// Arrow function was parsed successfully
return;
}
this.parseExpression(instr);
this.expect(TPAREN, ')');
} else if (this.accept(TBRACE, '{')) {
Expand All @@ -150,6 +160,136 @@ export class ParserState {
}
}

/**
* Parses an arrow function when we already have a single parameter name.
* Called when we detect: `paramName =>` pattern
*/
private parseArrowFunctionFromParameter(instr: Instruction[], paramName: string): void {
// Validate that arrow functions are enabled
if (!this.parser.isOperatorEnabled('=>')) {
const coords = this.tokens.getCoordinates();
throw new ParseError(
'Arrow function syntax is not permitted',
{
position: { line: coords.line, column: coords.column },
expression: this.tokens.expression
}
);
}

// Consume the '=>' operator
this.expect(TOP, '=>');

// Parse the function body expression. We use parseConditionalExpression instead of
// parseExpression because arrow function bodies should be single expressions and
// should NOT consume semicolons. The semicolon terminates the arrow function
// definition, allowing patterns like: `fn = x => x * 2; map(fn, arr)`
// If we used parseExpression, the semicolon and subsequent statements would be
// incorrectly included in the arrow function body.
const bodyInstr: Instruction[] = [];
this.parseConditionalExpression(bodyInstr);

// Build the arrow function: push param name, body expression, and IARROW instruction
instr.push(new Instruction(IVARNAME, paramName));
instr.push(new Instruction(IEXPR, bodyInstr));
instr.push(new Instruction(IARROW, 1));
}

/**
* Attempts to parse an arrow function after seeing '('.
* Returns true if it successfully parsed an arrow function, false otherwise.
* Uses lookahead to detect arrow function syntax without consuming tokens on failure.
*/
private tryParseArrowFunction(instr: Instruction[]): boolean {
// Save current position for backtracking
this.save();

// Try to parse parameter list
const params: string[] = [];

// Check for empty parameter list: () => expr
if (this.accept(TPAREN, ')')) {
// Check if followed by =>
if (this.accept(TOP, '=>')) {
// Validate that arrow functions are enabled
if (!this.parser.isOperatorEnabled('=>')) {
this.restore();
return false;
}
// Parse the function body
const bodyInstr: Instruction[] = [];
this.parseExpression(bodyInstr);
Comment on lines +219 to +221

Copilot AI Jan 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent parsing method used for zero-parameter arrow functions. Line 221 uses parseExpression(bodyInstr) while lines 190 and 281 correctly use parseConditionalExpression(bodyInstr) for single and multi-parameter arrow functions. This inconsistency means zero-parameter arrow functions would incorrectly consume semicolons in their body, preventing patterns like fn = () => 42; otherCode. The body parsing should use parseConditionalExpression for consistency.

Suggested change
// Parse the function body
const bodyInstr: Instruction[] = [];
this.parseExpression(bodyInstr);
// Parse the function body. Use parseConditionalExpression so the body is a
// single expression and does not consume semicolons, consistent with the
// non-empty parameter case below.
const bodyInstr: Instruction[] = [];
this.parseConditionalExpression(bodyInstr);

Copilot uses AI. Check for mistakes.

// Build the arrow function with no parameters
instr.push(new Instruction(IEXPR, bodyInstr));
instr.push(new Instruction(IARROW, 0));
return true;
}
// Not an arrow function, restore and return false
this.restore();
return false;
}

// Try to parse comma-separated parameter names
if (!this.accept(TNAME)) {
// Not a parameter list, restore and return false
this.restore();
return false;
}
params.push(this.current!.value as string);

// Parse additional parameters separated by commas
while (this.accept(TCOMMA)) {
if (!this.accept(TNAME)) {
// Invalid parameter, restore and return false
this.restore();
return false;
}
params.push(this.current!.value as string);
}

// Expect closing parenthesis
if (!this.accept(TPAREN, ')')) {
// Not a parameter list, restore and return false
this.restore();
return false;
}

// Check for arrow operator
if (!this.accept(TOP, '=>')) {
// Not an arrow function, restore and return false
this.restore();
return false;
}

// Validate that arrow functions are enabled
if (!this.parser.isOperatorEnabled('=>')) {
const coords = this.tokens.getCoordinates();
throw new ParseError(
'Arrow function syntax is not permitted',
{
position: { line: coords.line, column: coords.column },
expression: this.tokens.expression
}
);
}

// Parse the function body expression. We use parseConditionalExpression instead of
// parseExpression because arrow function bodies should be single expressions and
// should NOT consume semicolons. This allows patterns like: `fn = (a, b) => a + b; map(fn, arr)`
const bodyInstr: Instruction[] = [];
this.parseConditionalExpression(bodyInstr);

// Build the arrow function: push param names, body expression, and IARROW instruction
for (const param of params) {
instr.push(new Instruction(IVARNAME, param));
}
instr.push(new Instruction(IEXPR, bodyInstr));
instr.push(new Instruction(IARROW, params.length));

return true;
}

parseExpression(instr: Instruction[]): void {
const exprInstr: Instruction[] = [];

Expand Down
1 change: 1 addition & 0 deletions src/parsing/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ export class Parser {
'=': 'assignment',
'[': 'array',
'()=': 'fndef',
'=>': 'fndef',
'??': 'coalesce',
'as': 'conversion'
} as const;
Expand Down
Loading
Loading