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
22 changes: 9 additions & 13 deletions .claude/rules/comments.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@

```ts
//
// * Parser Utils
// * Event Handlers
//

/* ... */

//
// * Validators
//

/* ... */
```

- `// TODO: ` — actionable future work; start with an imperative verb, reference issue if possible
Expand All @@ -37,18 +45,6 @@
- Avoid commenting trivial code (obvious mappings, simple getters).
- Prefer JSDoc/TSDoc for public API functions instead of inline prose.
- Keep comments inside function bodies minimal — context belongs in tests or docs.
- Lines ≤ 80 characters, no emojis, complete sentences starting with a capital letter.

```ts
function binarySearch(haystack: number[], needle: number): number {
// Guard against unsorted input arrays
if (!isSorted(haystack)) {
// ! Sorting here would hide the caller's bug
throw new Error('Input must be pre-sorted');
}
// Standard binary-search implementation…
}
```

## Maintenance

Expand Down
3 changes: 3 additions & 0 deletions .claude/rules/rng.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ mock.nextInt(1, 6); // Throws! (sequence exhausted)
```

`MockRNG` MUST throw on exhaustion — this catches incorrect roll counts in tests.
`MockRNG.nextInt` validates that returned values fall within `[min, max]` — throws `RangeError` if not.

## Usage in Tests

```typescript
import { describe, it, expect } from 'bun:test';
// Internal tests use relative imports:
import { createMockRng } from '@/rng/mock';
// npm consumers use: import { createMockRng } from 'roll-parser/testing';

