roll-parser 3.0.0-beta.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
package/package.json CHANGED
@@ -1,14 +1,12 @@
1
1
  {
2
2
  "name": "roll-parser",
3
- "version": "3.0.0-beta.0",
4
- "description": "High-performance dice notation parser for tabletop RPGs. TypeScript-first, Bun-optimized.",
3
+ "version": "3.0.0",
4
+ "description": "High-performance dice notation parser for tabletop RPGs. TypeScript-first, runs on Node, Bun, and browsers with zero dependencies.",
5
5
  "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
6
  "bin": {
9
- "roll-parser": "./dist/cli.js"
7
+ "roll-parser": "./dist/cli/index.js"
10
8
  },
11
- "sideEffects": false,
9
+ "sideEffects": ["./dist/cli/index.js"],
12
10
  "exports": {
13
11
  ".": {
14
12
  "types": "./dist/index.d.ts",
@@ -20,21 +18,33 @@
20
18
  },
21
19
  "./package.json": "./package.json"
22
20
  },
21
+ "unpkg": "./dist/index.js",
22
+ "jsdelivr": "./dist/index.js",
23
23
  "files": [
24
24
  "dist",
25
25
  "src",
26
26
  "CHANGELOG.md",
27
- "!src/**/*.test.ts"
27
+ "MIGRATION.md",
28
+ "!src/**/*.test.ts",
29
+ "!src/test-helpers.ts"
28
30
  ],
29
31
  "keywords": [
30
32
  "parser",
31
33
  "dice",
34
+ "dice-notation",
35
+ "dice-roller",
36
+ "dice-parser",
32
37
  "roll",
33
38
  "rpg",
39
+ "ttrpg",
40
+ "tabletop",
34
41
  "dnd",
35
42
  "d20",
36
- "pratt-parser",
37
43
  "typescript",
44
+ "esm",
45
+ "browser",
46
+ "zero-dependency",
47
+ "cli",
38
48
  "bun"
39
49
  ],
40
50
  "homepage": "https://github.com/edloidas/roll-parser#readme",
@@ -42,7 +52,7 @@
42
52
  "url": "https://github.com/edloidas/roll-parser/issues"
43
53
  },
44
54
  "license": "MIT",
