Skip to content

feat: add math functions #37

@edloidas

Description

@edloidas

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:

  1. Call expect(TokenType.LPAREN)
  2. Parse comma-separated argument expressions: parseExpression(0), loop while peek() === COMMA
  3. Call expect(TokenType.RPAREN)
  4. Validate arity against the table — throw INVALID_FUNCTION_ARITY on mismatch
  5. 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:

  1. Evaluate each argument in its own EvalContext
  2. Merge all argument rolls into parent context
  3. Dispatch to Math.floor, Math.ceil, Math.round, Math.abs, Math.max, Math.min
  4. 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=1
    • max(1d6, 1d8) with rolls=[3, 7] → total=7
    • abs(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

Metadata

Metadata

Assignees

Labels

featureNew functionality

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions