-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add math functions #37
Description
Add math functions: floor(), ceil(), round(), abs(), max(), min(). These enable rounding division results (resolving GAPS #5) and comparative rolls.
With the Stage 2 foundation (#34), the lexer already emits FUNCTION (carrying the function name as value) and COMMA tokens, and the scanIdentifier rewrite handles all function name keywords. This issue covers the AST node, parser, and evaluator work.
AST Changes
New node type in src/parser/ast.ts:
type FunctionCallNode = {
type: 'FunctionCall';
name: string;
args: ASTNode[];
};Add to ASTNode union. Add isFunctionCall type guard. Export from src/index.ts.
Parser Changes
Arity table (constant at module scope):
| Function | Min args | Max args |
|---|---|---|
floor |
1 | 1 |
ceil |
1 | 1 |
round |
1 | 1 |
abs |
1 | 1 |
max |
2 | Infinity |
min |
2 | Infinity |
NUD handler for TokenType.FUNCTION:
- Call
expect(TokenType.LPAREN) - Parse comma-separated argument expressions:
parseExpression(0), loop whilepeek() === COMMA - Call
expect(TokenType.RPAREN) - Validate arity against the table — throw
INVALID_FUNCTION_ARITYon mismatch - Return
FunctionCallNode
FUNCTION and COMMA already have BP = -1 (set in #34), so they act as expression terminators. Inside parseFunctionCall, parseExpression(0) for each argument stops at , or ).
No LED handler needed — functions are purely prefix (NUD).
Evaluator Changes
New evalFunctionCall function in src/evaluator/evaluator.ts:
- Evaluate each argument in its own
EvalContext - Merge all argument rolls into parent context
- Dispatch to
Math.floor,Math.ceil,Math.round,Math.abs,Math.max,Math.min - Build expression:
floor(1d6 / 3), rendered:floor(1d6[5] / 3) = 1
Add 'FunctionCall' case to evalNode switch.
Biome compliance note: values[0] returns number | undefined due to noNonNullAssertion. Use a guard with a defensive error (UNKNOWN_FUNCTION) even though the parser validated arity.
New Error Codes
Add to ROLL_PARSER_ERROR_CODES in src/errors.ts:
INVALID_FUNCTION_ARITY— wrong number of arguments (parser)UNKNOWN_FUNCTION— unrecognized function name (evaluator, defensive)
Division Rounding (GAPS #5)
This feature resolves gap #5: "Non-integer results from division are not handled."
roll('1d6/3') currently returns total: 1.333.... With math functions, users write floor(1d6/3) for integer results. This is the intended Stage 2 solution per the PRD.
Edge Cases
| Expression | Expected |
|---|---|
floor(10/3) |
3 |
ceil(10/3) |
4 |
round(10/3) |
3 (Math.round(3.333) = 3) |
round(2.5) |
3 (JS Math.round rounds .5 up) |
abs(-5) |
5 |
abs(1d4-5) with roll=1 |
4 (abs(1-5) = abs(-4) = 4) |
max(1d6, 1d8) |
Higher of two independent rolls |
min(10, 1d20+5) |
Capped damage at 10 |
floor(floor(10/3)/2) |
1 (nested: inner=3, outer=floor(1.5)=1) |
2*floor(1d6/2) |
Arithmetic precedence works naturally |
max(1, 2, 3) |
3 (variadic, 2+ args) |
FLOOR(10/3) |
3 (case-insensitive) |
floor (10/3) |
3 (whitespace between name and ( is tolerated) |
floor() |
ParseError: INVALID_FUNCTION_ARITY (0 < min 1) |
floor(1, 2) |
ParseError: INVALID_FUNCTION_ARITY (2 > max 1) |
max(1d6) |
ParseError: INVALID_FUNCTION_ARITY (1 < min 2) |
floor + 3 |
ParseError: expected LPAREN but got '+' |
Test Plan
- Lexer: all function names tokenize as FUNCTION (already covered in build: prepare Stage 2 foundation #34)
- Parser: AST structure for each function, arity validation errors, nested calls, variadic max/min
- Evaluator with MockRNG:
floor(1d6/3)with roll=5 → total=1max(1d6, 1d8)with rolls=[3, 7] → total=7abs(1d4-5)with roll=1 → total=4- Nested:
floor(max(1d6, 1d8))with rolls=[3, 7] → total=7
- Integration:
roll('floor(1d6/3)', { rng }),roll('max(1d6, 1d8)', { rng }) - Property:
floor(NdX/Y) <= NdX/Y,abs(expr) >= 0,max(a, b) >= min(a, b) - Error tests: wrong arity, unknown function (defensive)
Drafted with AI assistance