45
- "author": "Mikita Taukachou <edloidas@gmail.com> (https://edloidas.com)",
55
+ "author": "Mikita Taukachou <edloidas@gmail.com> (https://edloidas.io)",
46
56
  "repository": {
47
57
  "type": "git",
48
58
  "url": "git+https://github.com/edloidas/roll-parser.git"
@@ -50,43 +60,82 @@
50
60
  "publishConfig": {
51
61
  "access": "public"
52
62
  },
63
+ "workspaces": ["scripts/browser-smoke", "scripts/docs"],
53
64
  "scripts": {
54
- "typecheck": "tsc --noEmit",
55
- "lint": "biome lint .",
56
- "lint:fix": "biome lint --write .",
57
- "format": "biome format --write .",
58
- "format:check": "biome format .",
59
- "check": "bun typecheck && bun lint && bun format:check",
60
- "check:fix": "bun typecheck && bun lint:fix && bun format",
65
+ "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || exit 0",
66
+ "typecheck": "tsc --noEmit && bun run build && tsc --noEmit -p site/tsconfig.json && tsc --noEmit -p scripts/tsconfig.json && tsc --noEmit -p bench/tsconfig.json",
67
+ "check": "bun typecheck && biome check .",
68
+ "check:fix": "bun typecheck && biome check --write .",
61
69
  "check:version": "bun scripts/check-version.ts",
62
70
  "check:changelog": "bun scripts/check-changelog.ts",
63
- "clean": "rm -rf dist coverage",
64
- "build": "bun run clean && bun run build:lib && bun run build:testing && bun run build:cli && bun run build:types",
65
- "build:lib": "bun build src/index.ts --outdir dist --target node --sourcemap=linked",
66
- "build:testing": "bun build src/testing.ts --outdir dist --target node --sourcemap=linked",
67
- "build:cli": "bun build src/cli/index.ts --outdir dist --entry-naming 'cli.[ext]' --target node --sourcemap=linked",
68
- "build:types": "tsc --emitDeclarationOnly -p tsconfig.build.json",
71
+ "generate:version": "bun scripts/generate-version.ts",
72
+ "clean": "rm -rf dist",
73
+ "build": "bun scripts/build.ts",
69
74
  "check:package": "attw --pack . --profile esm-only && publint",
70
- "bench": "bun run bench/roll.bench.ts",
75
+ "check:size": "size-limit",
76
+ "bench": "bun run bench/index.bench.ts",
77
+ "bench:lex": "bun run bench/lex.bench.ts",
78
+ "bench:parse": "bun run bench/parse.bench.ts",
79
+ "bench:evaluate": "bun run bench/evaluate.bench.ts",
80
+ "bench:roll": "bun run bench/roll.bench.ts",
81
+ "bench:json": "bun run scripts/bench-json.ts",
82
+ "bench:competitors": "cd bench/competitors && bun install && bun run all",
83
+ "site:build": "bun run build && bun scripts/build-site.ts",
84
+ "site:check": "bun scripts/check-site.ts",
85
+ "site:dev": "bun run build && bun ./site/index.html ./site/reference.html",
86
+ "site:preview": "bun scripts/serve-site.ts",
71
87
  "test": "bun test",
72
- "test:node": "node -e \"import('./dist/index.js').then(m => { const r = m.roll('1d6'); if (!(r.total >= 1 && r.total <= 6)) throw new Error('ESM smoke failed: total=' + r.total); console.log('Node.js ESM smoke: ok'); })\" && node -e \"const m = require('./dist/index.js'); const r = m.roll('1d6'); if (!(r.total >= 1 && r.total <= 6)) throw new Error('require(esm) smoke failed: total=' + r.total); console.log('Node.js require(esm) smoke: ok');\"",
73
88
  "test:watch": "bun test --watch",
74
89
  "test:ci": "bun test --bail --coverage",
75
- "validate": "bun run check && bun run build && bun run check:package && bun test:ci",
76
- "coverage": "bun test --coverage",
77
- "release:dry": "bun run check:version && bun run check:changelog && bun run check && bun run build && bun run check:package && bun test",
90
+ "validate": "bun run check && bun run build && bun run check:package && bun run check:size && bun run site:build && bun run site:check && bun test:ci",
91
+ "release:dry": "bun run check:version && bun run check:changelog && bun audit --audit-level=high && bun run clean && bun run validate",
78
92
  "prepublishOnly": "bun run release:dry"
79
93
  },
94
+ "nano-staged": {
95
+ "*.{ts,tsx}": "biome check --write --no-errors-on-unmatched"
96
+ },
97
+ "size-limit": [
98
+ {
99
+ "name": "index.js (full library)",
100
+ "path": "dist/index.js",
101
+ "limit": "12.5 kB"
102
+ },
103
+ {
104
+ "name": "index.js — { parse }",
105
+ "path": "dist/index.js",
106
+ "import": "{ parse }",
107
+ "limit": "6 kB"
108
+ },
109
+ {
110
+ "name": "index.js — { roll }",
111
+ "path": "dist/index.js",
112
+ "import": "{ roll }",
113
+ "limit": "12 kB"
114
+ },
115
+ {
116
+ "name": "testing.js — { createMockRng }",
117
+ "path": "dist/testing.js",
118
+ "import": "{ createMockRng }",
119
+ "limit": "250 B"
120
+ }
121
+ ],
80
122
  "devDependencies": {
81
- "@arethetypeswrong/cli": "^0.18.4",
82
- "@biomejs/biome": "^2.4.9",
83
- "@types/bun": "^1.3.0",
84
- "fast-check": "^4.6.0",
123
+ "@arethetypeswrong/cli": "^0.18.5",
124
+ "@biomejs/biome": "^2.5.6",
125
+ "@size-limit/preset-small-lib": "^13.0.2",
126
+ "@types/bun": "^1.3.14",
127
+ "esbuild": "^0.28.1",
128
+ "fast-check": "^4.9.0",
85
129
  "mitata": "^1.0.34",
86
- "publint": "^0.3.21",
87
- "typescript": "^6.0.2"
130
+ "nano-staged": "^1.0.2",
131
+ "publint": "^0.3.22",
132
+ "size-limit": "^13.0.2",
133
+ "typescript": "^7.0.2"
134
+ },
135
+ "overrides": {
136
+ "brace-expansion": "^5.0.9"
88
137
  },
89
138
  "engines": {
90
- "node": ">= 22.0.0"
139
+ "node": ">=22.12.0"
91
140
  }
92
141
  }