describe('roll', () => {
it('should roll exact values with MockRNG', () => {
Expand Down
2 changes: 2 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ describe('evaluate', () => {
Use `createMockRng` for all deterministic dice tests. See `rng.md`.

```typescript
// Internal tests use relative imports:
import { createMockRng } from '../rng/mock';
// npm consumers use: import { createMockRng } from 'roll-parser/testing';

test('keeps highest die from pool', () => {
const ast = parse('2d20kh1');
Expand Down
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.mjs",
"require": "./dist/testing.js"
}
},
"files": [
"dist",
"src",
"!src/**/*.test.ts",
"!src/rng/mock.ts"
"!src/**/*.test.ts"
],
"keywords": [
"parser",
Expand Down Expand Up @@ -51,9 +55,11 @@
"check": "bun typecheck && bun lint && bun format:check",
"check:fix": "bun typecheck && bun lint:fix && bun format",
"clean": "rm -rf dist coverage",
"build": "bun run clean && bun run build:esm && bun run build:cjs && bun run build:cli && bun run build:types",
"build": "bun run clean && bun run build:esm && bun run build:cjs && bun run build:esm:testing && bun run build:cjs:testing && bun run build:cli && bun run build:types",
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --target bun",
"build:cjs": "bun build src/index.ts --outfile dist/index.js --target node",
"build:esm:testing": "bun build src/testing.ts --outfile dist/testing.mjs --target bun",
"build:cjs:testing": "bun build src/testing.ts --outfile dist/testing.js --target node",
"build:cli": "bun build src/cli/index.ts --outfile dist/cli.js --target node",
"build:types": "tsc --emitDeclarationOnly -p tsconfig.build.json",
"test": "bun test",
Expand All @@ -67,7 +73,7 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"bun-types": "latest",
"@types/bun": "^1.2.0",
"fast-check": "^3.23.2",
"typescript": "^5.7.2"
},
Expand Down
10 changes: 2 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@
* @module cli/index
*/

import { EvaluatorError } from '../evaluator/evaluator';
import { isRollParserError } from '../errors';
import { VERSION } from '../index';
import { LexerError } from '../lexer/lexer';
import { ParseError } from '../parser/parser';
import { roll } from '../roll';
import { parseArgs } from './args';
import { formatResult } from './format';
Expand Down Expand Up @@ -65,11 +63,7 @@ function main(): void {
const output = formatResult(result, args.verbose);
process.stdout.write(`${output}\n`);
} catch (error) {
if (
error instanceof LexerError ||
error instanceof ParseError ||
error instanceof EvaluatorError
) {
if (isRollParserError(error)) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = 1;
return;
Expand Down
67 changes: 67 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Common error base class and error codes for roll-parser.
*
* @module errors
*/

/**
* All known roll-parser error codes. Single source of truth — the
* `RollParserErrorCode` type and the runtime `VALID_CODES` set are
* both derived from this array.
*
* Lexer: `UNEXPECTED_CHARACTER`, `UNEXPECTED_IDENTIFIER`
* Parser: `UNEXPECTED_TOKEN`, `UNEXPECTED_END`, `EXPECTED_TOKEN`
* Evaluator: `INVALID_DICE_COUNT`, `INVALID_DICE_SIDES`, `DICE_LIMIT_EXCEEDED`,
* `DIVISION_BY_ZERO`, `MODULO_BY_ZERO`, `UNKNOWN_OPERATOR`, `UNKNOWN_NODE_TYPE`,
* `INVALID_MODIFIER_COUNT`
*/
const ROLL_PARSER_ERROR_CODES = [
'UNEXPECTED_CHARACTER',
'UNEXPECTED_IDENTIFIER',
'UNEXPECTED_TOKEN',
'UNEXPECTED_END',
'EXPECTED_TOKEN',
'INVALID_DICE_COUNT',
'INVALID_DICE_SIDES',
'DICE_LIMIT_EXCEEDED',
'DIVISION_BY_ZERO',
'MODULO_BY_ZERO',
'UNKNOWN_OPERATOR',
'UNKNOWN_NODE_TYPE',
'INVALID_MODIFIER_COUNT',
] as const;

export type RollParserErrorCode = (typeof ROLL_PARSER_ERROR_CODES)[number];

/**
* Base error class for all roll-parser errors.
*
* Provides a typed `code` field for programmatic error handling.
* All library errors (`LexerError`, `ParseError`, `EvaluatorError`)
* extend this class.
*/
export class RollParserError extends Error {
readonly code: RollParserErrorCode;

constructor(message: string, code: RollParserErrorCode) {
super(message);
this.name = 'RollParserError';
this.code = code;
}
}

const VALID_CODES: Set<string> = new Set<string>(ROLL_PARSER_ERROR_CODES);

/**
* Type guard for roll-parser errors. Checks `instanceof` first, then
* falls back to duck-typing for cross-realm safety.
*/
export function isRollParserError(value: unknown): value is RollParserError {
if (value instanceof RollParserError) return true;
return (
value instanceof Error &&
'code' in value &&
typeof (value as RollParserError).code === 'string' &&
VALID_CODES.has((value as RollParserError).code)
);
}
35 changes: 25 additions & 10 deletions src/evaluator/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* @module evaluator/evaluator
*/

import type { RollParserErrorCode } from '../errors';
import { RollParserError } from '../errors';
import type { ASTNode, BinaryOpNode, DiceNode, ModifierNode, UnaryOpNode } from '../parser/ast';
import { isModifier } from '../parser/ast';
import type { RNG } from '../rng/types';
Expand All @@ -20,10 +22,13 @@ import {
/**
* Error thrown during AST evaluation.
*/
export class EvaluatorError extends Error {
constructor(message: string) {
super(message);
export class EvaluatorError extends RollParserError {
readonly nodeType: string | undefined;

constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
super(message, code);
this.name = 'EvaluatorError';
this.nodeType = nodeType ?? undefined;
}
}

Expand Down Expand Up @@ -105,7 +110,11 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb

default: {
const exhaustive: never = node;
throw new EvaluatorError(`Unknown node type: ${(exhaustive as ASTNode).type}`);
throw new EvaluatorError(
`Unknown node type: ${(exhaustive as ASTNode).type}`,
'UNKNOWN_NODE_TYPE',
(exhaustive as ASTNode).type,
);
}
}
}
Expand All @@ -131,15 +140,17 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): num
);

if (!Number.isInteger(count) || count < 0) {
throw new EvaluatorError(`Invalid dice count: ${count}`);
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
}
if (!Number.isInteger(sides) || sides < 1) {
throw new EvaluatorError(`Invalid dice sides: ${sides}`);
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
}

if (env.totalDiceRolled + count > env.maxDice) {
throw new EvaluatorError(
`Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
'DICE_LIMIT_EXCEEDED',
'Dice',
);
}
env.totalDiceRolled += count;
Expand Down Expand Up @@ -188,19 +199,19 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
return left * right;
case '/':
if (right === 0) {
throw new EvaluatorError('Division by zero');
throw new EvaluatorError('Division by zero', 'DIVISION_BY_ZERO', 'BinaryOp');
}
return left / right;
case '%':
if (right === 0) {
throw new EvaluatorError('Modulo by zero');
throw new EvaluatorError('Modulo by zero', 'MODULO_BY_ZERO', 'BinaryOp');
}
return left % right;
case '**':
return left ** right;
default: {
const exhaustive: never = node.operator;
throw new EvaluatorError(`Unknown operator: ${exhaustive}`);
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
}
}
}
Expand Down Expand Up @@ -237,7 +248,11 @@ function flattenModifierChain(
const modCount = evalNode(current.count, rng, countCtx, env);

if (!Number.isInteger(modCount) || modCount < 0) {
throw new EvaluatorError(`Invalid modifier count: ${modCount}`);
throw new EvaluatorError(
`Invalid modifier count: ${modCount}`,
'INVALID_MODIFIER_COUNT',
'Modifier',
);
}

const code =
Expand Down
14 changes: 9 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
* @module roll-parser
*/

// * Error hierarchy
export { RollParserError, isRollParserError } from './errors';
export type { RollParserErrorCode } from './errors';

// * Lexer exports
export { lex, Lexer, LexerError } from './lexer/lexer';
export { TokenType, type Token } from './lexer/tokens';
export { LexerError } from './lexer/lexer';

// * Parser exports
export { parse, Parser, ParseError } from './parser/parser';
export { parse, ParseError } from './parser/parser';
export type {
ASTNode,
BinaryOpNode,
Expand All @@ -29,7 +32,6 @@ export {
// * RNG exports
export type { RNG } from './rng/types';
export { SeededRNG } from './rng/seeded';
export { createMockRng, MockRNGExhaustedError } from './rng/mock';

// * Evaluator exports
export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator/evaluator';
Expand All @@ -39,4 +41,6 @@ export type { DieModifier, DieResult, EvaluateOptions, RollResult } from './type
export { roll } from './roll';
export type { RollOptions } from './roll';

export const VERSION = '3.0.0-alpha.0';
import pkg from '../package.json';

export const VERSION: string = pkg.version;
21 changes: 12 additions & 9 deletions src/lexer/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@
* @module lexer/lexer
*/

import type { RollParserErrorCode } from '../errors';
import { RollParserError } from '../errors';
import { type Token, TokenType } from './tokens';

/**
* Error thrown when the lexer encounters an invalid character.
*/
export class LexerError extends Error {
constructor(
message: string,
public readonly position: number,
public readonly character: string,
) {
super(`${message} at position ${position}: '${character}'`);
export class LexerError extends RollParserError {
readonly position: number;
readonly character: string;

constructor(message: string, code: RollParserErrorCode, position: number, character: string) {
super(`${message} at position ${position}: '${character}'`, code);
this.name = 'LexerError';
this.position = position;
this.character = character;
}
}

Expand Down Expand Up @@ -96,7 +99,7 @@ export class Lexer {
case ')':
return this.createTokenAt(TokenType.RPAREN, char, startPos);
default:
throw new LexerError('Unexpected character', startPos, char);
throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, char);
}
}

Expand Down Expand Up @@ -164,7 +167,7 @@ export class Lexer {
return this.createTokenAt(TokenType.KEEP_HIGH, char, startPos);
}

throw new LexerError('Unexpected identifier', startPos, char);
throw new LexerError('Unexpected identifier', 'UNEXPECTED_IDENTIFIER', startPos, char);
}

private peek(): string {
Expand Down
Loading
Loading