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
28 changes: 18 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands

```bash
npm test # run all tests once
npm test # run all tests once (vitest, typecheck enabled)
npm run test:watch # watch mode
npm run coverage # coverage report
npm run test:coverage # coverage report
npm run test:types # check the published type resolution (attw)

npm run build # compile to dist/
npm run watch # build in watch mode
npm run build:watch # build in watch mode

npm run lint # oxlint
npm run lint:fix # auto-fix lint issues
Expand All @@ -29,11 +30,11 @@ npx vitest run src/combinators/map.test.ts

## Architecture

ESM-only TypeScript parser combinator library. Five separate entry points, each compiled to its own `.mjs` bundle:
ESM-only TypeScript parser combinator library. Five entry points, built together so that code shared between them is emitted once as a common chunk rather than inlined into each:

| Entry | Path | Purpose |
| -------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- |
| `unitas` | `src/index.ts` → `src/core/` | Core types: `Parser<T>`, `Result<T>`, `success`, `failure`, `run`, `grammar`, `lazy`, `memoize` |
| `unitas` | `src/index.ts` → `src/core/` | Core types: `Parser<T>`, `Result<T>`, `success`/`failure`, `run`/`parse`, `grammar`, `lazy`, `memoize` |
| `unitas/terminals` | `src/terminals/` | Factory functions that match input directly (`char`, `regex`, `string`, `satisfy`, …) |
| `unitas/primitives` | `src/primitives/` | Pre-built parser instances (`digit`, `letters`, `whitespace`, …) |
| `unitas/combinators` | `src/combinators/` | Higher-order parsers that compose other parsers (`map`, `choice`, `sequence`, `many`, …) |
Expand All @@ -42,13 +43,20 @@ ESM-only TypeScript parser combinator library. Five separate entry points, each
### Core types (`src/core/`)

```typescript
type Parser<T> = (input: string) => Result<T>;
type Parser<T> = (input: string, index?: number, ctx?: Context) => Result<T>;
type Result<T> = Success<T> | Failure;
type Success<T> = { ok: true; value: T; remaining: string };
type Failure = { ok: false; error?: string };
type Success<T> = { ok: true; value: T; index: number };
type Failure = { ok: false; index: number; expected: readonly string[] };
type Context = { furthest: number; expected: readonly string[] };
```

`grammar` enables mutual recursion — rules receive a proxy object `p` so they can reference sibling rules by name without forward declarations. `lazy` defers evaluation for self-referential parsers.
Parsers read `input` from an offset and never slice it, so a position stays meaningful all the way up to the error message.

`ctx` is the parse context, created per `run`/`parse`. Failures record themselves into it via `failure(ctx, …)`, and because it only moves forward, backtracking cannot lose what a discarded branch expected — that is what lets an error point at the furthest position reached rather than wherever parsing stopped. **Any parser you call must be passed `ctx`**; omitting it still parses correctly but silently degrades the error message. `src/core/propagation.test.ts` checks every call site for this.

`not` and `label` deliberately run their inner parser against an isolated context, since they suppress the failure rather than report it.

`grammar` enables mutual recursion — rules receive a proxy object `p` so they can reference sibling rules by name without forward declarations, and a left-recursive rule is reported by name instead of overflowing the stack. `lazy` defers evaluation for self-referential parsers.

### File conventions

Expand All @@ -71,4 +79,4 @@ type Failure = { ok: false; error?: string };

- `src/**/*.test.ts` — unit tests co-located with source (vitest, typecheck enabled)
- `test/examples.test.ts` — generated from README examples, do not edit by hand
- `src/combinators/node.test.ts` — newly added, covers `node` (alias for `ast`)
- `src/core/propagation.test.ts` — guards that every parser call site forwards `ctx`; covers new files automatically
391 changes: 271 additions & 120 deletions README.md

Large diffs are not rendered by default.

60 changes: 45 additions & 15 deletions bin/generate-readme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,19 @@ const readmePath = path.join(rootDir, 'README.md');

const COMMENT_REGEX = /\/\*\*([\s\S]*?)\*\//g;

const DECLARATION_REGEX =
/^\s*export\s+(?:const|function|class|let|var)\s+([A-Za-z_$][\w$]*)/;

const CHEAT_SHEET_HEADING = '## Which function do I need?';

/** Covered by the "Core concepts" prose instead of the cheat sheet. */
const CHEAT_SHEET_EXEMPT = new Set(['failure', 'parser', 'run', 'success']);
const CHEAT_SHEET_EXEMPT = new Set([
'create',
'failure',
'reject',
'run',
'success',
]);

/**
* Module metadata. Order here drives the order of the API index in the README
Expand All @@ -27,44 +36,62 @@ const MODULES = [
key: 'core',
title: 'Core',
entry: 'unitas',
imports: 'run, parse, grammar',
tagline: 'Types, constructors and the grammar runner.',
intro:
'The core entry point holds everything a parser is built from: the `Parser<T>` and `Result<T>` types, the `success`/`failure` constructors, and the tools for running and wiring parsers together (`run`, `grammar`, `lazy`, `memoize`).',
intro: 'The core entry point holds everything a parser is built from: the `Parser<T>` and `Result<T>` types, the `success`/`failure` constructors, and the tools for running and wiring parsers together (`run`, `grammar`, `lazy`, `memoize`).',
},
{
key: 'terminals',
title: 'Terminals',
entry: 'unitas/terminals',
imports: 'char, string, regex',
tagline: 'Factories that match the input string directly.',
intro:
'Terminals are the leaves of a grammar. They do not take other parsers — they inspect the input string themselves. Each one is a factory: call it with what you want to match and it hands back a `Parser`.',
intro: 'Terminals are the leaves of a grammar. They do not take other parsers — they inspect the input string themselves. Each one is a factory: call it with what you want to match and it hands back a `Parser`.',
},
{
key: 'primitives',
title: 'Primitives',
entry: 'unitas/primitives',
imports: 'digit, letters, whitespace',
tagline: 'Ready-made parsers for the usual suspects.',
intro:
'Primitives are parser *instances*, not factories. Where a terminal needs an argument (`char("a")`), a primitive is already a parser and can be passed straight to a combinator (`many(digit)`).',
intro: 'Primitives are parser *instances*, not factories. Where a terminal needs an argument (`char("a")`), a primitive is already a parser and can be passed straight to a combinator (`many(digit)`).',
},
{
key: 'combinators',
title: 'Combinators',
entry: 'unitas/combinators',
imports: 'map, choice, sequence',
tagline: 'Take parsers, return a new parser.',
intro:
'Combinators are the glue. Every one of them takes one or more parsers and returns a new parser, which is what lets a grammar stay a set of small, independently testable pieces.',
intro: 'Combinators are the glue. Every one of them takes one or more parsers and returns a new parser, which is what lets a grammar stay a set of small, independently testable pieces.',
},
{
key: 'utils',
title: 'Utils',
entry: 'unitas/utils',
imports: 'pick, join, pipe',
tagline: 'Plain helpers for `map` callbacks.',
intro:
'Utils are not parsers. They are small curried helpers meant to be dropped into a `map` callback so reshaping a result stays a one-liner instead of an arrow function.',
intro: 'Utils are not parsers. They are small curried helpers meant to be dropped into a `map` callback so reshaping a result stays a one-liner instead of an arrow function.',
},
];

/**
* The exported name a doc block belongs to. One file may document several
* exports, so the file name is not enough — it would collide in the index and
* point every entry at the same anchor.
*/
function declaredName(source) {
const [, name] = source.match(DECLARATION_REGEX) || [];

return name;
}

/**
* `{@link foo}` means nothing to a markdown reader, so it becomes code.
*/
function resolveLinks(description) {
return description.replace(/\{@link\s+([^}\s|]+)[^}]*\}/g, '`$1`');
}

function extractDocs(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const docs = [];
Expand Down Expand Up @@ -98,10 +125,13 @@ function extractDocs(filePath) {
}

if (exampleLines.length > 0) {
const after = match.index + match[0].length;
docs.push({
description: descriptionParts.join(' '),
description: resolveLinks(descriptionParts.join(' ')),
example: exampleLines.join('\n'),
name: basename(filePath, '.ts'),
name:
declaredName(content.slice(after)) ??
basename(filePath, '.ts'),
});
}
}
Expand Down Expand Up @@ -204,6 +234,7 @@ function generateApiPage(module, items) {
return template
.replaceAll('<$title>', module.title)
.replaceAll('<$entry>', module.entry)
.replaceAll('<$imports>', module.imports)
.replaceAll('<$intro>', module.intro)
.replaceAll('<$count>', String(items.length))
.replaceAll('<$nav>', nav)
Expand Down Expand Up @@ -294,8 +325,7 @@ function warnAboutCheatSheetGaps(template, docsByNamespace) {
(docsByNamespace[key] || [])
.map(({ name }) => name)
.filter(
(name) =>
!mentioned.has(name) && !CHEAT_SHEET_EXEMPT.has(name),
(name) => !mentioned.has(name) && !CHEAT_SHEET_EXEMPT.has(name),
),
);

Expand Down
Loading
Loading