package/src/cli/args.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  export type CliArgs = {
13
13
  notation: string | undefined;
14
14
  verbose: boolean;
15
+ json: boolean;
15
16
  seed: string | undefined;
16
17
  showHelp: boolean;
17
18
  showVersion: boolean;
@@ -22,6 +23,46 @@ export type CliArgs = {
22
23
  */
23
24
  export type ParseArgsResult = { ok: true; args: CliArgs } | { ok: false; error: string };
24
25
 
26
+ /** Argument that stops option parsing — everything after it is notation. */
27
+ const TERMINATOR = '--';
28
+
29
+ /** Defaults every parse starts from. */
30
+ const BASE_ARGS: CliArgs = {
31
+ notation: undefined,
32
+ verbose: false,
33
+ json: false,
34
+ seed: undefined,
35
+ showHelp: false,
36
+ showVersion: false,
37
+ };
38
+
39
+ /**
40
+ * Finds `--help` / `--version` anywhere before the `--` terminator. Help wins
41
+ * over version regardless of order, and both win over usage errors — a user
42
+ * who mistyped an option is asking for the manual, not for a diagnostic.
43
+ */
44
+ function findInformationalFlag(argv: string[]): 'help' | 'version' | undefined {
45
+ let flag: 'help' | 'version' | undefined;
46
+
47
+ for (const arg of argv) {
48
+ if (arg === TERMINATOR) break;
49
+ if (arg === '--help' || arg === '-h') return 'help';
50
+ if (arg === '--version') flag = 'version';
51
+ }
52
+
53
+ return flag;
54
+ }
55
+
56
+ /**
57
+ * True for an argument that starts with `-` yet reads as notation rather than
58
+ * an option: negative numbers (`-3`) and negative-prefixed expressions
59
+ * (`-d6`, `-D6`, `-dF`, `-(2d6)`, `-{2d6}`, `-@str`). A fallback for users who
60
+ * do not reach for `--`; `--` remains the unambiguous form.
61
+ */
62
+ function isNegativeNotation(arg: string): boolean {
63
+ return arg.length > 1 && /^[\ddD({@]/.test(arg.slice(1));
64
+ }
65
+
25
66
  /**
26
67
  * Parses a raw argument array into typed CLI options.
27
68
  *
@@ -29,24 +70,38 @@ export type ParseArgsResult = { ok: true; args: CliArgs } | { ok: false; error:
29
70
  * @returns Parsed result or an error message for usage errors
30
71
  */
31
72
  export function parseArgs(argv: string[]): ParseArgsResult {
73
+ const informational = findInformationalFlag(argv);
74
+ if (informational != null) {
75
+ return {
76
+ ok: true,
77
+ args: {
78
+ ...BASE_ARGS,
79
+ showHelp: informational === 'help',
80
+ showVersion: informational === 'version',
81
+ },
82
+ };
83
+ }
84
+
32
85
  let verbose = false;
86
+ let json = false;
33
87
  let seed: string | undefined;
34
- let showHelp = false;
35
- let showVersion = false;
36
88
  const positional: string[] = [];
37
89
 
38
90
  for (let i = 0; i < argv.length; i++) {
39
91
  const arg = argv[i] as string;
40
92
 
41
- if (arg === '--help' || arg === '-h') {
42
- showHelp = true;
43
- } else if (arg === '--version') {
44
- showVersion = true;
93
+ if (arg === TERMINATOR) {
94
+ positional.push(...argv.slice(i + 1));
95
+ break;
45
96
  } else if (arg === '--verbose' || arg === '-v') {
46
97
  verbose = true;
98
+ } else if (arg === '--json') {
99
+ json = true;
47
100
  } else if (arg === '--seed') {
101
+ // A seed is an opaque string, so any non-empty next argument counts:
102
+ // `--seed -abc` is a valid seed, not a missing value.
48
103
  const next = argv[i + 1];
49
- if (next === undefined || (next.startsWith('-') && !/^-\d/.test(next))) {
104
+ if (next == null || next === '') {
50
105
  return { ok: false, error: 'Missing value for --seed' };
51
106
  }
52
107
  seed = next;
@@ -59,15 +114,16 @@ export function parseArgs(argv: string[]): ParseArgsResult {
59
114
  seed = value;
60
115
  } else if (arg.startsWith('--')) {
61
116
  return { ok: false, error: `Unknown option: ${arg}` };
62
- } else if (arg.startsWith('-') && arg.length > 1 && !/^[\ddD(]/.test(arg.slice(1))) {
63
- // Negative prefix notation (-3, -d6, -D6, -dF, -(2d6)) stays positional
117
+ } else if (arg.startsWith('-') && !isNegativeNotation(arg)) {
64
118
  return { ok: false, error: `Unknown option: ${arg}` };
65
119
  } else {
66
120
  positional.push(arg);
67
121
  }
68
122
  }
69
123
 
124
+ // Joined, not separate rolls — a shell splits `roll-parser 2d6 + 3` into three
125
+ // words and the user means one expression.
70
126
  const notation = positional.length > 0 ? positional.join(' ') : undefined;
71
127
 
72
- return { ok: true, args: { notation, verbose, seed, showHelp, showVersion } };
128
+ return { ok: true, args: { ...BASE_ARGS, notation, verbose, json, seed } };
73
129
  }
package/src/cli/format.ts CHANGED
@@ -8,17 +8,36 @@
8
8
 
9
9
  import type { RollResult } from '../types.js';
10
10
 
11
+ /**
12
+ * Output mode selectors for {@link formatResult}.
13
+ */
14
+ export type FormatOptions = {
15
+ /** Emit the whole result as compact JSON. Takes precedence over `verbose`. */
16
+ json?: boolean;
17
+ /** Show the detailed roll breakdown instead of the bare total. */
18
+ verbose?: boolean;
19
+ };
20
+
11
21
  /**
12
22
  * Formats a roll result for terminal display.
13
23
  *
14
24
  * In normal mode, returns just the total. In verbose mode, returns the
15
- * rendered breakdown with terminal-safe formatting for dropped dice.
25
+ * rendered breakdown with terminal-safe formatting for dropped dice. In JSON
26
+ * mode, returns the complete `RollResult` — including the structured `parts`
27
+ * tree — as a single-line `JSON.stringify` payload, where `degree` appears as
28
+ * its numeric `DegreeOfSuccess` value.
16
29
  *
17
30
  * @param result - The roll result to format
18
- * @param verbose - Whether to show the detailed breakdown
31
+ * @param options - Output mode; JSON wins when combined with verbose
19
32
  * @returns Formatted string for terminal output
20
33
  */
21
- export function formatResult(result: RollResult, verbose: boolean): string {
34
+ export function formatResult(result: RollResult, options: FormatOptions = {}): string {
35
+ const { json = false, verbose = false } = options;
36
+
37
+ if (json) {
38
+ return JSON.stringify(result);
39
+ }
40
+
22
41
  if (!verbose) {
23
42
  return String(result.total);
24
43
  }
package/src/cli/index.ts CHANGED
@@ -3,90 +3,33 @@
3
3
  /**
4
4
  * CLI entry point for roll-parser.
5
5
  *
6
+ * Owns the process-level wiring only; the command itself lives in
7
+ * `./main.js` so it stays testable without spawning a subprocess.
8
+ *
6
9
  * @module cli/index
7
10
  */
8
11
 
9
- import { isRollParserError } from '../errors.js';
10
- import { VERSION } from '../index.js';
11
- import { roll } from '../roll.js';
12
- import { parseArgs } from './args.js';
13
- import { formatResult } from './format.js';
14
-
15
- const HELP_TEXT = `roll-parser v${VERSION}
16
-
17
- Usage: roll-parser <notation> [options]
18
-
19
- Options:
20
- -h, --help Show this help message
21
- --version Show version number
22
- -v, --verbose Show detailed roll breakdown
23
- --seed <value> Use seed for reproducible rolls
24
-
25
- Examples:
26
- roll-parser 2d6+3
27
- roll-parser 4d6kh3 --verbose
28
- roll-parser 4d6dl1 --seed "character-str"
29
- `;
30
-
31
- /**
32
- * Prints the notation with a caret under the error position for errors that
33
- * carry one (LexerError/ParseError `position`, EvaluatorError `start`).
34
- * Skipped for multi-line notations and out-of-range positions to keep the
35
- * caret honest.
36
- */
37
- function writeErrorContext(notation: string, error: Error): void {
38
- const position =
39
- 'position' in error ? error.position : 'start' in error ? error.start : undefined;
40
- if (typeof position !== 'number' || !Number.isInteger(position)) return;
41
- if (notation.includes('\n') || position < 0 || position > notation.length) return;
42
-
43
- process.stderr.write(` ${notation}\n`);
44
- process.stderr.write(` ${' '.repeat(position)}^\n`);
45
- }
46
-
47
- function main(): void {
48
- const parsed = parseArgs(process.argv.slice(2));
49
-
50
- if (!parsed.ok) {
51
- process.stderr.write(`Error: ${parsed.error}\n`);
52
- process.stderr.write('Run "roll-parser --help" for usage.\n');
53
- process.exitCode = 2;
54
- return;
55
- }
56
-
57
- const { args } = parsed;
58
-
59
- if (args.showHelp) {
60
- process.stdout.write(HELP_TEXT);
61
- return;
62
- }
63
-
64
- if (args.showVersion) {
65
- process.stdout.write(`${VERSION}\n`);
66
- return;
67
- }
68
-
69
- if (args.notation == null) {
70
- process.stderr.write('Error: No dice notation provided.\n');
71
- process.stderr.write('Run "roll-parser --help" for usage.\n');
72
- process.exitCode = 2;
73
- return;
74
- }
75
-
76
- try {
77
- const options = args.seed != null ? { seed: args.seed } : {};
78
- const result = roll(args.notation, options);
79
- const output = formatResult(result, args.verbose);
80
- process.stdout.write(`${output}\n`);
81
- } catch (error) {
82
- if (isRollParserError(error)) {
83
- process.stderr.write(`Error: ${error.message}\n`);
84
- writeErrorContext(args.notation, error);
85
- process.exitCode = 1;
86
- return;
87
- }
88
- throw error;
89
- }
90
- }
91
-
92
- main();
12
+ import { main } from './main.js';
13
+
14
+ // Declared locally because the library build sets `types: []` — runtime globals
15
+ // are type errors everywhere, and this is the one place `process` is legitimate.
16
+ declare const process: {
17
+ argv: string[];
18
+ exitCode: number | undefined;
19
+ stdout: { write(text: string): void };
20
+ stderr: { write(text: string): void };
21
+ };
22
+
23
+ const exitCode = main({
24
+ argv: process.argv.slice(2),
25
+ stdout: (text) => {
26
+ process.stdout.write(text);
27
+ },
28
+ stderr: (text) => {
29
+ process.stderr.write(text);
30
+ },
31
+ });
32
+
33
+ // Left unset on success — assigning 0 would clobber an exit code a surrounding
34
+ // runtime hook may already have set.
35
+ if (exitCode !== 0) process.exitCode = exitCode;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CLI command implementation for roll-parser.
3
+ *
4
+ * Split out of `index.ts` so `main` and `writeErrorContext` can run
5
+ * in-process under the test runner — the shebang entry point stays a thin
6
+ * wrapper that owns `process.argv`, the real streams, and `process.exitCode`.
7
+ *
8
+ * @module cli/main
9
+ */
10
+
11
+ import { getErrorSpan, isRollParserError } from '../errors.js';
12
+ import { VERSION } from '../index.js';
13
+ import { roll } from '../roll.js';
14
+ import { parseArgs } from './args.js';
15
+ import { formatResult } from './format.js';
16
+
17
+ const HELP_TEXT = `roll-parser v${VERSION}
18
+
19
+ Usage: roll-parser [options] [--] <notation>
20
+
21
+ Options:
22
+ -h, --help Show this help message
23
+ --version Show version number
24
+ -v, --verbose Show detailed roll breakdown
25
+ --json Print the whole result as compact JSON (wins over --verbose)
26
+ --seed <value> Use seed for reproducible rolls
27
+ -- Treat every following argument as notation
28
+
29
+ JSON output:
30
+ Emits the complete result, including the structured "parts" tree. The
31
+ "degree" field (DegreeOfSuccess) serializes as a number: 0 critical failure,
32
+ 1 failure, 2 success, 3 critical success. Errors stay plain text on stderr.
33
+
34
+ Exit codes:
35
+ 0 Success
36
+ 1 Roll or parse error
37
+ 2 Usage error
38
+
39
+ Examples:
40
+ roll-parser 2d6+3
41
+ roll-parser 4d6kh3 --verbose
42
+ roll-parser 4d6dl1 --seed "character-str"
43
+ roll-parser "1d20+7 vs 25" --json
44
+ roll-parser -- -1d6+3
45
+ `;
46
+
47
+ /** Sink for one stream's worth of CLI output. */
48
+ export type WriteFn = (text: string) => void;
49
+
50
+ /** Everything `main` needs from the host process. */
51
+ export type CliEnv = {
52
+ /** Arguments after the interpreter and script path (`process.argv.slice(2)`). */
53
+ argv: string[];
54
+ stdout: WriteFn;
55
+ stderr: WriteFn;
56
+ };
57
+
58
+ /**
59
+ * Prints the notation with a caret under the error position. `getErrorSpan`
60
+ * normalizes the lexer/parser `position` and the evaluator `start`/`end`
61
+ * shapes, so no duck-typing is needed here. Skipped for multi-line notations
62
+ * and out-of-range positions to keep the caret honest.
63
+ */
64
+ export function writeErrorContext(notation: string, error: unknown, write: WriteFn): void {
65
+ const span = getErrorSpan(error);
66
+ if (span == null) return;
67
+ if (notation.includes('\n') || span.start > notation.length) return;
68
+
69
+ // Code points, not UTF-16 units — an astral character ('🎲') is two units but
70
+ // one column, and counting units shifts the caret right.
71
+ const column = [...notation.slice(0, span.start)].length;
72
+
73
+ write(` ${notation}\n`);
74
+ write(` ${' '.repeat(column)}^\n`);
75
+ }
76
+
77
+ /**
78
+ * Runs one CLI invocation and returns the process exit code: `0` on success,
79
+ * `1` for a roll-parser error, `2` for a usage error. Anything that is not a
80
+ * `RollParserError` propagates so the runtime reports it with a stack.
81
+ *
82
+ * `--json` swaps the success payload only — diagnostics stay plain text on
83
+ * stderr and the exit codes are identical, so scripts can branch on the code
84
+ * before parsing stdout.
85
+ */
86
+ export function main(env: CliEnv): number {
87
+ const { argv, stdout, stderr } = env;
88
+ const parsed = parseArgs(argv);
89
+
90
+ if (!parsed.ok) {
91
+ stderr(`Error: ${parsed.error}\n`);
92
+ stderr('Run "roll-parser --help" for usage.\n');
93
+ return 2;
94
+ }
95
+
96
+ const { args } = parsed;
97
+
98
+ if (args.showHelp) {
99
+ stdout(HELP_TEXT);
100
+ return 0;
101
+ }
102
+
103
+ if (args.showVersion) {
104
+ stdout(`${VERSION}\n`);
105
+ return 0;
106
+ }
107
+
108
+ if (args.notation == null) {
109
+ stderr('Error: No dice notation provided.\n');
110
+ stderr('Run "roll-parser --help" for usage.\n');
111
+ return 2;
112
+ }
113
+
114
+ try {
115
+ const options = args.seed != null ? { seed: args.seed } : {};
116
+ const result = roll(args.notation, options);
117
+ const output = formatResult(result, { json: args.json, verbose: args.verbose });
118
+ stdout(`${output}\n`);
119
+ } catch (error) {
120
+ if (isRollParserError(error)) {
121
+ stderr(`Error: ${error.message}\n`);
122
+ writeErrorContext(args.notation, error, stderr);
123
+ return 1;
124
+ }
125
+ throw error;
126
+ }
127
+
128
+ return 0;
129
+ }