@claudiu-ceia/combine
v0.11.3
Published
Build typed parsers for complete strings and text streams, with pending results for incomplete input.
Maintainers
Readme
combine
Typed parser combinators for complete strings and text streams in TypeScript.
The same grammar can parse a finished value or an append-only stream. Streamed
parses can return pending when more input could change the result.
Use combine for streamed model output, DSLs, configuration formats, protocols, logs, and CLI output.
Complete input and streamed input
import {
createStreamingParser,
isPending,
parseAll,
trie,
} from "@claudiu-ceia/combine";
const delimiter = trie(["$", "$$"]);
const finite = parseAll(delimiter, "$$");
if (finite.success) console.log(finite.value); // "$$"
const stream = createStreamingParser(delimiter);
const first = stream.feed("$");
console.log(isPending(first)); // true
const second = stream.feed("$");
if (second.success) console.log(second.value); // "$$"After the first $, both $ and $$ are still possible. combine returns
pending. The second $ makes the result final.
success- the parser has a result for the matched prefixpending- more input or finalization is needed before decidingfailure- the current input cannot match
pending does not guarantee that the buffered prefix can eventually succeed.
Some parsers make conservative decisions while the source remains open.
Success completes a streaming session after a prefix match, even if unread text
remains. Compose the grammar with eof() when trailing input must fail.
Install
npm install @claudiu-ceia/combineAlternatives:
pnpm add @claudiu-ceia/combine
bun add @claudiu-ceia/combineDeno can import the package from JSR:
import { parseAll, str } from "jsr:@claudiu-ceia/combine";Why streamed text needs a pending state
$May become $ or $$.
```ts
const answer =May become a complete fenced code block.
<think>May become a configured tagged block.
Model responses often mix prose with structured fragments. A chunk can end in the middle of a delimiter, code fence, citation, or custom tag. Treating every current buffer as a complete document causes incorrect failures or repeated reinterpretation.
These inputs are unfinished, not malformed.
Streaming API
createStreamingParser(parser)creates one buffered parsing sessionfeed(chunk)appends text and returns the current resultfinish()closes the input and returns a final success or failureparseStream(parser, chunks)yields states for anAsyncIterable<string>parseStreamEach(parser, chunks, options)yields consecutive parsed valuesisPending(result)distinguishes unfinished input from failure
Streaming APIs retain at most 1,048,576 UTF-16 code units by default. Pass
{ maxBufferLength } to choose a smaller or larger limit, or explicitly use
{ maxBufferLength: Infinity } for trusted unbounded input. Exceeding the limit
produces a definitive failure without appending the rejected chunk.
The same combine grammar can be used for finite and streamed input. How early a
result finalizes depends on its parsers. Streaming-aware primitives such as
str, trie, digit, letter, space, and number can resolve before the
source ends. regex(...) waits for final input because a JavaScript regular
expression cannot prove that a match will not grow.
Custom parsers should preserve the supplied context and account for open input.
The current engine retains buffered input up to the configured limit and
reparses it after each chunk. It does not resume from a saved parser
continuation. finish() converts unresolved input into a final success or
failure.
Code fences show why terminators need to remain open across chunk boundaries:
import {
any,
anyChar,
createStreamingParser,
eof,
eol,
isPending,
many,
manyTill,
map,
seq,
str,
} from "@claudiu-ceia/combine";
const horizontalSpace = any(str(" "), str("\t"));
const closingFence = map(
seq(str("```"), many(horizontalSpace), any(eol(), eof())),
() => "```",
);
const codeLine = map(manyTill(anyChar(), eol()), (parts) => parts.join(""));
const fencedTypeScript = map(
seq(str("```ts"), eol(), manyTill(codeLine, closingFence)),
([, , lines]) => lines.slice(0, -1).join(""),
);
const block = createStreamingParser(fencedTypeScript);
const prefix = block.feed("```ts\nconst answer = 42;\n``");
console.log(isPending(prefix)); // true
const complete = block.feed("`\n");
if (complete.success) console.log(complete.value);The parser remains pending until a complete closing-fence line arrives. See the streaming guide for async sources, repeated values, boundaries, retained buffers, relative offsets, and custom parsers.
Building a grammar
Parsers compose into typed output without a grammar file or code-generation step.
import { map, parseAll, regex, seq, str } from "@claudiu-ceia/combine";
const predicate = map(
seq(
regex(/[A-Za-z][A-Za-z0-9_-]*/, "field"),
str(":"),
regex(/[A-Za-z0-9_-]+/, "value"),
),
([field, , value]) => ({ field, value }),
);
const result = parseAll(predicate, "status:open");
if (!result.success) throw new Error(result.expected);
console.log(result.value); // { field: "status", value: "open" }The complete query example
adds quoted values and parentheses, uses chainl1 for precedence and
associativity, defineLanguage for recursive productions, and context plus
cut for focused errors.
Errors and backtracking
Running the complete query grammar against this malformed input:
status:open AND owner:Produces this output from formatErrorReport:
expected value after ':' at line 1, column 23
1 | status:open AND owner:
| ^
in predicate at line 1, column 17
in expression at line 1, column 17
in query at line 1, column 1context(...) records which grammar productions were active. cut(...) marks
the value after owner: as required, so ordered choice does not backtrack and
replace the useful error with an unrelated alternative.
Built with combine
expparses expressions into a typed AST with source spans, then evaluates them against an explicit environmentts-ducklingscans free-form text for dates, URLs, countries, and sensitive identifierspii-maskusests-ducklingfor grammar-based PII detection and adds masking, structured-value traversal, and logger integrations
Repository examples:
examples/query.tscovers typed output, precedence, recursion, and committed errorsexamples/calculator.tsbuilds a typed arithmetic AST with precedence and source spansexamples/lisp.tsparses recursive lists with centralized trivia handling
Runtime support
The npm package supports Node 20+ and is ESM-only. CommonJS callers can load it
with await import("@claudiu-ceia/combine"). CI validates the package with Bun
1.4 and Node, then runs the same test suite and checks every JSR entrypoint with
Deno 2.
The root entrypoint exports the complete API. Specialized entrypoints are also available:
| Module | npm | JSR |
| ---------------------------- | ---------------------------------------- | -------------------------------------------- |
| Nondeterministic recognizers | @claudiu-ceia/combine/nondeterministic | jsr:@claudiu-ceia/combine/nondeterministic |
| Performance tracing | @claudiu-ceia/combine/perf | jsr:@claudiu-ceia/combine/perf |
| Streaming | @claudiu-ceia/combine/streaming | jsr:@claudiu-ceia/combine/streaming |
Benchmark methodology
The checked-in comparison parses equivalent recursive arithmetic grammars with
81 B, 1,795 B, and 14,384 B fixtures. CI runs three base and three candidate
passes with Bun 1.4 on the same GitHub-hosted ubuntu-latest runner, then
compares median p50 values. GitHub runner hardware can vary between runs.
The combine-only streaming fixture is an 8,227 B ASCII fenced block parsed as finite input, 64 B chunks, 512 B chunks, and one complete chunk. Chunked parses remain pending until the chunk containing the closing fence. The selected comparison libraries do not expose the same append-only parser lifecycle, so the suite does not make a general claim about their streaming capabilities.
bun run bench:verify
bun run bench:comparison
bun run bench:streamingSee the benchmark overview and comparison methodology for scope and limits.
Documentation
Development and license
bun install
bun run check
bun run build
bun run package:check
deno task checkbun run check runs formatting, linting, TypeScript, tests, and benchmark
correctness verification and is the primary development loop. deno task check
reuses the test suite through a Deno-only compatibility adapter and validates
the native TypeScript entrypoints. Deno is otherwise only required to publish
the JSR package.
combine is available under the MIT license.
