diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b45106..74d792d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `generateScaffoldFiles(source, flags, options): Record` — new programmatic API that returns a map of filename → content; used by the CLI when `--outdir` is given ([#25](https://github.com/ConfiguredThings/RDP.js/issues/25)) +- `InterpreterMixin` and `WalkerMixin` exported from the main package — TypeScript mixin interfaces for interpreter and tree-walker traversal patterns + +### Fixed +- `--transformer json --facade --pipeline` now produces three separate scaffold files (`{base}-transformer.ts`, `{base}-pipeline.ts`, `{base}-facade.ts`) so the facade is a genuine module boundary; consumers import only from the facade ([#25](https://github.com/ConfiguredThings/RDP.js/issues/25)) + +### Changed + +- `--output ` renamed to `--outdir ` — the flag now accepts a directory; artifact filenames are derived from the parser base name (e.g. `date-facade.ts`, `date-transformer.ts`) ([#25](https://github.com/ConfiguredThings/RDP.js/issues/25)) +- `--traversal` alone now adds mixin stubs directly to the generated parser file (can be re-generated freely) rather than producing a separate scaffold file. Using `--traversal` together with `--facade`, `--pipeline`, or `--transformer` still produces a scaffold file + ## [0.6.0] - 2026-04-20 ### Added diff --git a/docs-site/content/guide/cli.mdx b/docs-site/content/guide/cli.mdx index 9cc0490..e604f94 100644 --- a/docs-site/content/guide/cli.mdx +++ b/docs-site/content/guide/cli.mdx @@ -41,7 +41,7 @@ rdp-gen [options] | Option | Default | Description | |--------|---------|-------------| | `` | (required) | Path to a `.ebnf` or `.abnf` grammar file | -| `-o, --output ` | stdout | Write output to a file instead of stdout | +| `-o, --outdir ` | stdout | Write output files to a directory; each artifact gets a derived filename | | `--format ` | inferred from extension | Force `ebnf` or `abnf` | | `--parser-name ` | `GeneratedParser` | Class name for the generated parser | | `--tree-name ` | `ParseTree` | Type name for the generated parse tree | @@ -56,16 +56,16 @@ rdp-gen [options] #### Examples -Generate a parser to a file: +Generate a parser to a directory (writes `src/JsonParser.ts`): ```bash -rdp-gen grammar.ebnf --parser-name JsonParser --output src/JsonParser.ts +rdp-gen grammar.ebnf --parser-name JsonParser --outdir src/ ``` Generate with tracing support: ```bash -rdp-gen grammar.ebnf --parser-name MyParser --observable --output src/MyParser.ts +rdp-gen grammar.ebnf --parser-name MyParser --observable --outdir src/ ``` Parse an ABNF grammar and write to stdout: @@ -76,9 +76,31 @@ rdp-gen protocol.abnf --format abnf --parser-name FrameParser --- +### Traversal stubs (`--traversal`) + +When used alone (without `--facade`, `--pipeline`, or `--transformer`), `--traversal` +adds evaluation scaffolding **directly to the generated parser file** — the output is +still a parser, not a separate scaffold. Because the stubs follow the grammar +mechanically, the file can be **regenerated** as the grammar evolves. + +| Flags | What it adds to the parser | +|---|---| +| `--traversal interpreter` | Parser class implements `InterpreterMixin` — one `eval{Rule}(node): unknown` stub per rule and a `static evaluate()` entry point | +| `--traversal tree-walker` | Exports a `walk(root, fn)` function alongside the existing `childNodes()` helper; includes a commented-out `Visitor` template covering every rule | + +```bash +# Generate (or regenerate) a parser with interpreter stubs baked in (writes src/DateParser.ts) +rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --outdir src/ +``` + +When `--traversal` is combined with `--facade`, `--pipeline`, or `--transformer`, the +traversal strategy moves _inside_ the scaffold — see [Scaffolding](#scaffolding) below. + +--- + ### Scaffolding -When any of `--traversal`, `--transformer`, `--facade`, `--pipeline`, or `--lexer` is +When any of `--transformer`, `--facade`, `--pipeline`, or `--lexer span` is present, `rdp-gen` emits a **scaffold** rather than a parser. A scaffold is a **one-time starter file** — unlike the generated parser it is not designed to be regenerated. It imports the parser by a relative path, wires up the chosen pattern, @@ -89,21 +111,17 @@ a real grammar. | Flags | What it emits | |---|---| -| `--traversal interpreter` | One typed `eval{Rule}()` function per rule, `evaluate()` entry, `RDParserException` wrapping | -| `--traversal tree-walker` | `walk()` utility using `childNodes`, commented `Visitor` stub for every rule | | `--traversal interpreter --facade` | Module-as-facade: `parse{Base}()`, `{Base}Result` class with `from()`, private eval functions | | `--traversal tree-walker --facade` | Module-as-facade: `parse{Base}()`, `{Base}Result` class, private walk utility and visitor stubs | | `--traversal tree-walker --pipeline` | Exported `parse`/`validate`/`transform` + `load{Base}()` combinator; tree-walker inside `transform` | -| `--traversal interpreter --pipeline --facade` | Module-as-facade wrapping a pipeline; eval inside `#transform` | -| `--traversal tree-walker --pipeline --facade` | As above but `#transform` uses a tree walker | +| `--traversal tree-walker --pipeline --facade` | Module-as-facade wrapping a pipeline with a tree walker inside `#transform` | | `--transformer` | Exported `Transformer` object with a stub per rule + entry function | | `--transformer json` | Two-way stubs: `{Base}ToJSON: Transformer` and `jsonTo{Base}: Transformer` plus round-trip helpers | | `--lexer span` | Span tokeniser + classifier + `{Base}TokenParser` stubs (see [Tokenising for performance](/docs/tokenising/)) | -| `--lexer span --traversal interpreter` | As above but `{Base}TokenParser` evaluates directly during descent — no intermediate tree | #### Flag compatibility -The scaffold flags are designed to compose. The table below shows which combinations are valid (`✓`), invalid (`✗`), or not applicable (`—`). The one constraint is that `--traversal interpreter` cannot be combined with `--pipeline` unless `--facade` is also set (the facade wraps a private pipeline whose `#transform` uses the interpreter, so an intermediate tree is still produced). +The scaffold flags are designed to compose. The table below shows which combinations are valid (`✓`), invalid (`✗`), or not applicable (`—`). The one constraint is that `--traversal interpreter` cannot be combined with `--pipeline` — the interpreter evaluates directly during traversal, leaving no intermediate tree for the validate stage. | | `--facade` | `--pipeline` | `--transformer [json]` | |---|:---:|:---:|:---:| @@ -113,37 +131,46 @@ The scaffold flags are designed to compose. The table below shows which combinat | `--facade` | — | ✓ | ✓ | | `--transformer [json]` | ✓ | — | — | -`--lexer span` is compatible with any of the above combinations (pass it alongside `--traversal interpreter` to wire evaluation directly into the token parser). +`--lexer span` is compatible with any of the above combinations. + +When `--transformer json` is combined with `--facade` (with or without `--pipeline`), +`--outdir` produces **separate files** for each concern so the facade is a genuine module boundary: + +```bash +# Writes: src/DateParser.ts, src/date-transformer.ts, src/date-pipeline.ts, src/date-facade.ts +rdp-gen date.abnf --parser-name DateParser --transformer json --facade --pipeline --lexer span --outdir src/ +``` The typical workflow for a new grammar: ```bash -# 1. Generate the parser (regenerate freely as the grammar evolves) -rdp-gen date.ebnf --parser-name DateParser --output src/DateParser.ts +# 1. Generate the parser (regenerate freely as the grammar evolves) → src/DateParser.ts +rdp-gen date.ebnf --parser-name DateParser --outdir src/ -# 2. Generate a scaffold once (edit this file; do not regenerate) -rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --facade --output src/date.ts +# 2. Generate a scaffold once (edit; do not regenerate) → src/date-facade.ts +rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --facade --outdir src/ ``` -The scaffold imports the parser by a relative path (`'./DateParser.js'`), so keep -both files in the same directory. +Both files land in the same directory, so the scaffold's import of `'./DateParser.js'` resolves correctly. -#### Interpreter scaffold +#### Interpreter traversal stubs -Emits one `eval{Rule}(node: {Rule}Node): unknown` function per rule, an `evaluate()` -entry point that calls the first rule, and wraps `RDParserException` in a plain `Error`: +Adds `InterpreterMixin` to the parser class, emitting one +`eval{Rule}(node: {Rule}Node): unknown` stub per rule and a `static evaluate()` entry +point. The file header notes that stubs are present and safe to edit; the parser +structure itself can still be regenerated: ```bash -rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --output src/date-eval.ts +rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --outdir src/ ``` -#### Tree-walker scaffold +#### Tree-walker stubs -Emits a `walk(root, fn)` utility using `childNodes` (always present in the generated -parser), plus a commented-out `Visitor` stub covering every rule in the grammar: +Exports a `walk(root, fn)` function alongside the existing `childNodes()` helper, +plus a commented-out `Visitor` template covering every rule in the grammar: ```bash -rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --output src/date-walker.ts +rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --outdir src/ ``` #### Facade scaffold @@ -153,17 +180,14 @@ class with a `static from(tree)` factory, and a `{Base}Error` class. Combine wit `--traversal` to specify the strategy used inside the facade: ```bash -# Facade wrapping recursive interpreter functions -rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --facade --output src/date.ts - -# Facade wrapping a tree walker -rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --facade --output src/date.ts +# Facade wrapping recursive interpreter functions → src/date-facade.ts +rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --facade --outdir src/ -# Facade wrapping a full pipeline (interpreter inside #transform) -rdp-gen date.ebnf --parser-name DateParser --traversal interpreter --pipeline --facade --output src/date.ts +# Facade wrapping a tree walker → src/date-facade.ts +rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --facade --outdir src/ -# Facade wrapping a full pipeline (tree walker inside #transform) -rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --pipeline --facade --output src/date.ts +# Facade wrapping a full pipeline (tree walker inside #transform) → src/date-facade.ts +rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --pipeline --facade --outdir src/ ``` #### Pipeline scaffold @@ -175,8 +199,8 @@ inside `transform`. Note that `--traversal interpreter` is not compatible with ` (see [flag compatibility](#flag-compatibility) above): ```bash -# Pipeline with tree walker inside transform -rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --pipeline --output src/date-pipeline.ts +# Pipeline with tree walker inside transform → src/date-pipeline.ts +rdp-gen date.ebnf --parser-name DateParser --traversal tree-walker --pipeline --outdir src/ ``` #### Transformer scaffold @@ -187,7 +211,7 @@ concrete return type, then fill in the handlers. Because `Transformer` requires adding a new rule to the grammar will immediately cause a compile error in the scaffold: ```bash -rdp-gen date.ebnf --parser-name DateParser --transformer --output src/date-transformer.ts +rdp-gen date.ebnf --parser-name DateParser --transformer --outdir src/ ``` See [`Transformer` and `transform()`](/docs/parse-tree/#transformer-parsetree-t-and-transform) @@ -200,10 +224,10 @@ functions. This is the recommended starting point when JSON is one endpoint of y translation: ```bash -rdp-gen date.ebnf --parser-name DateParser --transformer json --output src/date-json.ts +rdp-gen date.ebnf --parser-name DateParser --transformer json --outdir src/ ``` -The generated file contains: +Writes `src/date-transformer.ts` containing: - `dateToJSON: Transformer` — one stub per grammar rule - `jsonToDate: Transformer` — one stub per JSON kind (`string`, `number`, `boolean`, `null`, `array`, `object`) - `dateToJSONString(input: string): string` — parses the input and calls `fromJSONAST` diff --git a/docs-site/src/components/CopyCodeBlock.tsx b/docs-site/src/components/CopyCodeBlock.tsx new file mode 100644 index 0000000..f193372 --- /dev/null +++ b/docs-site/src/components/CopyCodeBlock.tsx @@ -0,0 +1,31 @@ +import React, { useRef, useState } from 'react' + +type Props = React.HTMLAttributes + +export function CopyCodeBlock({ children, ...props }: Props) { + const preRef = useRef(null) + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + const text = preRef.current?.textContent ?? '' + void navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( +
+
+        {children}
+      
+ +
+ ) +} diff --git a/docs-site/src/styles/global.css b/docs-site/src/styles/global.css index 9184f50..b3f24be 100644 --- a/docs-site/src/styles/global.css +++ b/docs-site/src/styles/global.css @@ -387,6 +387,40 @@ a:hover { color: var(--color-link-hover); text-decoration: underline; } } /* ── Shiki code blocks ────────────────────────────────────────────────────── */ +.doc-content .code-block-wrapper { + position: relative; +} + +.doc-content .code-block-wrapper:not(:hover) .code-copy-btn:not(.code-copy-btn--copied) { + opacity: 0; +} + +.code-copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.2rem 0.55rem; + font-size: 0.75rem; + font-family: var(--font-sans); + line-height: 1.5; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text-muted); + cursor: pointer; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} + +.code-copy-btn:hover { + background: var(--color-border); + color: var(--color-text); +} + +.code-copy-btn--copied { + color: var(--color-accent); + border-color: var(--color-accent); +} + .doc-content pre { margin: 1.25rem 0; border-radius: 10px; diff --git a/docs-site/src/templates/doc-page.tsx b/docs-site/src/templates/doc-page.tsx index c20a9f8..568c283 100644 --- a/docs-site/src/templates/doc-page.tsx +++ b/docs-site/src/templates/doc-page.tsx @@ -6,6 +6,7 @@ import { SiteHeader } from '../components/SiteHeader' import { DocRailroadDiagram } from '../components/DocRailroadDiagram' import { BenchmarkChart } from '../components/BenchmarkChart' import { TableOfContents } from '../components/TableOfContents' +import { CopyCodeBlock } from '../components/CopyCodeBlock' interface PageContext { id: string @@ -23,6 +24,7 @@ const mdxComponents = { img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => ( {alt} ), + pre: CopyCodeBlock, RailroadDiagram: DocRailroadDiagram, BenchmarkChart, } diff --git a/package.json b/package.json index 421d097..4a2d154 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,10 @@ "npm": ">=10" }, "scripts": { + "clean": "rm -rf dist", "generate:grammars": "node scripts/generate-grammar-index.mjs", "pretest": "npm run generate:grammars", - "prebuild": "npm run generate:grammars", + "prebuild": "npm run clean && npm run generate:grammars", "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/.bin/jest", "lint": "eslint src docs-site/src", diff --git a/src/__tests__/generator/codegen.test.ts b/src/__tests__/generator/codegen.test.ts index b48d08e..133de9f 100644 --- a/src/__tests__/generator/codegen.test.ts +++ b/src/__tests__/generator/codegen.test.ts @@ -444,3 +444,73 @@ describe('generated walker — runtime correctness', () => { ]) }) }) + +// ── traversal: interpreter ──────────────────────────────────────────────────── + +const DATE_GRAMMAR = `Date = Year, '-', Month, '-', Day;\nYear = Digit, Digit, Digit, Digit;\nMonth = Digit, Digit;\nDay = Digit, Digit;\nDigit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9';` +const PARSER_NAME = 'DateParser' + +describe('generateParser — traversal: interpreter', () => { + const src = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME, traversal: 'interpreter' }) + + it('emits scaffold header (not regenerated)', () => { + expect(src).toContain('not regenerated; edit freely') + }) + + it('imports InterpreterMixin from @configuredthings/rdp.js', () => { + expect(src).toMatch(/import \{[^}]*InterpreterMixin[^}]*\} from '@configuredthings\/rdp\.js'/) + }) + + it('class implements InterpreterMixin', () => { + expect(src).toContain('implements InterpreterMixin') + }) + + it('emits one eval stub per grammar rule', () => { + expect(src).toContain('evalDate(node: DateNode): unknown') + expect(src).toContain('evalYear(node: YearNode): unknown') + expect(src).toContain('evalMonth(node: MonthNode): unknown') + expect(src).toContain('evalDay(node: DayNode): unknown') + expect(src).toContain('evalDigit(node: DigitNode): unknown') + }) + + it('emits static evaluate() entry point', () => { + expect(src).toContain('static evaluate(input: string): unknown') + expect(src).toContain('parser.evalDate(parser.parse())') + }) + + it('type-checks without errors', () => { + expect(typeCheck(src)).toEqual([]) + }) +}) + +// ── traversal: tree-walker ──────────────────────────────────────────────────── + +describe('generateParser — traversal: tree-walker', () => { + const src = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME, traversal: 'tree-walker' }) + + it('emits scaffold header (not regenerated)', () => { + expect(src).toContain('not regenerated; edit freely') + }) + + it('imports visit and Visitor from @configuredthings/rdp.js', () => { + expect(src).toMatch(/import \{[^}]*visit[^}]*\} from '@configuredthings\/rdp\.js'/) + expect(src).toMatch(/import \{[^}]*Visitor[^}]*\} from '@configuredthings\/rdp\.js'/) + }) + + it('exports walk()', () => { + expect(src).toContain('export function walk(root: ParseTree') + }) + + it('walk() uses childNodes for recursion', () => { + expect(src).toContain('for (const child of childNodes(root)) walk(child, fn)') + }) + + it('includes visitor template comment', () => { + expect(src).toContain(`// const visitor: Visitor = {`) + expect(src).toContain(`// 'Date': (node) => { /* ... */ },`) + }) + + it('type-checks without errors', () => { + expect(typeCheck(src)).toEqual([]) + }) +}) diff --git a/src/__tests__/generator/scaffold.test.ts b/src/__tests__/generator/scaffold.test.ts index e3378b2..43d46a2 100644 --- a/src/__tests__/generator/scaffold.test.ts +++ b/src/__tests__/generator/scaffold.test.ts @@ -1,103 +1,100 @@ import { generateScaffold, generateInitScaffold } from '../../generator/scaffold.js' import { generateParser } from '../../generator/codegen.js' -import { importScaffold } from '../../__testUtils__/generator-runtime.js' +import { importScaffold, compileAndImport } from '../../__testUtils__/generator-runtime.js' const DATE_GRAMMAR = `Date = Year, '-', Month, '-', Day;\nYear = Digit, Digit, Digit, Digit;\nMonth = Digit, Digit;\nDay = Digit, Digit;\nDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';` const PARSER_NAME = 'DateParser' // ── Error cases ─────────────────────────────────────────────────────────────── describe('generateScaffold — error cases', () => { - it('throws when no scaffold flags are provided', () => { - expect(() => generateScaffold(DATE_GRAMMAR, {}, { parserName: PARSER_NAME })).toThrow( - /At least one of --traversal, --transformer, or --lexer must be provided/, - ) + it('delegates to generateParser when no scaffold flags are provided', () => { + const direct = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME }) + const via = generateScaffold(DATE_GRAMMAR, {}, { parserName: PARSER_NAME }) + expect(via).toBe(direct) }) - it('throws when --traversal interpreter is combined with --pipeline without --facade', () => { + it('throws when --traversal interpreter is combined with --pipeline (with or without --facade)', () => { + const msg = /--traversal interpreter cannot be combined with --pipeline/ expect(() => generateScaffold( DATE_GRAMMAR, { traversal: 'interpreter', pipeline: true }, { parserName: PARSER_NAME }, ), - ).toThrow(/--traversal interpreter cannot be combined with --pipeline without --facade/) - }) -}) - -// ── Evaluator scaffold ──────────────────────────────────────────────────────── - -describe('generateScaffold — interpreter', () => { - let output: string - - beforeAll(() => { - output = generateScaffold( - DATE_GRAMMAR, - { traversal: 'interpreter' }, - { parserName: PARSER_NAME }, - ) + ).toThrow(msg) + expect(() => + generateScaffold( + DATE_GRAMMAR, + { traversal: 'interpreter', pipeline: true, facade: true }, + { parserName: PARSER_NAME }, + ), + ).toThrow(msg) }) - it('imports the parser class and every node type', () => { - expect(output).toContain(`import {`) - expect(output).toContain(` ${PARSER_NAME},`) - expect(output).toContain(` type DateNode,`) - expect(output).toContain(` type YearNode,`) - expect(output).toContain(` type DigitNode,`) - expect(output).toContain(`} from './${PARSER_NAME}.js'`) + it('throws when --traversal and --transformer are both provided (interpreter + standard)', () => { + expect(() => + generateScaffold( + DATE_GRAMMAR, + { traversal: 'interpreter', transformer: 'standard' }, + { parserName: PARSER_NAME }, + ), + ).toThrow(/--traversal and --transformer are mutually exclusive/) }) - it('emits an evaluate() entry point that calls the first rule', () => { - expect(output).toContain(`export function evaluate(input: string): unknown`) - expect(output).toContain(`evalDate(${PARSER_NAME}.parse(input))`) + it('throws when --traversal and --transformer are both provided (interpreter + json)', () => { + expect(() => + generateScaffold( + DATE_GRAMMAR, + { traversal: 'interpreter', transformer: 'json' }, + { parserName: PARSER_NAME }, + ), + ).toThrow(/--traversal and --transformer are mutually exclusive/) }) - it('emits one eval function per grammar rule', () => { - expect(output).toContain(`function evalDate(node: DateNode): unknown`) - expect(output).toContain(`function evalYear(node: YearNode): unknown`) - expect(output).toContain(`function evalMonth(node: MonthNode): unknown`) - expect(output).toContain(`function evalDay(node: DayNode): unknown`) - expect(output).toContain(`function evalDigit(node: DigitNode): unknown`) + it('throws when --traversal and --transformer are both provided (tree-walker + standard)', () => { + expect(() => + generateScaffold( + DATE_GRAMMAR, + { traversal: 'tree-walker', transformer: 'standard' }, + { parserName: PARSER_NAME }, + ), + ).toThrow(/--traversal and --transformer are mutually exclusive/) }) - it('wraps RDParserException in the entry point', () => { - expect(output).toContain(`RDParserException`) + it('throws when --traversal and --transformer are both provided (tree-walker + json)', () => { + expect(() => + generateScaffold( + DATE_GRAMMAR, + { traversal: 'tree-walker', transformer: 'json' }, + { parserName: PARSER_NAME }, + ), + ).toThrow(/--traversal and --transformer are mutually exclusive/) }) }) -// ── Walker scaffold ─────────────────────────────────────────────────────────── - -describe('generateScaffold — tree-walker', () => { - let output: string +// ── Standalone traversal routes through generateParser via generateScaffold ─── - beforeAll(() => { - output = generateScaffold( +describe('generateScaffold — standalone traversal delegates to generateParser', () => { + it('interpreter: emits implements InterpreterMixin and eval stubs in the parser class', () => { + const output = generateScaffold( DATE_GRAMMAR, - { traversal: 'tree-walker' }, + { traversal: 'interpreter' }, { parserName: PARSER_NAME }, ) + expect(output).toContain('implements InterpreterMixin') + expect(output).toContain('evalDate(node: DateNode): unknown') + expect(output).toContain('static evaluate(input: string): unknown') }) - it('imports childNodes and ParseTree from the parser module', () => { - expect(output).toContain(`childNodes`) - expect(output).toContain(`type ParseTree`) - expect(output).toContain(`'./${PARSER_NAME}.js'`) - }) - - it('imports visit and Visitor from the runtime', () => { - expect(output).toContain(`visit`) - expect(output).toContain(`type Visitor`) - expect(output).toContain(`'@configuredthings/rdp.js'`) - }) - - it('emits a walk() utility function', () => { - expect(output).toContain(`export function walk(root: ParseTree`) + it('tree-walker: emits walk() export and visitor template in the parser file', () => { + const output = generateScaffold( + DATE_GRAMMAR, + { traversal: 'tree-walker' }, + { parserName: PARSER_NAME }, + ) + expect(output).toContain('export function walk(root: ParseTree') expect(output).toContain(`for (const child of childNodes(root)) walk(child, fn)`) - }) - - it('includes a commented-out visitor stub for every rule', () => { - for (const rule of ['Date', 'Year', 'Month', 'Day', 'Digit']) { - expect(output).toContain(`'${rule}':`) - } + expect(output).toContain(`// const visitor: Visitor = {`) }) }) @@ -200,63 +197,6 @@ describe('generateScaffold — facade + tree-walker', () => { }) }) -// ── Facade + pipeline:interpreter scaffold ────────────────────────────────────── - -describe('generateScaffold — facade + pipeline:interpreter', () => { - let output: string - - beforeAll(() => { - output = generateScaffold( - DATE_GRAMMAR, - { traversal: 'interpreter', pipeline: true, facade: true }, - { parserName: PARSER_NAME }, - ) - }) - - it('imports all node types (needed by private eval functions)', () => { - expect(output).toContain(` type DateNode,`) - expect(output).toContain(` type YearNode,`) - expect(output).toContain(` type DigitNode,`) - }) - - it('emits a DateResult class with static from()', () => { - expect(output).toContain(`export class DateResult`) - expect(output).toContain(`static from(tree: DateNode): DateResult`) - }) - - it('emits a DateError class', () => { - expect(output).toContain(`export class DateError extends Error`) - }) - - it('emits a parseDate() entry function that delegates to DatePipeline.run()', () => { - expect(output).toContain(`export function parseDate(input: string): DateResult`) - expect(output).toContain(`DatePipeline.run(input)`) - }) - - it('emits a private DatePipeline class with static private stages', () => { - expect(output).toContain(`class DatePipeline`) - expect(output).toContain(`static run(input: string): DateResult`) - expect(output).toContain(`static #parse(input: string): DateNode`) - expect(output).toContain(`static #validate(`) - expect(output).toContain(`static #transform(tree: DateNode): DateResult`) - }) - - it('#validate returns a discriminated union with no errors array', () => { - expect(output).toContain(`ok: true; tree: DateNode`) - expect(output).toContain(`ok: false }`) - expect(output).not.toContain(`errors: ValidationError`) - }) - - it('pipeline throws DateError on parse failure', () => { - expect(output).toContain(`throw new DateError(input)`) - }) - - it('emits private eval functions for every rule', () => { - expect(output).toContain(`function evalDate(node: DateNode): unknown`) - expect(output).toContain(`function evalYear(node: YearNode): unknown`) - }) -}) - // ── Facade + pipeline:tree-walker scaffold ───────────────────────────────────────── describe('generateScaffold — facade + pipeline:tree-walker', () => { @@ -368,21 +308,18 @@ describe('interpreter scaffold — runtime', () => { let evaluate: (input: string) => unknown beforeAll(async () => { - const parserSource = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME }) - const scaffoldSource = generateScaffold( + const source = generateScaffold( DATE_GRAMMAR, { traversal: 'interpreter' }, - { - parserName: PARSER_NAME, - }, + { parserName: PARSER_NAME }, ) - const { scaffold } = await importScaffold(scaffoldSource, parserSource, PARSER_NAME) - evaluate = scaffold['evaluate'] as (input: string) => unknown + const mod = await compileAndImport(source) + const cls = mod[PARSER_NAME] as { evaluate: (input: string) => unknown } + evaluate = cls.evaluate.bind(cls) }) - it('wraps a parse failure in a plain Error (not RDParserException)', () => { - expect(() => evaluate('not-a-date')).toThrow(Error) - expect(() => evaluate('not-a-date')).not.toThrow('RDParserException') + it('evaluates by calling DateParser.parse() internally', () => { + expect(() => evaluate('not-a-date')).toThrow() }) it('reaches the not-implemented stub on valid input', () => { @@ -397,17 +334,15 @@ describe('tree-walker scaffold — runtime', () => { let DateParser: { parse(s: string): { kind: string } } beforeAll(async () => { - const parserSource = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME }) - const scaffoldSource = generateScaffold( + // walk() is now exported directly from the parser file (traversal mixin) + const source = generateScaffold( DATE_GRAMMAR, { traversal: 'tree-walker' }, - { - parserName: PARSER_NAME, - }, + { parserName: PARSER_NAME }, ) - const { scaffold, parser } = await importScaffold(scaffoldSource, parserSource, PARSER_NAME) - walk = scaffold['walk'] as typeof walk - DateParser = parser[PARSER_NAME] as typeof DateParser + const mod = await compileAndImport(source) + walk = mod['walk'] as typeof walk + DateParser = mod[PARSER_NAME] as typeof DateParser }) it('walk() visits every non-terminal node in "2024-01-15"', () => { @@ -490,33 +425,6 @@ describe('facade + tree-walker scaffold — runtime', () => { }) }) -// ── Scaffold runtime — facade + pipeline:interpreter ──────────────────────────── - -describe('facade + pipeline:interpreter scaffold — runtime', () => { - let parseDate: (input: string) => unknown - let DateError: new (input: string) => Error - - beforeAll(async () => { - const parserSource = generateParser(DATE_GRAMMAR, { parserName: PARSER_NAME }) - const scaffoldSource = generateScaffold( - DATE_GRAMMAR, - { traversal: 'interpreter', pipeline: true, facade: true }, - { parserName: PARSER_NAME }, - ) - const { scaffold } = await importScaffold(scaffoldSource, parserSource, PARSER_NAME) - parseDate = scaffold['parseDate'] as (input: string) => unknown - DateError = scaffold['DateError'] as new (input: string) => Error - }) - - it('throws DateError on invalid input (from #parse stage)', () => { - expect(() => parseDate('not-a-date')).toThrow(DateError) - }) - - it('reaches not-implemented on valid input (from #transform)', () => { - expect(() => parseDate('2024-01-15')).toThrow('not implemented') - }) -}) - // ── Scaffold runtime — facade + pipeline:tree-walker ─────────────────────────────── describe('facade + pipeline:tree-walker scaffold — runtime', () => { @@ -735,6 +643,161 @@ describe('json-transformer scaffold — runtime', () => { }) }) +// ── Span-lexer scaffold (standalone) ───────────────────────────────────────── + +describe('generateScaffold — span-lexer (standalone)', () => { + let output: string + + beforeAll(() => { + output = generateScaffold(DATE_GRAMMAR, { lexer: 'span' }, { parserName: PARSER_NAME }) + }) + + it('emits a TokenRDParser subclass named after parserName', () => { + expect(output).toContain(`export class ${PARSER_NAME} extends TokenRDParser`) + }) + + it('emits a TT token-type constant with NAME, INT, EOF entries', () => { + expect(output).toContain(`export const TT = {`) + expect(output).toContain(`NAME:`) + expect(output).toContain(`INT:`) + expect(output).toContain(`EOF:`) + }) + + it('emits a MINUS punctuation token for the "-" terminal in the grammar', () => { + expect(output).toContain(`MINUS:`) + }) + + it('emits a spanTokenize() function', () => { + expect(output).toContain(`export function spanTokenize(input: string): SpanBuffer`) + }) + + it('emits a classify() function', () => { + expect(output).toContain(`export function classify(input: string,`) + }) + + it('emits a #parse stub for every grammar rule', () => { + for (const rule of ['Date', 'Year', 'Month', 'Day', 'Digit']) { + expect(output).toContain(`#parse${rule}(): unknown`) + } + }) + + it('emits parse-tree type declarations', () => { + expect(output).toContain(`export type DateNode =`) + expect(output).toContain(`export type ParseTree =`) + }) + + it('does not contain any planScaffold patterns', () => { + expect(output).not.toContain(`export function evaluate(`) + expect(output).not.toContain(`Transformer<`) + expect(output).not.toContain(`export class DateResult`) + }) +}) + +// ── Span-lexer combined with other scaffold flags ───────────────────────────── +// +// --lexer span is orthogonal to facade / pipeline / transformer combinations. +// Standalone traversal (no facade/pipeline) now routes through generateParser, +// so generateScaffold throws for span + standalone traversal too. The facade/ +// pipeline combinations still produce lexer-agnostic scaffolds identical to +// the same flags without --lexer span. + +describe('generateScaffold — lexer:span combined with other flags', () => { + const opts = { parserName: PARSER_NAME } + const treeOpts = { parserName: PARSER_NAME, treeName: 'ParseTree' } + + it('span + interpreter (standalone) equals interpreter alone', () => { + expect(generateScaffold(DATE_GRAMMAR, { lexer: 'span', traversal: 'interpreter' }, opts)).toBe( + generateScaffold(DATE_GRAMMAR, { traversal: 'interpreter' }, opts), + ) + }) + + it('span + tree-walker (standalone) equals tree-walker alone', () => { + expect(generateScaffold(DATE_GRAMMAR, { lexer: 'span', traversal: 'tree-walker' }, opts)).toBe( + generateScaffold(DATE_GRAMMAR, { traversal: 'tree-walker' }, opts), + ) + }) + + it('span + facade + interpreter equals facade+interpreter', () => { + expect( + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', traversal: 'interpreter', facade: true }, + opts, + ), + ).toBe(generateScaffold(DATE_GRAMMAR, { traversal: 'interpreter', facade: true }, opts)) + }) + + it('span + facade + tree-walker equals facade+tree-walker', () => { + expect( + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', traversal: 'tree-walker', facade: true }, + opts, + ), + ).toBe(generateScaffold(DATE_GRAMMAR, { traversal: 'tree-walker', facade: true }, opts)) + }) + + it('span + facade + pipeline + tree-walker equals facade+pipeline+tree-walker', () => { + expect( + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', traversal: 'tree-walker', facade: true, pipeline: true }, + treeOpts, + ), + ).toBe( + generateScaffold( + DATE_GRAMMAR, + { traversal: 'tree-walker', facade: true, pipeline: true }, + treeOpts, + ), + ) + }) + + it('span + pipeline + tree-walker equals pipeline+tree-walker', () => { + expect( + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', traversal: 'tree-walker', pipeline: true }, + treeOpts, + ), + ).toBe(generateScaffold(DATE_GRAMMAR, { traversal: 'tree-walker', pipeline: true }, treeOpts)) + }) + + it('span + standard transformer equals standard-transformer alone', () => { + expect(generateScaffold(DATE_GRAMMAR, { lexer: 'span', transformer: 'standard' }, opts)).toBe( + generateScaffold(DATE_GRAMMAR, { transformer: 'standard' }, opts), + ) + }) + + it('span + json transformer equals json-transformer alone', () => { + expect(generateScaffold(DATE_GRAMMAR, { lexer: 'span', transformer: 'json' }, opts)).toBe( + generateScaffold(DATE_GRAMMAR, { transformer: 'json' }, opts), + ) + }) + + it('span + json + facade + pipeline equals json+facade+pipeline', () => { + expect( + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', transformer: 'json', facade: true, pipeline: true }, + opts, + ), + ).toBe( + generateScaffold(DATE_GRAMMAR, { transformer: 'json', facade: true, pipeline: true }, opts), + ) + }) + + it('still throws for span + interpreter + pipeline', () => { + expect(() => + generateScaffold( + DATE_GRAMMAR, + { lexer: 'span', traversal: 'interpreter', pipeline: true }, + opts, + ), + ).toThrow(/--traversal interpreter cannot be combined with --pipeline/) + }) +}) + // ── Init scaffold ───────────────────────────────────────────────────────────── describe('generateInitScaffold', () => { diff --git a/src/cli/rdp-gen.ts b/src/cli/rdp-gen.ts index 0ee7b41..81f5557 100644 --- a/src/cli/rdp-gen.ts +++ b/src/cli/rdp-gen.ts @@ -8,8 +8,16 @@ import { parseArgs } from 'node:util' import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' import { createRequire } from 'node:module' -import { generateParser, generateScaffold, generateInitScaffold } from '../generator/index.js' +import { + generateScaffold, + generateScaffoldFiles, + generateInitScaffold, + Traversal, + Transformer, + Lexer, +} from '../generator/index.js' import type { ScaffoldFlags } from '../generator/index.js' import { EBNFParser } from '../generator/ebnf-parser.js' import { ABNFParser } from '../generator/abnf-parser.js' @@ -75,7 +83,7 @@ function runGenerate(rawArgs: string[]): void { const { values, positionals } = parseArgs({ args: normArgs, options: { - output: { type: 'string', short: 'o' }, + outdir: { type: 'string', short: 'o' }, format: { type: 'string' }, 'parser-name': { type: 'string' }, 'tree-name': { type: 'string' }, @@ -123,8 +131,8 @@ function runGenerate(rawArgs: string[]): void { const traversalRaw = values['traversal'] if ( traversalRaw !== undefined && - traversalRaw !== 'interpreter' && - traversalRaw !== 'tree-walker' + traversalRaw !== Traversal.Interpreter && + traversalRaw !== Traversal.TreeWalker ) { console.error( `rdp-gen: unknown --traversal value "${traversalRaw}". Valid values: interpreter, tree-walker`, @@ -133,7 +141,11 @@ function runGenerate(rawArgs: string[]): void { } const transformerRaw = values['transformer'] - if (transformerRaw !== undefined && transformerRaw !== 'standard' && transformerRaw !== 'json') { + if ( + transformerRaw !== undefined && + transformerRaw !== Transformer.Standard && + transformerRaw !== Transformer.JSON + ) { console.error( `rdp-gen: unknown --transformer value "${transformerRaw}". Pass --transformer or --transformer json`, ) @@ -141,60 +153,50 @@ function runGenerate(rawArgs: string[]): void { } const lexerRaw = values['lexer'] - if (lexerRaw !== undefined && lexerRaw !== 'scannerless' && lexerRaw !== 'span') { + if (lexerRaw !== undefined && lexerRaw !== Lexer.Scannerless && lexerRaw !== Lexer.Span) { console.error( `rdp-gen: unknown --lexer value "${lexerRaw}". Valid values: scannerless (default), span`, ) process.exit(1) } - const isScaffoldMode = - traversalRaw !== undefined || - transformerRaw !== undefined || - values['facade'] === true || - values['pipeline'] === true + const flags: ScaffoldFlags = { + ...(traversalRaw !== undefined && { traversal: traversalRaw as Traversal }), + ...(transformerRaw !== undefined && { transformer: transformerRaw as Transformer }), + ...(values['facade'] === true && { facade: true }), + ...(values['pipeline'] === true && { pipeline: true }), + ...(lexerRaw === Lexer.Span && { lexer: Lexer.Span }), + } - let output: string if (values['ast-only']) { const ast = format === 'abnf' ? ABNFParser.parse(source) : EBNFParser.parse(source) - output = JSON.stringify(ast, null, 2) - } else if (lexerRaw === 'span') { - // Span-lexer path — independent of scaffold mode; --traversal interpreter optionally - // wires evaluation directly into the TokenParser methods. - const flags: ScaffoldFlags = { - lexer: 'span', - ...(traversalRaw === 'interpreter' && { traversal: 'interpreter' }), - } + process.stdout.write(JSON.stringify(ast, null, 2)) + return + } + + const outdir = values['outdir'] + if (outdir) { + let files: Record try { - output = generateScaffold(source, flags, generatorOptions) + files = generateScaffoldFiles(source, flags, generatorOptions) } catch (e) { console.error(`rdp-gen: ${e instanceof Error ? e.message : String(e)}`) process.exit(1) } - } else if (isScaffoldMode) { - const flags: ScaffoldFlags = { - ...(traversalRaw !== undefined && { - traversal: traversalRaw as 'interpreter' | 'tree-walker', - }), - ...(transformerRaw !== undefined && { - transformer: transformerRaw as 'standard' | 'json', - }), - ...(values['facade'] === true && { facade: true }), - ...(values['pipeline'] === true && { pipeline: true }), + mkdirSync(outdir, { recursive: true }) + for (const [filename, content] of Object.entries(files)) { + const filePath = join(outdir, filename) + writeFileSync(filePath, content, 'utf-8') + console.error(`rdp-gen: wrote ${filePath}`) } + } else { + let output: string try { output = generateScaffold(source, flags, generatorOptions) } catch (e) { console.error(`rdp-gen: ${e instanceof Error ? e.message : String(e)}`) process.exit(1) } - } else { - output = generateParser(source, generatorOptions) - } - - if (values['output']) { - writeFileSync(values['output'], output, 'utf-8') - } else { process.stdout.write(output) } } @@ -279,13 +281,12 @@ function printGenerateHelp(): void { Usage: rdp-gen [options] Generate a TypeScript parser from an EBNF or ABNF grammar file. -Passing any scaffold flag emits a one-time starter file instead of the parser. Arguments: path to grammar file (.ebnf or .abnf) Options: - -o, --output write output to file instead of stdout + -o, --outdir write output files to directory (each artifact gets a derived name) --format grammar format: ebnf or abnf (default: inferred from extension) --parser-name class name for the generated parser (default: GeneratedParser) --tree-name type name for the generated parse tree (default: ParseTree) @@ -298,16 +299,19 @@ Options: -v, --version print version number -h, --help show this help -Scaffold flags (any combination switches to scaffold mode): - --traversal emit a traversal scaffold - interpreter one typed eval function per rule - tree-walker walk() utility + visitor stubs +Traversal flags (add stubs to the generated parser; file can still be regenerated): + --traversal mix traversal stubs into the parser class + interpreter implements InterpreterMixin; one eval stub per rule + tree-walker exports walk() alongside childNodes() + +Scaffold flags (any combination emits a one-time starter file instead of the parser): --transformer [json] emit a Transformer scaffold (no value) Transformer with stubs per rule json two-way stubs: ParseTree→JSONAST and JSONAST→string --facade wrap the scaffold in a module-as-facade (requires --traversal) - --pipeline emit parse/validate/transform stages (requires --traversal tree-walker, - or --traversal interpreter combined with --facade) + --pipeline emit parse/validate/transform stages (requires --traversal tree-walker) + + --traversal combined with any scaffold flag moves the traversal strategy inside the scaffold. Commands: init [options] scaffold a new parser project (run rdp-gen init --help)`) diff --git a/src/generator/codegen.ts b/src/generator/codegen.ts index 53569dc..8624059 100644 --- a/src/generator/codegen.ts +++ b/src/generator/codegen.ts @@ -64,6 +64,17 @@ export type GeneratorOptions = { * @default false */ caseSensitiveStrings?: boolean + + /** + * Mix traversal stubs into the generated file and switch the file header to + * "not regenerated; edit freely". + * + * - `'interpreter'`: adds `implements InterpreterMixin` to + * the class and emits one `eval*` stub method per rule, plus a `static evaluate()`. + * - `'tree-walker'`: emits a standalone `walk()` export after `childNodes` and + * a visitor template comment. + */ + traversal?: 'interpreter' | 'tree-walker' } /** @@ -87,6 +98,7 @@ export function generateParser(source: string, options: GeneratorOptions = {}): const parserName = options.parserName ?? 'GeneratedParser' const treeName = options.treeName ?? 'ParseTree' const observable = options.observable ?? false + const traversal = options.traversal const ast = format === 'abnf' @@ -100,16 +112,29 @@ export function generateParser(source: string, options: GeneratorOptions = {}): const baseClass = observable ? 'ObservableRDParser' : 'ScannerlessRDParser' const importPath = observable ? '@configuredthings/rdp.js/observable' : '@configuredthings/rdp.js' - const importNames = observable - ? `{ ObservableRDParser, type ParseObserver }` - : `{ ScannerlessRDParser }` + + // Build import names: base class, optional mixin type, optional walker imports + const baseImports: string[] = observable + ? ['ObservableRDParser', 'type ParseObserver'] + : ['ScannerlessRDParser'] + if (traversal === 'interpreter') baseImports.push('type InterpreterMixin') + if (traversal === 'tree-walker') baseImports.push('visit', 'type Visitor') const lines: string[] = [] - lines.push( - `// This file is generated by rdp-gen (@configuredthings/rdp.js). Do not edit by hand.`, - ) - lines.push(`// Regenerate with: rdp-gen --parser-name ${parserName}`) + if (traversal !== undefined) { + lines.push( + `// Parser scaffold generated by rdp-gen (@configuredthings/rdp.js) — this file is not regenerated; edit freely.`, + ) + lines.push( + `// Regenerate the parser structure with: rdp-gen --parser-name ${parserName}`, + ) + } else { + lines.push( + `// This file is generated by rdp-gen (@configuredthings/rdp.js). Do not edit by hand.`, + ) + lines.push(`// Regenerate with: rdp-gen --parser-name ${parserName}`) + } lines.push(`//`) lines.push(`// Required tsconfig compiler options:`) lines.push(`// target: ES2022 (native # private fields)`) @@ -117,9 +142,12 @@ export function generateParser(source: string, options: GeneratorOptions = {}): lines.push(`// noUncheckedIndexedAccess: true`) lines.push(`// moduleResolution: node16 or bundler`) lines.push(``) - lines.push(`import ${importNames} from '${importPath}'`) + lines.push(`import { ${baseImports.join(', ')} } from '${importPath}'`) lines.push(``) - lines.push(`export class ${parserName} extends ${baseClass} {`) + + const implementsClause = + traversal === 'interpreter' ? ` implements InterpreterMixin<${treeName}, unknown>` : `` + lines.push(`export class ${parserName} extends ${baseClass}${implementsClause} {`) lines.push(``) lines.push(` private constructor(source: DataView) {`) lines.push(` super(source)`) @@ -193,6 +221,33 @@ export function generateParser(source: string, options: GeneratorOptions = {}): } } + // Emit interpreter eval stubs inside the class + if (traversal === 'interpreter' && firstRule) { + lines.push(``) + lines.push( + ` // ── Interpreter — fill in each eval method, then change 'unknown' to your result type ──`, + ) + lines.push(``) + lines.push(` /** Parse \`input\` and evaluate it to a result. */`) + lines.push(` static evaluate(input: string): unknown {`) + lines.push(` const bytes = new TextEncoder().encode(input)`) + lines.push(` const parser = new ${parserName}(new DataView(bytes.buffer))`) + lines.push(` return parser.eval${pascalCase(firstRule.name)}(parser.parse())`) + lines.push(` }`) + for (const rule of ast.rules) { + const nodeType = `${pascalCase(rule.name)}Node` + const bodyItems = rule.body.kind === 'sequence' ? rule.body.items : [rule.body] + const fieldNames = inferFieldNames(bodyItems) + const hints = bodyItems.map((item, i) => `node.${fieldNames[i]}: ${typeForBody(item)}`) + lines.push(``) + lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) + lines.push(` eval${pascalCase(rule.name)}(node: ${nodeType}): unknown {`) + for (const hint of hints) lines.push(` // ${hint}`) + lines.push(` throw new Error('not implemented')`) + lines.push(` }`) + } + } + lines.push(`}`) lines.push(``) @@ -202,6 +257,27 @@ export function generateParser(source: string, options: GeneratorOptions = {}): // Always append the childNodes walker helper lines.push(generateWalker(ast, { treeName })) + // Emit walk() export for tree-walker traversal + if (traversal === 'tree-walker') { + const treeT = treeName + lines.push(`export function walk(root: ${treeT}, fn: (node: ${treeT}) => void): void {`) + lines.push(` fn(root)`) + lines.push(` for (const child of childNodes(root)) walk(child, fn)`) + lines.push(`}`) + lines.push(``) + lines.push(`// Add handlers for the node kinds you care about.`) + lines.push(`// Use Required> to enforce that every kind is handled.`) + lines.push(`//`) + lines.push(`// const visitor: Visitor<${treeT}> = {`) + for (const rule of ast.rules) { + lines.push(`// '${rule.name}': (node) => { /* ... */ },`) + } + lines.push(`// }`) + lines.push(`//`) + lines.push(`// walk(${parserName}.parse(input), (node) => visit(node, visitor))`) + lines.push(``) + } + return lines.join('\n') } diff --git a/src/generator/index.ts b/src/generator/index.ts index b6dac25..0302eaf 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -28,7 +28,14 @@ export { generateParser } from './codegen.js' export type { GeneratorOptions } from './codegen.js' -export { generateScaffold, generateInitScaffold } from './scaffold.js' +export { + generateScaffold, + generateScaffoldFiles, + generateInitScaffold, + Traversal, + Transformer, + Lexer, +} from './scaffold.js' export type { ScaffoldFlags, InitScaffoldOptions } from './scaffold.js' export type { GrammarAST, ProductionRule, RuleBody, CoreRuleName } from './ast.js' export { EBNFParser } from './ebnf-parser.js' diff --git a/src/generator/scaffold.ts b/src/generator/scaffold.ts index cdefe85..0d1105b 100644 --- a/src/generator/scaffold.ts +++ b/src/generator/scaffold.ts @@ -1,44 +1,110 @@ /** * Scaffold generators — emit one-time starter files for each usage pattern. * - * Unlike the generated parser, scaffold output is intended to be edited by hand - * and is NOT regenerated. It is a starting point, not a derived artefact. + * The scaffold router resolves flags into an ordered stack of independent + * sections, then composes them: each section declares its own imports, which + * the composer merges and deduplicates before emitting the final file. + * + * Adding a new flag combination means adding a new branch to `planScaffold` + * and, if needed, a new section generator — not a new monolithic function. */ import type { GrammarAST, ProductionRule, RuleBody } from './ast.js' import { EBNFParser } from './ebnf-parser.js' import { ABNFParser } from './abnf-parser.js' import { detectLeftRecursion } from './left-recursion.js' +import { generateParser } from './codegen.js' import type { GeneratorOptions } from './codegen.js' -import { inferFieldNames, typeForBody } from './type-gen.js' +import { inferFieldNames, typeForBody, emitTypeDeclarations } from './type-gen.js' + +// ── Public enums ────────────────────────────────────────────────────────────── + +/** How the generated scaffold traverses the parse tree. */ +export const Traversal = { + /** One typed `eval*` function per rule; evaluates directly from the tree. */ + Interpreter: 'interpreter', + /** A `walk()` utility + `Visitor` stubs; driven by a depth-first tree walk. */ + TreeWalker: 'tree-walker', +} as const +export type Traversal = (typeof Traversal)[keyof typeof Traversal] + +/** Which Transformer scaffold variant to emit. */ +export const Transformer = { + /** `Transformer` with one handler stub per rule. */ + Standard: 'standard', + /** Two-way stubs: `ParseTree → JSONAST` and `JSONAST → string`. */ + JSON: 'json', +} as const +export type Transformer = (typeof Transformer)[keyof typeof Transformer] + +/** Tokenisation strategy for the generated parser. */ +export const Lexer = { + /** Characters → AST directly; no separate tokeniser (default). */ + Scannerless: 'scannerless', + /** Emit a span tokeniser + classifier scaffold alongside the parser. */ + Span: 'span', +} as const +export type Lexer = (typeof Lexer)[keyof typeof Lexer] + +// ── Public scaffold flags ───────────────────────────────────────────────────── /** * Orthogonal scaffold configuration flags. * * Presence of any flag switches `generateScaffold` into scaffold mode. - * All combinations are valid except `--traversal interpreter` with `--pipeline` - * unless `--facade` is also set (the facade wraps a private pipeline whose - * `#transform` uses the interpreter, so an intermediate tree is still produced). + * `traversal` and `transformer` are mutually exclusive. + * `pipeline` requires `traversal: tree-walker` (interpreter has no intermediate tree). */ export type ScaffoldFlags = { - /** Traversal strategy used inside the scaffold. */ - traversal?: 'interpreter' | 'tree-walker' - /** Emit a Transformer scaffold. `'json'` produces the two-way JSON variant. */ - transformer?: 'standard' | 'json' - /** Wrap the scaffold in a module-as-facade (requires `traversal` or `transformer`). */ + /** Traversal strategy mixed in to the generated scaffold. */ + traversal?: Traversal + /** Emit a Transformer scaffold. */ + transformer?: Transformer + /** Wrap the scaffold in a module-as-facade. */ facade?: boolean - /** Emit pipeline stages: `parse` / `validate` / `transform` (requires `traversal`). */ + /** Emit pipeline stages: `parse` / `validate` / `transform`. */ pipeline?: boolean - /** Include a span tokeniser + classifier pipeline. */ - lexer?: 'span' + /** Tokenisation strategy for the generated parser. Defaults to `Lexer.Scannerless`. */ + lexer?: Lexer +} + +// ── Internal types ──────────────────────────────────────────────────────────── + +/** Derived names built once from the grammar and generator options. */ +type ScaffoldCtx = { + parserName: string + parserModule: string + base: string + camelBase: string + treeName: string + nodeTypes: string[] + firstNodeType: string + firstRuleName: string + errorClass: string + resultClass: string + pipelineClass: string + loadFn: string + entryFn: string + /** Import path for the transformer artifact (e.g. `./date-transformer.js`). */ + transformerModule: string + /** Import path for the pipeline artifact (e.g. `./date-pipeline.js`). */ + pipelineModule: string } +/** A single import declaration contributed by a section. */ +type Import = { from: string; names: string[]; disabled?: true } + +/** One logical block of generated code with its import requirements. */ +type Section = { header: string | null; imports: Import[]; lines: string[] } + +/** The full generation plan produced by `planScaffold`. */ +type ScaffoldPlan = { variantLabel: string; stepsLines: string[]; sections: Section[] } + +// ── Public entry point ──────────────────────────────────────────────────────── + /** * Generate a one-time scaffold file driven by orthogonal `ScaffoldFlags`. * - * The scaffold is intended as a starting point — edit it freely. Unlike the - * generated parser it is not designed to be regenerated from the grammar. - * * @param source - EBNF or ABNF grammar source text. * @param flags - Which scaffold dimensions to include (see {@link ScaffoldFlags}). * @param options - Generator configuration (same options as `generateParser`). @@ -51,23 +117,23 @@ export function generateScaffold( flags: ScaffoldFlags, options: GeneratorOptions = {}, ): string { - const { traversal, transformer, facade, pipeline, lexer } = flags + const { traversal, transformer, pipeline, facade } = flags - // ── Validate combinations ────────────────────────────────────────────────── - if (traversal === 'interpreter' && pipeline && !facade) { + if (traversal && transformer) { throw new Error( - '--traversal interpreter cannot be combined with --pipeline without --facade. ' + - 'The standalone pipeline pattern requires an intermediate tree for its validate stage. ' + - 'Use --traversal tree-walker --pipeline, or add --facade to wrap the pipeline.', + `--traversal and --transformer are mutually exclusive. ` + + `Use --traversal (interpreter or tree-walker) to walk the parse tree directly, ` + + `or --transformer to emit a Transformer object — not both in the same scaffold.`, ) } - if (!traversal && !transformer && !lexer) { + if (traversal === Traversal.Interpreter && pipeline) { throw new Error( - 'At least one of --traversal, --transformer, or --lexer must be provided to generate a scaffold.', + '--traversal interpreter cannot be combined with --pipeline. ' + + 'The interpreter evaluates directly during parsing — there is no intermediate tree for ' + + 'the validate stage to inspect. Use --traversal tree-walker --pipeline instead.', ) } - // ── Parse grammar ────────────────────────────────────────────────────────── const format = options.format ?? 'ebnf' const parserName = options.parserName ?? 'GeneratedParser' const treeName = options.treeName ?? 'ParseTree' @@ -82,718 +148,618 @@ export function generateScaffold( : EBNFParser.parse(source) detectLeftRecursion(ast) - // ── Route to generator ───────────────────────────────────────────────────── - - // Transformer path (mutually exclusive with traversal-based paths) - if (transformer) { - return transformer === 'json' - ? generateJsonTransformerScaffold(ast, parserName, treeName) - : generateTransformerScaffold(ast, parserName, treeName) - } - - // Lexer path — may optionally wire up a traversal - if (lexer === 'span') { - if (traversal === 'interpreter') return generateSpanLexerScaffold(ast, parserName, true) - if (!traversal) return generateSpanLexerScaffold(ast, parserName, false) - throw new Error(`--lexer span --traversal ${traversal} is not yet implemented.`) - } - - // Pure traversal path - if (traversal === 'interpreter') { - if (facade && pipeline) return generateFacadePipelineScaffold(ast, parserName, 'interpreter') - if (facade) return generateFacadeInterpreterScaffold(ast, parserName) - return generateInterpreterScaffold(ast, parserName) + // No separate scaffold file needed — route directly to the appropriate generator. + if (!transformer && !facade && !pipeline) { + if (flags.lexer === Lexer.Span && !traversal) { + // Span-only: emit the span tokeniser scaffold. + return generateSpanLexerScaffold(ast, parserName, false, treeName) + } + // Plain parser or traversal-mixin (scannerless or span+traversal): mixin stubs + // land inside the parser class itself. Span tokeniser is a separate CLI concern. + return generateParser(source, { + ...options, + ...(traversal !== undefined && { traversal }), + }) } - if (traversal === 'tree-walker') { - if (facade && pipeline) - return generateFacadePipelineScaffold(ast, parserName, 'tree-walker', treeName) - if (facade) return generateFacadeWalkerScaffold(ast, parserName, treeName) - if (pipeline) return generatePipelineWalkerScaffold(ast, parserName, treeName) - return generateWalkerScaffold(ast, parserName, treeName) + if (flags.lexer === Lexer.Span) { + if (!traversal && !transformer && !pipeline && !facade) { + // Already handled above, but guard for safety. + return generateSpanLexerScaffold(ast, parserName, false, treeName) + } + // Scaffold flags present — fall through to planScaffold. + // The scaffold templates reference the parser by name and are lexer-agnostic; + // the CLI writes the span-lexer parser to a separate file. } - throw new Error('unhandled scaffold flag combination') + const ctx = buildCtx(ast, parserName, treeName) + const plan = planScaffold(flags, ast, ctx) + return compose(plan, ctx) } -// ── Interpreter scaffold ────────────────────────────────────────────────────── +// ── Context builder ─────────────────────────────────────────────────────────── -function generateInterpreterScaffold(ast: GrammarAST, parserName: string): string { +function buildCtx(ast: GrammarAST, parserName: string, treeName: string): ScaffoldCtx { const firstRule = ast.rules[0] - if (!firstRule) return '' - - const modulePath = `./${parserName}.js` + const base = stripParserSuffix(parserName) + const camelBase = base.charAt(0).toLowerCase() + base.slice(1) const nodeTypes = ast.rules.map((r) => `${pascalCase(r.name)}Node`) - const lines: string[] = [] + const firstNodeType = firstRule ? `${pascalCase(firstRule.name)}Node` : 'never' + return { + parserName, + parserModule: `./${parserName}.js`, + base, + camelBase, + treeName, + nodeTypes, + firstNodeType, + firstRuleName: firstRule?.name ?? '', + errorClass: `${base}Error`, + resultClass: `${base}Result`, + pipelineClass: `${base}Pipeline`, + loadFn: `load${base}`, + entryFn: `parse${base}`, + transformerModule: `./${camelBase}-transformer.js`, + pipelineModule: `./${camelBase}-pipeline.js`, + } +} - lines.push( - `// Interpreter scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push( - `// Steps: 1) replace 'unknown' with your concrete return types 2) fill in the function bodies`, - ) - lines.push( - `// 3) remove eslint-disable-next-line comments — present only to keep stubs lint-clean`, - ) - lines.push(``) - lines.push(`import {`) - lines.push(` ${parserName},`) - for (const t of nodeTypes) lines.push(` type ${t},`) - lines.push(`} from '${modulePath}'`) - lines.push(`import { RDParserException } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` and evaluate it to a result.`) - lines.push(` *`) - lines.push(` * Replace the \`unknown\` return type with your concrete result type once`) - lines.push(` * you have filled in the \`eval*\` functions below.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse and evaluate.`) - lines.push(` * @returns The evaluated result (narrowed once implemented).`) - lines.push(` * @throws {Error} If \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export function evaluate(input: string): unknown {`) - lines.push(` try {`) - lines.push(` return eval${pascalCase(firstRule.name)}(${parserName}.parse(input))`) - lines.push(` } catch (e) {`) - lines.push( - ` if (e instanceof RDParserException) throw new Error(\`parse error: "\${input}"\`)`, - ) - lines.push(` throw e`) - lines.push(` }`) - lines.push(`}`) +// ── Planner ─────────────────────────────────────────────────────────────────── + +function planScaffold(flags: ScaffoldFlags, ast: GrammarAST, ctx: ScaffoldCtx): ScaffoldPlan { + const { traversal, transformer, facade, pipeline } = flags + + // ── JSON transformer ──────────────────────────────────────────────────────── + if (transformer === Transformer.JSON) { + if (facade && pipeline) { + return { + variantLabel: 'Facade + pipeline:json', + stepsLines: [ + `// Steps: 1) fill in ${ctx.camelBase}ToJSON handlers 2) fill in jsonTo${ctx.base} handlers`, + `// 3) add semantic validation in #validate 4) remove eslint-disable-next-line comments`, + ], + sections: [ + jsonTransformerSection(ast, ctx), + errorSection(ctx), + jsonPublicApiSection(ctx, true), + jsonPipelineSection(ast, ctx), + ], + } + } + return { + variantLabel: 'JSON transformer', + stepsLines: [ + `// Steps: 1) fill in ${ctx.camelBase}ToJSON handlers 2) fill in jsonTo${ctx.base} handlers`, + `// 3) remove eslint-disable-next-line comments — present only to keep stubs lint-clean`, + ], + sections: [jsonTransformerSection(ast, ctx), jsonPublicApiSection(ctx, false)], + } + } - for (const rule of ast.rules) { - const nodeType = `${pascalCase(rule.name)}Node` - lines.push(``) - lines.push(`/**`) - lines.push(` * Evaluate a \`${rule.name}\` node.`) - lines.push(` *`) - lines.push(` * @param node - The {@link ${nodeType}} to evaluate.`) - lines.push(` * @returns The evaluated result (replace \`unknown\` with your concrete type).`) - lines.push(` */`) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`function eval${pascalCase(rule.name)}(node: ${nodeType}): unknown {`) - for (const hint of fieldHints(rule)) lines.push(` // ${hint}`) - lines.push(` throw new Error('not implemented')`) - lines.push(`}`) + // ── Standard transformer ──────────────────────────────────────────────────── + if (transformer === Transformer.Standard) { + return { + variantLabel: 'Transformer', + stepsLines: [ + `// Steps: 1) replace 'unknown' with your concrete return type 2) fill in the handlers`, + `// 3) remove eslint-disable-next-line comments — present only to keep stubs lint-clean`, + ], + sections: [standardTransformerSection(ast, ctx)], + } } - lines.push(``) - return lines.join('\n') -} + // ── Interpreter ───────────────────────────────────────────────────────────── + if (traversal === Traversal.Interpreter) { + // facade is guaranteed here (pipeline+interpreter is validated out above) + return { + variantLabel: 'Facade + interpreter', + stepsLines: [ + `// Steps: 1) define ${ctx.resultClass} constructor fields 2) fill in eval methods`, + `// 3) implement static from() using the eval results 4) remove eslint-disable-next-line comments`, + ], + sections: [ + domainResultSection(ctx, Traversal.Interpreter), + errorSection(ctx), + facadePublicApiSection(ctx, false), + interpreterPrivateSection(ast, ctx, { + header: + '// ── Private ──────────────────────────────────────────────────────────────────', + withJsdoc: false, + }), + ], + } + } -// ── Walker scaffold ─────────────────────────────────────────────────────────── + // ── Tree-walker ───────────────────────────────────────────────────────────── + if (traversal === Traversal.TreeWalker) { + if (facade && pipeline) { + return { + variantLabel: 'Facade + pipeline:tree-walker', + stepsLines: [ + `// Steps: 1) define ${ctx.resultClass} fields 2) fill in #validate and #transform`, + `// 3) implement ${ctx.resultClass}.from() 4) remove eslint-disable-next-line comments`, + ], + sections: [ + domainResultSection(ctx, Traversal.TreeWalker), + errorSection(ctx), + facadePublicApiSection(ctx, true), + facadePipelineSection(ast, ctx, Traversal.TreeWalker), + walkerPrivateSection( + ast, + ctx, + '// ── Private walk utilities ───────────────────────────────────────────────────', + ), + ], + } + } + if (facade) { + return { + variantLabel: 'Facade + tree-walker', + stepsLines: [ + `// Steps: 1) define ${ctx.resultClass} fields 2) implement static from() using walk()`, + `// 3) uncomment and fill in the visitor 4) remove eslint-disable-next-line comments`, + ], + sections: [ + domainResultSection(ctx, Traversal.TreeWalker), + errorSection(ctx), + facadePublicApiSection(ctx, false), + walkerPrivateSection( + ast, + ctx, + '// ── Private ──────────────────────────────────────────────────────────────────', + ), + ], + } + } + if (pipeline) { + return { + variantLabel: 'Pipeline + tree-walker', + stepsLines: [ + `// Steps: 1) define ${ctx.resultClass} fields 2) fill in validate() 3) fill in the visitor inside transform()`, + `// 4) remove eslint-disable-next-line comments`, + ], + sections: [ + pipelineTypesSection(ctx), + pipelineStagesSection(ast, ctx), + walkerPrivateSection( + ast, + ctx, + '// ── Private walk utilities ───────────────────────────────────────────────────', + ), + ], + } + } + } -function generateWalkerScaffold(ast: GrammarAST, parserName: string, treeName: string): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' + throw new Error('unhandled scaffold flag combination') +} + +// ── Composer ────────────────────────────────────────────────────────────────── - const modulePath = `./${parserName}.js` +function compose(plan: ScaffoldPlan, ctx: ScaffoldCtx): string { + const { variantLabel, stepsLines, sections } = plan const lines: string[] = [] lines.push( - `// Tree-walking scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push(`// childNodes() is always present in the generated parser file.`) - lines.push( - `// Once you uncomment the walk call below, remove the eslint-disable-next-line comments.`, + `// ${variantLabel} scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, ) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`import { ${parserName}, childNodes, type ${treeName} } from '${modulePath}'`) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`import { visit, type Visitor } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Walk \`root\` depth-first (pre-order), calling \`fn\` on every node.`) - lines.push(` *`) - lines.push(` * @param root - The tree node to start from.`) - lines.push(` * @param fn - Called once per node visited, before its children.`) - lines.push(` */`) - lines.push(`export function walk(root: ${treeName}, fn: (node: ${treeName}) => void): void {`) - lines.push(` fn(root)`) - lines.push(` for (const child of childNodes(root)) walk(child, fn)`) - lines.push(`}`) - lines.push(``) - lines.push(`// Add handlers for the node kinds you care about.`) - lines.push(`// Use Required> to enforce that every kind is handled.`) - lines.push(`//`) - lines.push(`// const visitor: Visitor<${treeName}> = {`) - for (const rule of ast.rules) { - lines.push(`// '${rule.name}': (node) => { /* ... */ },`) + for (const l of stepsLines) lines.push(l) + lines.push('') + + // Merge imports: deduplicate names per module, preserve first-seen module order. + const importMap = new Map() + const importOrder: string[] = [] + for (const section of sections) { + for (const imp of section.imports) { + if (!importMap.has(imp.from)) { + importMap.set(imp.from, { names: [], disabled: imp.disabled === true }) + importOrder.push(imp.from) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const entry = importMap.get(imp.from)! + for (const name of imp.names) { + if (!entry.names.includes(name)) entry.names.push(name) + } + } + } + + for (const from of importOrder) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { names, disabled } = importMap.get(from)! + // Within each module: values (no "type " prefix) first, then types. + const sorted = [ + ...names.filter((n) => !n.startsWith('type ')), + ...names.filter((n) => n.startsWith('type ')), + ] + if (disabled) lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) + // Use multi-line format for the parser module when many names; single-line otherwise. + if (from === ctx.parserModule && sorted.length > 4) { + lines.push(`import {`) + for (const name of sorted) lines.push(` ${name},`) + lines.push(`} from '${from}'`) + } else { + lines.push(`import { ${sorted.join(', ')} } from '${from}'`) + } + } + if (importOrder.length > 0) lines.push('') + + for (const section of sections) { + if (section.header) { + lines.push(section.header) + lines.push('') + } + lines.push(...section.lines) } - lines.push(`// }`) - lines.push(`//`) - lines.push(`// walk(${parserName}.parse(input), (node) => visit(node, visitor))`) - lines.push(``) return lines.join('\n') } -// ── Facade + interpreter scaffold ───────────────────────────────────────────── +// ── Section generators ──────────────────────────────────────────────────────── -function generateFacadeInterpreterScaffold(ast: GrammarAST, parserName: string): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' - - const base = stripParserSuffix(parserName) - const modulePath = `./${parserName}.js` - const nodeTypes = ast.rules.map((r) => `${pascalCase(r.name)}Node`) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstNodeType = nodeTypes[0]! - const resultClass = `${base}Result` - const errorClass = `${base}Error` - const entryFn = `parse${base}` +/** Eval stub functions — one per grammar rule (used by facade+interpreter). */ +function interpreterPrivateSection( + ast: GrammarAST, + ctx: ScaffoldCtx, + opts: { header: string | null; withJsdoc: boolean }, +): Section { + const nodeTypeNames = ast.rules.map((r) => `type ${pascalCase(r.name)}Node`) const lines: string[] = [] - lines.push( - `// Facade + interpreter scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push(`// Steps: 1) define ${resultClass} constructor fields 2) fill in eval functions`) - lines.push( - `// 3) implement static from() using the eval results 4) remove eslint-disable-next-line comments`, - ) - lines.push(``) - lines.push(`import {`) - lines.push(` ${parserName},`) - for (const t of nodeTypes) lines.push(` type ${t},`) - lines.push(`} from '${modulePath}'`) - lines.push(`import { RDParserException } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`// ── Domain type ──────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Domain representation of a successfully parsed input.`) - lines.push(` *`) - lines.push(` * Replace the constructor stub with the fields that make sense for your domain.`) - lines.push( - ` * The private eval functions below produce the raw values; \`from()\` assembles them.`, - ) - lines.push(` */`) - lines.push(`export class ${resultClass} {`) - lines.push(` // TODO: define constructor fields`) - lines.push(` constructor() {}`) - lines.push(``) - lines.push(` /**`) - lines.push(` * Build a {@link ${resultClass}} from the raw parse tree.`) - lines.push(` *`) - lines.push(` * Call the private eval functions to extract values, then construct.`) - lines.push(` */`) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` static from(tree: ${firstNodeType}): ${resultClass} {`) - lines.push(` throw new Error('not implemented')`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Thrown by {@link ${entryFn}} when \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export class ${errorClass} extends Error {`) - lines.push(` constructor(input: string) {`) - lines.push(` super(\`invalid input: "\${input}"\`)`) - lines.push(` this.name = '${errorClass}'`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Public API ───────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` and return a domain {@link ${resultClass}} object.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse.`) - lines.push(` * @returns A {@link ${resultClass}} representing the parsed input.`) - lines.push(` * @throws {@link ${errorClass}} If \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export function ${entryFn}(input: string): ${resultClass} {`) - lines.push(` let tree: ${firstNodeType}`) - lines.push(` try {`) - lines.push(` tree = ${parserName}.parse(input)`) - lines.push(` } catch (e) {`) - lines.push(` if (e instanceof RDParserException) throw new ${errorClass}(input)`) - lines.push(` throw e`) - lines.push(` }`) - lines.push(` return ${resultClass}.from(tree)`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Private ──────────────────────────────────────────────────────────────────`) - for (const rule of ast.rules) { const nodeType = `${pascalCase(rule.name)}Node` lines.push(``) + if (opts.withJsdoc) { + lines.push(`/**`) + lines.push(` * Evaluate a \`${rule.name}\` node.`) + lines.push(` *`) + lines.push(` * @param node - The {@link ${nodeType}} to evaluate.`) + lines.push(` * @returns The evaluated result (replace \`unknown\` with your concrete type).`) + lines.push(` */`) + } lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) lines.push(`function eval${pascalCase(rule.name)}(node: ${nodeType}): unknown {`) for (const hint of fieldHints(rule)) lines.push(` // ${hint}`) lines.push(` throw new Error('not implemented')`) lines.push(`}`) } - lines.push(``) - return lines.join('\n') -} -// ── Facade + tree-walker scaffold ──────────────────────────────────────────── - -function generateFacadeWalkerScaffold( - ast: GrammarAST, - parserName: string, - treeName: string, -): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' - - const base = stripParserSuffix(parserName) - const modulePath = `./${parserName}.js` - const firstNodeType = `${pascalCase(firstRule.name)}Node` - const resultClass = `${base}Result` - const errorClass = `${base}Error` - const entryFn = `parse${base}` - const lines: string[] = [] - - lines.push( - `// Facade + tree-walker scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push(`// Steps: 1) define ${resultClass} fields 2) implement static from() using walk()`) - lines.push( - `// 3) uncomment and fill in the visitor 4) remove eslint-disable-next-line comments`, - ) - lines.push(``) - lines.push( - `import { ${parserName}, childNodes, type ${treeName}, type ${firstNodeType} } from '${modulePath}'`, - ) - lines.push(`import { RDParserException, visit, type Visitor } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`// ── Domain type ──────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Domain representation of a successfully parsed input.`) - lines.push(` *`) - lines.push(` * Replace the constructor stub with the fields for your domain.`) - lines.push(` * {@link ${resultClass}.from} walks the tree to build this object.`) - lines.push(` */`) - lines.push(`export class ${resultClass} {`) - lines.push(` // TODO: define constructor fields`) - lines.push(` constructor() {}`) - lines.push(``) - lines.push(` /**`) - lines.push(` * Build a {@link ${resultClass}} by walking the parse tree.`) - lines.push(` */`) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` static from(tree: ${firstNodeType}): ${resultClass} {`) - lines.push(` // Use walk() and visitor to extract values, then construct.`) - lines.push(` throw new Error('not implemented')`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Thrown by {@link ${entryFn}} when \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export class ${errorClass} extends Error {`) - lines.push(` constructor(input: string) {`) - lines.push(` super(\`invalid input: "\${input}"\`)`) - lines.push(` this.name = '${errorClass}'`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Public API ───────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` and return a domain {@link ${resultClass}} object.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse.`) - lines.push(` * @returns A {@link ${resultClass}} representing the parsed input.`) - lines.push(` * @throws {@link ${errorClass}} If \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export function ${entryFn}(input: string): ${resultClass} {`) - lines.push(` let tree: ${firstNodeType}`) - lines.push(` try {`) - lines.push(` tree = ${parserName}.parse(input)`) - lines.push(` } catch (e) {`) - lines.push(` if (e instanceof RDParserException) throw new ${errorClass}(input)`) - lines.push(` throw e`) - lines.push(` }`) - lines.push(` return ${resultClass}.from(tree)`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Private ──────────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`function walk(root: ${treeName}, fn: (node: ${treeName}) => void): void {`) - lines.push(` fn(root)`) - lines.push(` for (const child of childNodes(root)) walk(child, fn)`) - lines.push(`}`) - lines.push(``) - lines.push(`// Add handlers for the node kinds you care about.`) - lines.push(`// Use Required> to enforce that every kind is handled.`) - lines.push(`//`) - lines.push(`// const visitor: Visitor<${treeName}> = {`) - for (const rule of ast.rules) { - lines.push(`// '${rule.name}': (node) => { /* ... */ },`) + return { + header: opts.header, + imports: [{ from: ctx.parserModule, names: nodeTypeNames }], + lines, } - lines.push(`// }`) - lines.push(`//`) - lines.push(`// walk(tree, (node) => visit(node, visitor))`) - lines.push(``) - - return lines.join('\n') } -// ── Facade + pipeline scaffold ──────────────────────────────────────────────── - -function generateFacadePipelineScaffold( - ast: GrammarAST, - parserName: string, - transformInner: 'interpreter' | 'tree-walker', - treeName?: string, -): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' - - const base = stripParserSuffix(parserName) - const modulePath = `./${parserName}.js` - const nodeTypes = ast.rules.map((r) => `${pascalCase(r.name)}Node`) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const firstNodeType = nodeTypes[0]! - const resultClass = `${base}Result` - const errorClass = `${base}Error` - const pipelineClass = `${base}Pipeline` - const entryFn = `parse${base}` - const resolvedTreeName = treeName ?? 'ParseTree' - const lines: string[] = [] - - const innerLabel = - transformInner === 'interpreter' ? 'pipeline:interpreter' : 'pipeline:tree-walker' - lines.push( - `// Facade + ${innerLabel} scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push(`// Steps: 1) define ${resultClass} fields 2) fill in #validate and #transform`) - lines.push( - `// 3) implement ${resultClass}.from() 4) remove eslint-disable-next-line comments`, - ) - lines.push(``) - - // Imports: interpreter needs all node types for the private eval functions; - // tree-walker only needs the root node type plus childNodes/ParseTree. - if (transformInner === 'interpreter') { - lines.push(`import {`) - lines.push(` ${parserName},`) - for (const t of nodeTypes) lines.push(` type ${t},`) - lines.push(`} from '${modulePath}'`) - lines.push(`import { RDParserException } from '@configuredthings/rdp.js'`) - } else { - lines.push( - `import { ${parserName}, childNodes, type ${resolvedTreeName}, type ${firstNodeType} } from '${modulePath}'`, - ) - lines.push(`import { RDParserException, visit, type Visitor } from '@configuredthings/rdp.js'`) +/** Private `walk()` + visitor template for facade/pipeline walker cases. */ +function walkerPrivateSection(ast: GrammarAST, ctx: ScaffoldCtx, header: string): Section { + const lines: string[] = [ + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `function walk(root: ${ctx.treeName}, fn: (node: ${ctx.treeName}) => void): void {`, + ` fn(root)`, + ` for (const child of childNodes(root)) walk(child, fn)`, + `}`, + ``, + `// Add handlers for the node kinds you care about.`, + `// Use Required> to enforce that every kind is handled.`, + `//`, + `// const visitor: Visitor<${ctx.treeName}> = {`, + ...ast.rules.map((r) => `// '${r.name}': (node) => { /* ... */ },`), + `// }`, + `//`, + `// walk(tree, (node) => visit(node, visitor))`, + ``, + ] + return { + header, + imports: [ + { from: ctx.parserModule, names: ['childNodes', `type ${ctx.treeName}`] }, + { from: '@configuredthings/rdp.js', names: ['visit', `type Visitor`] }, + ], + lines, } +} - lines.push(``) - lines.push(`// ── Domain type ──────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Domain representation of a successfully parsed input.`) - lines.push(` *`) - lines.push(` * Replace the constructor stub with your domain fields.`) - lines.push(` * {@link ${pipelineClass}} builds this via \`from()\`.`) - lines.push(` */`) - lines.push(`export class ${resultClass} {`) - lines.push(` // TODO: define constructor fields`) - lines.push(` constructor() {}`) - lines.push(``) - lines.push(` /**`) - lines.push(` * Build a {@link ${resultClass}} from the validated parse tree.`) - lines.push(` */`) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` static from(tree: ${firstNodeType}): ${resultClass} {`) - lines.push(` throw new Error('not implemented')`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Thrown by {@link ${entryFn}} when \`input\` does not match the grammar`) - lines.push(` * or fails semantic validation.`) - lines.push(` */`) - lines.push(`export class ${errorClass} extends Error {`) - lines.push(` constructor(input: string) {`) - lines.push(` super(\`invalid input: "\${input}"\`)`) - lines.push(` this.name = '${errorClass}'`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Public API ───────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` and return a domain {@link ${resultClass}} object.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse.`) - lines.push(` * @returns A {@link ${resultClass}} representing the parsed input.`) - lines.push( - ` * @throws {@link ${errorClass}} If \`input\` does not match the grammar or is invalid.`, - ) - lines.push(` */`) - lines.push(`export function ${entryFn}(input: string): ${resultClass} {`) - lines.push(` return ${pipelineClass}.run(input)`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Pipeline (private) ───────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`class ${pipelineClass} {`) - lines.push(` static run(input: string): ${resultClass} {`) - lines.push(` const tree = ${pipelineClass}.#parse(input)`) - lines.push(` const result = ${pipelineClass}.#validate(tree)`) - lines.push(` if (!result.ok) throw new ${errorClass}(input)`) - lines.push(` return ${pipelineClass}.#transform(result.tree)`) - lines.push(` }`) - lines.push(``) - lines.push(` static #parse(input: string): ${firstNodeType} {`) - lines.push(` try {`) - lines.push(` return ${parserName}.parse(input)`) - lines.push(` } catch (e) {`) - lines.push(` if (e instanceof RDParserException) throw new ${errorClass}(input)`) - lines.push(` throw e`) - lines.push(` }`) - lines.push(` }`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` static #validate(`) - lines.push(` tree: ${firstNodeType},`) - lines.push(` ): { ok: true; tree: ${firstNodeType} } | { ok: false } {`) - lines.push(` // TODO: add semantic validation; return { ok: false } to reject`) - lines.push(` return { ok: true, tree }`) - lines.push(` }`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` static #transform(tree: ${firstNodeType}): ${resultClass} {`) - - if (transformInner === 'interpreter') { - lines.push(` // Call ${resultClass}.from(tree) once from() is implemented, or`) - lines.push(` // use the private eval functions below to assemble the result.`) - lines.push(` throw new Error('not implemented')`) - } else { - lines.push( - ` // Use walk() and visitor to build the result, then return ${resultClass}.from(tree).`, - ) - lines.push(` throw new Error('not implemented')`) +/** Domain result class (`XxxResult`) — body differs by traversal strategy. */ +function domainResultSection(ctx: ScaffoldCtx, strategy: Traversal): Section { + const fromBody = + strategy === Traversal.Interpreter + ? ` // Call the private eval functions to extract values, then construct.` + : ` // Use walk() and visitor to extract values, then construct.` + return { + header: '// ── Domain type ──────────────────────────────────────────────────────────────', + imports: [{ from: ctx.parserModule, names: [`type ${ctx.firstNodeType}`] }], + lines: [ + `/**`, + ` * Domain representation of a successfully parsed input.`, + ` *`, + strategy === Traversal.Interpreter + ? ` * Replace the constructor stub with the fields that make sense for your domain.` + : ` * Replace the constructor stub with the fields for your domain.`, + strategy === Traversal.Interpreter + ? ` * The private eval functions below produce the raw values; \`from()\` assembles them.` + : ` * {@link ${ctx.resultClass}.from} walks the tree to build this object.`, + ` */`, + `export class ${ctx.resultClass} {`, + ` // TODO: define constructor fields`, + ` constructor() {}`, + ``, + ` /**`, + strategy === Traversal.Interpreter + ? ` * Build a {@link ${ctx.resultClass}} from the raw parse tree.` + : ` * Build a {@link ${ctx.resultClass}} by walking the parse tree.`, + ` */`, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` static from(tree: ${ctx.firstNodeType}): ${ctx.resultClass} {`, + fromBody, + ` throw new Error('not implemented')`, + ` }`, + `}`, + ``, + ], } +} - lines.push(` }`) - lines.push(`}`) - - if (transformInner === 'interpreter') { - lines.push(``) - lines.push(`// ── Private eval functions ───────────────────────────────────────────────────`) - for (const rule of ast.rules) { - const nodeType = `${pascalCase(rule.name)}Node` - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`function eval${pascalCase(rule.name)}(node: ${nodeType}): unknown {`) - for (const hint of fieldHints(rule)) lines.push(` // ${hint}`) - lines.push(` throw new Error('not implemented')`) - lines.push(`}`) - } - } else { - lines.push(``) - lines.push(`// ── Private walk utilities ───────────────────────────────────────────────────`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push( - `function walk(root: ${resolvedTreeName}, fn: (node: ${resolvedTreeName}) => void): void {`, - ) - lines.push(` fn(root)`) - lines.push(` for (const child of childNodes(root)) walk(child, fn)`) - lines.push(`}`) - lines.push(``) - lines.push(`// Add handlers for the node kinds you care about.`) - lines.push(`//`) - lines.push(`// const visitor: Visitor<${resolvedTreeName}> = {`) - for (const rule of ast.rules) { - lines.push(`// '${rule.name}': (node) => { /* ... */ },`) - } - lines.push(`// }`) +/** Error class (`XxxError`). */ +function errorSection(ctx: ScaffoldCtx): Section { + return { + header: null, + imports: [], + lines: [ + `/**`, + ` * Thrown by the public API when \`input\` does not match the grammar`, + ` * or fails semantic validation.`, + ` */`, + `export class ${ctx.errorClass} extends Error {`, + ` constructor(input: string) {`, + ` super(\`invalid input: "\${input}"\`)`, + ` this.name = '${ctx.errorClass}'`, + ` }`, + `}`, + ``, + ], } - - lines.push(``) - return lines.join('\n') } -// ── Pipeline + tree-walker scaffold ────────────────────────────────────────── +/** + * Public API for facade patterns (interpreter and walker). + * When `hasPipeline` is true the body delegates to `XxxPipeline.run()`; + * otherwise it parses inline and calls `XxxResult.from()`. + */ +function facadePublicApiSection(ctx: ScaffoldCtx, hasPipeline: boolean): Section { + const body = hasPipeline + ? [` return ${ctx.pipelineClass}.run(input)`] + : [ + ` let tree: ${ctx.firstNodeType}`, + ` try {`, + ` tree = ${ctx.parserName}.parse(input)`, + ` } catch (e) {`, + ` if (e instanceof RDParserException) throw new ${ctx.errorClass}(input)`, + ` throw e`, + ` }`, + ` return ${ctx.resultClass}.from(tree)`, + ] + return { + header: '// ── Public API ───────────────────────────────────────────────────────────────', + imports: [ + { from: ctx.parserModule, names: [ctx.parserName, `type ${ctx.firstNodeType}`] }, + { from: '@configuredthings/rdp.js', names: ['RDParserException'] }, + ], + lines: [ + `/**`, + ` * Parse \`input\` and return a domain {@link ${ctx.resultClass}} object.`, + ` *`, + ` * @param input - Source string to parse.`, + ` * @returns A {@link ${ctx.resultClass}} representing the parsed input.`, + ` * @throws {@link ${ctx.errorClass}} If \`input\` does not match the grammar.`, + ` */`, + `export function ${ctx.entryFn}(input: string): ${ctx.resultClass} {`, + ...body, + `}`, + ``, + ], + } +} -function generatePipelineWalkerScaffold( +/** + * Private `XxxPipeline` class with `#parse` / `#validate` / `#transform` stages. + * The `#transform` body is a stub for interpreter/walker; for json it calls `fromJSONAST`. + */ +function facadePipelineSection( ast: GrammarAST, - parserName: string, - treeName: string, -): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' + ctx: ScaffoldCtx, + strategy: typeof Traversal.TreeWalker | typeof Transformer.JSON, +): Section { + const returnType = strategy === Transformer.JSON ? 'string' : ctx.resultClass + + const transformBody = + strategy === Transformer.JSON + ? [` return fromJSONAST(transform(tree, ${ctx.camelBase}ToJSON))`] + : [ + ` // Use walk() and visitor to build the result, then return ${ctx.resultClass}.from(tree).`, + ` throw new Error('not implemented')`, + ] + + const walkerCommentLines = + strategy === Traversal.TreeWalker + ? [ + ``, + `// Add handlers for the node kinds you care about.`, + `//`, + `// const visitor: Visitor<${ctx.treeName}> = {`, + ...ast.rules.map((r) => `// '${r.name}': (node) => { /* ... */ },`), + `// }`, + ] + : [] + + const rdpjsImports = + strategy === Transformer.JSON + ? ['RDParserException', 'transform', 'fromJSONAST'] + : ['RDParserException', 'visit', `type Visitor`] + + const parserImports = + strategy === Traversal.TreeWalker + ? [ctx.parserName, `type ${ctx.firstNodeType}`, 'childNodes', `type ${ctx.treeName}`] + : [ctx.parserName, `type ${ctx.firstNodeType}`] + + return { + header: '// ── Pipeline (private) ───────────────────────────────────────────────────────', + imports: [ + { from: ctx.parserModule, names: parserImports }, + { from: '@configuredthings/rdp.js', names: rdpjsImports }, + ], + lines: [ + `class ${ctx.pipelineClass} {`, + ` static run(input: string): ${returnType} {`, + ` const tree = ${ctx.pipelineClass}.#parse(input)`, + ` const result = ${ctx.pipelineClass}.#validate(tree)`, + ` if (!result.ok) throw new ${ctx.errorClass}(input)`, + ` return ${ctx.pipelineClass}.#transform(result.tree)`, + ` }`, + ``, + ` static #parse(input: string): ${ctx.firstNodeType} {`, + ` try {`, + ` return ${ctx.parserName}.parse(input)`, + ` } catch (e) {`, + ` if (e instanceof RDParserException) throw new ${ctx.errorClass}(input)`, + ` throw e`, + ` }`, + ` }`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` static #validate(`, + ` tree: ${ctx.firstNodeType},`, + ` ): { ok: true; tree: ${ctx.firstNodeType} } | { ok: false } {`, + ` // TODO: add semantic validation; return { ok: false } to reject`, + ` return { ok: true, tree }`, + ` }`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` static #transform(tree: ${ctx.firstNodeType}): ${returnType} {`, + ...transformBody, + ` }`, + `}`, + ...walkerCommentLines, + ``, + ], + } +} - const base = stripParserSuffix(parserName) - const modulePath = `./${parserName}.js` - const firstNodeType = `${pascalCase(firstRule.name)}Node` - const resultType = `${base}Result` - const loadFn = `load${base}` - const lines: string[] = [] +/** Result interface + ValidationError interface for the standalone pipeline walker. */ +function pipelineTypesSection(ctx: ScaffoldCtx): Section { + return { + header: '// ── Types ────────────────────────────────────────────────────────────────────', + imports: [], + lines: [ + `/**`, + ` * Domain representation produced by the final pipeline stage.`, + ` *`, + ` * Replace this empty interface with the fields you want {@link transform} to return.`, + ` */`, + `// eslint-disable-next-line @typescript-eslint/no-empty-object-type`, + `export interface ${ctx.resultClass} {`, + ` // TODO: define your domain type`, + `}`, + ``, + `/**`, + ` * A single semantic error found during {@link validate}.`, + ` */`, + `export interface ValidationError {`, + ` message: string`, + `}`, + ``, + ], + } +} - lines.push( - `// Pipeline + tree-walker scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push( - `// Steps: 1) define ${resultType} fields 2) fill in validate() 3) fill in the visitor inside transform()`, - ) - lines.push(`// 4) remove eslint-disable-next-line comments`) - lines.push(``) - lines.push( - `import { ${parserName}, childNodes, type ${treeName}, type ${firstNodeType} } from '${modulePath}'`, - ) - lines.push(`import { RDParserException, visit, type Visitor } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`// ── Types ────────────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Domain representation produced by the final pipeline stage.`) - lines.push(` *`) - lines.push( - ` * Replace this empty interface with the fields you want {@link transform} to return.`, - ) - lines.push(` */`) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-empty-object-type`) - lines.push(`export interface ${resultType} {`) - lines.push(` // TODO: define your domain type`) - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * A single semantic error found during {@link validate}.`) - lines.push(` */`) - lines.push(`export interface ValidationError {`) - lines.push(` message: string`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Stage 1: parse ───────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Stage 1 — parse \`input\` into a typed parse tree.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse.`) - lines.push(` * @returns The root {@link ${firstNodeType}} of the parse tree.`) - lines.push(` * @throws {SyntaxError} If \`input\` does not match the grammar.`) - lines.push(` */`) - lines.push(`export function parse(input: string): ${firstNodeType} {`) - lines.push(` try {`) - lines.push(` return ${parserName}.parse(input)`) - lines.push(` } catch (e) {`) - lines.push(` if (e instanceof RDParserException) throw new SyntaxError(e.message)`) - lines.push(` throw e`) - lines.push(` }`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Stage 2: validate ────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Stage 2 — check the parse tree for semantic errors.`) - lines.push(` *`) - lines.push(` * @param tree - Parse tree returned by {@link parse}.`) - lines.push( +/** Exported `parse` / `validate` / `transform` + `loadXxx` for the standalone pipeline walker. */ +function pipelineStagesSection(ast: GrammarAST, ctx: ScaffoldCtx): Section { + const lines: string[] = [ + `// ── Stage 1: parse ───────────────────────────────────────────────────────────`, + ``, + `/**`, + ` * Stage 1 — parse \`input\` into a typed parse tree.`, + ` *`, + ` * @param input - Source string to parse.`, + ` * @returns The root {@link ${ctx.firstNodeType}} of the parse tree.`, + ` * @throws {SyntaxError} If \`input\` does not match the grammar.`, + ` */`, + `export function parse(input: string): ${ctx.firstNodeType} {`, + ` try {`, + ` return ${ctx.parserName}.parse(input)`, + ` } catch (e) {`, + ` if (e instanceof RDParserException) throw new SyntaxError(e.message)`, + ` throw e`, + ` }`, + `}`, + ``, + `// ── Stage 2: validate ────────────────────────────────────────────────────────`, + ``, + `/**`, + ` * Stage 2 — check the parse tree for semantic errors.`, + ` *`, + ` * @param tree - Parse tree returned by {@link parse}.`, ` * @returns \`{ ok: true, tree }\` when valid, or \`{ ok: false, errors }\` otherwise.`, - ) - lines.push(` */`) - lines.push(`export function validate(`) - lines.push(` tree: ${firstNodeType},`) - lines.push(`): { ok: true; tree: ${firstNodeType} } | { ok: false; errors: ValidationError[] } {`) - lines.push(` const errors: ValidationError[] = []`) - lines.push(` // TODO: add validation logic`) - lines.push(` return errors.length > 0 ? { ok: false, errors } : { ok: true, tree }`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Stage 3: transform ───────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push( - ` * Stage 3 — convert the validated parse tree into a domain {@link ${resultType}} object.`, - ) - lines.push(` *`) - lines.push(` * @param tree - Validated parse tree from {@link validate}.`) - lines.push(` * @returns The domain object.`) - lines.push(` */`) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`export function transform(tree: ${firstNodeType}): ${resultType} {`) - lines.push(` // Use walk() and visitor to collect values, then construct ${resultType}.`) - lines.push(` throw new Error('not implemented')`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Combinator ───────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse, validate, and transform \`input\` in one call.`) - lines.push(` *`) - lines.push(` * @param input - Source string to load.`) - lines.push(` * @returns The domain object.`) - lines.push(` * @throws {SyntaxError} If \`input\` does not match the grammar.`) - lines.push(` * @throws {AggregateError} If \`input\` fails semantic validation.`) - lines.push(` */`) - lines.push(`export function ${loadFn}(input: string): ${resultType} {`) - lines.push(` const tree = parse(input)`) - lines.push(` const result = validate(tree)`) - lines.push( - ` if (!result.ok) throw new AggregateError(result.errors, \`invalid ${base}: "\${input}"\`)`, - ) - lines.push(` return transform(result.tree)`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Private walk utilities ───────────────────────────────────────────────────`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`function walk(root: ${treeName}, fn: (node: ${treeName}) => void): void {`) - lines.push(` fn(root)`) - lines.push(` for (const child of childNodes(root)) walk(child, fn)`) - lines.push(`}`) - lines.push(``) - lines.push(`// Add handlers for the node kinds you care about.`) - lines.push(`//`) - lines.push(`// const visitor: Visitor<${treeName}> = {`) - for (const rule of ast.rules) { - lines.push(`// '${rule.name}': (node) => { /* ... */ },`) + ` */`, + `export function validate(`, + ` tree: ${ctx.firstNodeType},`, + `): { ok: true; tree: ${ctx.firstNodeType} } | { ok: false; errors: ValidationError[] } {`, + ` const errors: ValidationError[] = []`, + ` // TODO: add validation logic`, + ` return errors.length > 0 ? { ok: false, errors } : { ok: true, tree }`, + `}`, + ``, + `// ── Stage 3: transform ───────────────────────────────────────────────────────`, + ``, + `/**`, + ` * Stage 3 — convert the validated parse tree into a domain {@link ${ctx.resultClass}} object.`, + ` *`, + ` * @param tree - Validated parse tree from {@link validate}.`, + ` * @returns The domain object.`, + ` */`, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `export function transform(tree: ${ctx.firstNodeType}): ${ctx.resultClass} {`, + ` // Use walk() and visitor to collect values, then construct ${ctx.resultClass}.`, + ` throw new Error('not implemented')`, + `}`, + ``, + `// ── Combinator ───────────────────────────────────────────────────────────────`, + ``, + `/**`, + ` * Parse, validate, and transform \`input\` in one call.`, + ` *`, + ` * @param input - Source string to load.`, + ` * @returns The domain object.`, + ` * @throws {SyntaxError} If \`input\` does not match the grammar.`, + ` * @throws {AggregateError} If \`input\` fails semantic validation.`, + ` */`, + `export function ${ctx.loadFn}(input: string): ${ctx.resultClass} {`, + ` const tree = parse(input)`, + ` const result = validate(tree)`, + ` if (!result.ok) throw new AggregateError(result.errors, \`invalid ${ctx.base}: "\${input}"\`)`, + ` return transform(result.tree)`, + `}`, + ``, + ] + return { + header: null, + imports: [ + { + from: ctx.parserModule, + names: [ctx.parserName, 'childNodes', `type ${ctx.treeName}`, `type ${ctx.firstNodeType}`], + }, + { from: '@configuredthings/rdp.js', names: ['RDParserException', 'visit', `type Visitor`] }, + ], + lines, } - lines.push(`// }`) - lines.push(``) - - return lines.join('\n') } -// ── Transformer scaffold ────────────────────────────────────────────────────── - -function generateTransformerScaffold( - ast: GrammarAST, - parserName: string, - treeName: string, -): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' - - const base = stripParserSuffix(parserName) - const camelBase = base.charAt(0).toLowerCase() + base.slice(1) - const modulePath = `./${parserName}.js` - const nodeTypes = ast.rules.map((r) => `${pascalCase(r.name)}Node`) - const lines: string[] = [] - - lines.push( - `// Transformer scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push( - `// Steps: 1) replace 'unknown' with your concrete return type 2) fill in the handlers`, - ) - lines.push( - `// 3) remove eslint-disable-next-line comments — present only to keep stubs lint-clean`, - ) - lines.push(``) - lines.push(`import {`) - lines.push(` ${parserName},`) - for (const t of nodeTypes) lines.push(` type ${t},`) - lines.push(` type ${treeName},`) - lines.push(`} from '${modulePath}'`) - lines.push(`import { transform, type Transformer } from '@configuredthings/rdp.js'`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`export const ${camelBase}Transformer: Transformer<${treeName}, unknown> = {`) - +/** Standard `Transformer` object + `transformXxx()` entry point. */ +function standardTransformerSection(ast: GrammarAST, ctx: ScaffoldCtx): Section { + const lines: string[] = [ + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `export const ${ctx.camelBase}Transformer: Transformer<${ctx.treeName}, unknown> = {`, + ] for (const rule of ast.rules) { const nodeType = `${pascalCase(rule.name)}Node` lines.push(``) @@ -803,62 +769,42 @@ function generateTransformerScaffold( lines.push(` throw new Error('not implemented')`) lines.push(` },`) } - - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` and transform it in one call.`) - lines.push(` *`) - lines.push(` * @param input - Source string to parse and transform.`) - lines.push(` * @returns The transformed result.`) - lines.push(` */`) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`export function transform${base}(input: string): unknown {`) - lines.push(` return transform(${parserName}.parse(input), ${camelBase}Transformer)`) - lines.push(`}`) - lines.push(``) - - return lines.join('\n') -} - -// ── JSON transformer scaffold ───────────────────────────────────────────────── - -function generateJsonTransformerScaffold( - ast: GrammarAST, - parserName: string, - treeName: string, -): string { - const firstRule = ast.rules[0] - if (!firstRule) return '' - - const base = stripParserSuffix(parserName) - const camelBase = base.charAt(0).toLowerCase() + base.slice(1) - const modulePath = `./${parserName}.js` - const nodeTypes = ast.rules.map((r) => `${pascalCase(r.name)}Node`) - const lines: string[] = [] - - lines.push( - `// JSON transformer scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, - ) - lines.push(`// Steps: 1) fill in ${camelBase}ToJSON handlers 2) fill in jsonTo${base} handlers`) lines.push( - `// 3) remove eslint-disable-next-line comments — present only to keep stubs lint-clean`, + `}`, + ``, + `/**`, + ` * Parse \`input\` and transform it in one call.`, + ` *`, + ` * @param input - Source string to parse and transform.`, + ` * @returns The transformed result.`, + ` */`, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `export function transform${ctx.base}(input: string): unknown {`, + ` return transform(${ctx.parserName}.parse(input), ${ctx.camelBase}Transformer)`, + `}`, + ``, ) - lines.push(``) - lines.push(`import {`) - lines.push(` ${parserName},`) - for (const t of nodeTypes) lines.push(` type ${t},`) - lines.push(` type ${treeName},`) - lines.push(`} from '${modulePath}'`) - lines.push( - `import { transform, type Transformer, toJSONAST, fromJSONAST, type JSONAST } from '@configuredthings/rdp.js'`, - ) - lines.push(``) - lines.push(`// ── ${base} → JSON ──────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`export const ${camelBase}ToJSON: Transformer<${treeName}, JSONAST> = {`) + return { + header: null, + imports: [ + { + from: ctx.parserModule, + names: [ctx.parserName, ...ctx.nodeTypes.map((t) => `type ${t}`), `type ${ctx.treeName}`], + }, + { from: '@configuredthings/rdp.js', names: ['transform', `type Transformer`] }, + ], + lines, + } +} +/** Two-way JSON transformer stubs (`xxxToJSON` and `jsonToXxx`). */ +function jsonTransformerSection(ast: GrammarAST, ctx: ScaffoldCtx): Section { + const lines: string[] = [ + `// ── ${ctx.base} → JSON ──────────────────────────────────────────────────────────────`, + ``, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `export const ${ctx.camelBase}ToJSON: Transformer<${ctx.treeName}, JSONAST> = {`, + ] for (const rule of ast.rules) { const nodeType = `${pascalCase(rule.name)}Node` lines.push(``) @@ -868,57 +814,395 @@ function generateJsonTransformerScaffold( lines.push(` throw new Error('not implemented')`) lines.push(` },`) } + lines.push( + `}`, + ``, + `// ── JSON → ${ctx.base} ──────────────────────────────────────────────────────────────`, + ``, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars`, + `export const jsonTo${ctx.base}: Transformer = {`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` string(node): string { throw new Error('not implemented') },`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` number(node): string { throw new Error('not implemented') },`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` boolean(node): string { throw new Error('not implemented') },`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` null(node): string { throw new Error('not implemented') },`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` array(node): string { throw new Error('not implemented') },`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` object(node): string { throw new Error('not implemented') },`, + `}`, + ``, + ) + return { + header: null, + imports: [ + { + from: ctx.parserModule, + names: [ctx.parserName, ...ctx.nodeTypes.map((t) => `type ${t}`), `type ${ctx.treeName}`], + }, + { + from: '@configuredthings/rdp.js', + names: ['transform', `type Transformer`, 'toJSONAST', 'fromJSONAST', `type JSONAST`], + }, + ], + lines, + } +} - lines.push(`}`) - lines.push(``) - lines.push(`// ── JSON → ${base} ──────────────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`// eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(`export const jsonTo${base}: Transformer = {`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` string(node): string { throw new Error('not implemented') },`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` number(node): string { throw new Error('not implemented') },`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` boolean(node): string { throw new Error('not implemented') },`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` null(node): string { throw new Error('not implemented') },`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` array(node): string { throw new Error('not implemented') },`) - lines.push(``) - lines.push(` // eslint-disable-next-line @typescript-eslint/no-unused-vars`) - lines.push(` object(node): string { throw new Error('not implemented') },`) - lines.push(`}`) - lines.push(``) - lines.push(`// ── Round-trip helpers ───────────────────────────────────────────────────────`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` as ${base} format and serialise the result to a JSON string.`) - lines.push(` *`) - lines.push(` * @param input - Source string in ${base} format.`) - lines.push(` * @returns A JSON string.`) - lines.push(` */`) - lines.push(`export function ${camelBase}ToJSONString(input: string): string {`) - lines.push(` return fromJSONAST(transform(${parserName}.parse(input), ${camelBase}ToJSON))`) - lines.push(`}`) - lines.push(``) - lines.push(`/**`) - lines.push(` * Parse \`input\` as a JSON string and emit it in ${base} format.`) - lines.push(` *`) - lines.push(` * @param input - A valid JSON string.`) - lines.push(` * @returns A string in ${base} format.`) - lines.push(` */`) - lines.push(`export function jsonStringTo${base}(input: string): string {`) - lines.push(` return transform(toJSONAST(input), jsonTo${base})`) - lines.push(`}`) - lines.push(``) +/** + * Round-trip helper functions for JSON transformer. + * When `hasPipeline` is true, `xxxToJSONString` delegates to `XxxPipeline.run()`. + */ +function jsonPublicApiSection(ctx: ScaffoldCtx, hasPipeline: boolean): Section { + const toJSONBody = hasPipeline + ? ` return ${ctx.pipelineClass}.run(input)` + : ` return fromJSONAST(transform(${ctx.parserName}.parse(input), ${ctx.camelBase}ToJSON))` + + const toJSONThrows = hasPipeline + ? [ + ` * @throws {@link ${ctx.errorClass}} If \`input\` does not match the grammar or is invalid.`, + ] + : [] + + const lines: string[] = [ + `/**`, + ` * Parse \`input\` as ${ctx.base} format and serialise the result to a JSON string.`, + ` *`, + ` * @param input - Source string in ${ctx.base} format.`, + ` * @returns A JSON string.`, + ...toJSONThrows, + ` */`, + `export function ${ctx.camelBase}ToJSONString(input: string): string {`, + toJSONBody, + `}`, + ``, + `/**`, + ` * Parse \`input\` as a JSON string and emit it in ${ctx.base} format.`, + ` *`, + ` * @param input - A valid JSON string.`, + ` * @returns A string in ${ctx.base} format.`, + ` */`, + `export function jsonStringTo${ctx.base}(input: string): string {`, + ` return transform(toJSONAST(input), jsonTo${ctx.base})`, + `}`, + ``, + ] + return { + header: hasPipeline + ? '// ── Public API ───────────────────────────────────────────────────────────────' + : '// ── Round-trip helpers ───────────────────────────────────────────────────────', + imports: hasPipeline + ? [{ from: '@configuredthings/rdp.js', names: ['RDParserException'] }] + : [], + lines, + } +} - return lines.join('\n') +/** Private `XxxPipeline` class for the facade+pipeline+json pattern. */ +function jsonPipelineSection(ast: GrammarAST, ctx: ScaffoldCtx): Section { + return facadePipelineSection(ast, ctx, 'json') +} + +// ── Multi-file split sections ───────────────────────────────────────────────── + +/** + * Exported `XxxPipeline` class for the split facade+pipeline+json pattern. + * Lives in its own file; imports `xxxToJSON` from the transformer artifact. + */ +function splitJsonPipelineSection(ctx: ScaffoldCtx): Section { + return { + header: '// ── Pipeline ────────────────────────────────────────────────────────────────', + imports: [ + { from: ctx.parserModule, names: [ctx.parserName, `type ${ctx.firstNodeType}`] }, + { from: ctx.transformerModule, names: [`${ctx.camelBase}ToJSON`] }, + { + from: '@configuredthings/rdp.js', + names: ['RDParserException', 'transform', 'fromJSONAST'], + }, + ], + lines: [ + `export class ${ctx.pipelineClass} {`, + ` static run(input: string): string {`, + ` const tree = ${ctx.pipelineClass}.#parse(input)`, + ` const result = ${ctx.pipelineClass}.#validate(tree)`, + ` if (!result.ok) throw new ${ctx.errorClass}(input)`, + ` return ${ctx.pipelineClass}.#transform(result.tree)`, + ` }`, + ``, + ` static #parse(input: string): ${ctx.firstNodeType} {`, + ` try {`, + ` return ${ctx.parserName}.parse(input)`, + ` } catch (e) {`, + ` if (e instanceof RDParserException) throw new ${ctx.errorClass}(input)`, + ` throw e`, + ` }`, + ` }`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` static #validate(`, + ` tree: ${ctx.firstNodeType},`, + ` ): { ok: true; tree: ${ctx.firstNodeType} } | { ok: false } {`, + ` // TODO: add semantic validation; return { ok: false } to reject`, + ` return { ok: true, tree }`, + ` }`, + ``, + ` // eslint-disable-next-line @typescript-eslint/no-unused-vars`, + ` static #transform(tree: ${ctx.firstNodeType}): string {`, + ` return fromJSONAST(transform(tree, ${ctx.camelBase}ToJSON))`, + ` }`, + `}`, + ``, + ], + } +} + +/** + * Facade public API for the split facade+pipeline+json pattern. + * Re-exports `XxxError` from the pipeline artifact so consumers have a single import point. + */ +function splitFacadeJsonWithPipelineSection(ctx: ScaffoldCtx): Section { + return { + header: null, + imports: [ + { from: ctx.pipelineModule, names: [ctx.pipelineClass] }, + { from: ctx.transformerModule, names: [`jsonTo${ctx.base}`] }, + { from: '@configuredthings/rdp.js', names: ['transform', 'toJSONAST'] }, + ], + lines: [ + `export { ${ctx.errorClass} } from '${ctx.pipelineModule}'`, + ``, + `/**`, + ` * Parse \`input\` as ${ctx.base} format and serialise the result to a JSON string.`, + ` *`, + ` * @param input - Source string in ${ctx.base} format.`, + ` * @returns A JSON string.`, + ` * @throws {\`${ctx.errorClass}\`} If \`input\` does not match the grammar or is invalid.`, + ` */`, + `export function ${ctx.camelBase}ToJSONString(input: string): string {`, + ` return ${ctx.pipelineClass}.run(input)`, + `}`, + ``, + `/**`, + ` * Parse \`input\` as a JSON string and emit it in ${ctx.base} format.`, + ` *`, + ` * @param input - A valid JSON string.`, + ` * @returns A string in ${ctx.base} format.`, + ` */`, + `export function jsonStringTo${ctx.base}(input: string): string {`, + ` return transform(toJSONAST(input), jsonTo${ctx.base})`, + `}`, + ``, + ], + } +} + +/** + * Facade public API for the split facade+json pattern (no pipeline). + * Parses inline and wraps `RDParserException` in `XxxError`. + */ +function splitFacadeJsonNoPipelineSection(ctx: ScaffoldCtx): Section { + return { + header: null, + imports: [ + { from: ctx.parserModule, names: [ctx.parserName, `type ${ctx.firstNodeType}`] }, + { from: ctx.transformerModule, names: [`${ctx.camelBase}ToJSON`, `jsonTo${ctx.base}`] }, + { + from: '@configuredthings/rdp.js', + names: ['RDParserException', 'transform', 'toJSONAST', 'fromJSONAST'], + }, + ], + lines: [ + `export class ${ctx.errorClass} extends Error {`, + ` constructor(input: string) {`, + ` super(\`invalid input: "\${input}"\`)`, + ` this.name = '${ctx.errorClass}'`, + ` }`, + `}`, + ``, + `/**`, + ` * Parse \`input\` as ${ctx.base} format and serialise the result to a JSON string.`, + ` *`, + ` * @param input - Source string in ${ctx.base} format.`, + ` * @returns A JSON string.`, + ` * @throws {\`${ctx.errorClass}\`} If \`input\` does not match the grammar.`, + ` */`, + `export function ${ctx.camelBase}ToJSONString(input: string): string {`, + ` let tree: ${ctx.firstNodeType}`, + ` try {`, + ` tree = ${ctx.parserName}.parse(input)`, + ` } catch (e) {`, + ` if (e instanceof RDParserException) throw new ${ctx.errorClass}(input)`, + ` throw e`, + ` }`, + ` return fromJSONAST(transform(tree, ${ctx.camelBase}ToJSON))`, + `}`, + ``, + `/**`, + ` * Parse \`input\` as a JSON string and emit it in ${ctx.base} format.`, + ` *`, + ` * @param input - A valid JSON string.`, + ` * @returns A string in ${ctx.base} format.`, + ` */`, + `export function jsonStringTo${ctx.base}(input: string): string {`, + ` return transform(toJSONAST(input), jsonTo${ctx.base})`, + `}`, + ``, + ], + } +} + +// ── Multi-file public entry point ───────────────────────────────────────────── + +/** Derive the output filename for a single-artifact scaffold. */ +function scaffoldFilename(flags: ScaffoldFlags, ctx: ScaffoldCtx): string { + if (flags.facade) return `${ctx.camelBase}-facade.ts` + if (flags.pipeline) return `${ctx.camelBase}-pipeline.ts` + if (flags.transformer) return `${ctx.camelBase}-transformer.ts` + return `${ctx.camelBase}-scaffold.ts` +} + +/** + * Generate all scaffold artifacts for a grammar, returning a map of + * `filename → TypeScript source`. Use this when writing to a directory + * (`--outdir`); each entry is written as a sibling file. + * + * Span-lexer parser (`{ParserName}.ts`), transformer, pipeline, and facade + * artifacts are all included when the corresponding flags are set. + * + * @param source - EBNF or ABNF grammar source text. + * @param flags - Which scaffold dimensions to include. + * @param options - Generator configuration (same as `generateParser`). + * @returns A `Record` with one entry per output file. + */ +export function generateScaffoldFiles( + source: string, + flags: ScaffoldFlags, + options: GeneratorOptions = {}, +): Record { + const { traversal, transformer, facade, pipeline } = flags + + if (traversal && transformer) { + throw new Error( + `--traversal and --transformer are mutually exclusive. ` + + `Use --traversal (interpreter or tree-walker) to walk the parse tree directly, ` + + `or --transformer to emit a Transformer object — not both in the same scaffold.`, + ) + } + if (traversal === Traversal.Interpreter && pipeline) { + throw new Error( + '--traversal interpreter cannot be combined with --pipeline. ' + + 'The interpreter evaluates directly during parsing — there is no intermediate tree for ' + + 'the validate stage to inspect. Use --traversal tree-walker --pipeline instead.', + ) + } + + const format = options.format ?? 'ebnf' + const parserName = options.parserName ?? 'GeneratedParser' + const treeName = options.treeName ?? 'ParseTree' + + const ast = + format === 'abnf' + ? ABNFParser.parse(source, { + ...(options.caseSensitiveStrings !== undefined && { + caseSensitiveStrings: options.caseSensitiveStrings, + }), + }) + : EBNFParser.parse(source) + detectLeftRecursion(ast) + + const result: Record = {} + + // Span-lexer parser always goes to its own file. + if (flags.lexer === Lexer.Span) { + result[`${parserName}.ts`] = generateSpanLexerScaffold(ast, parserName, false, treeName) + } + + // No transformer/facade/pipeline → parser only (scannerless or span already added). + if (!transformer && !facade && !pipeline) { + if (flags.lexer !== Lexer.Span) { + result[`${parserName}.ts`] = generateParser(source, { + ...options, + ...(traversal !== undefined && { traversal }), + }) + } + return result + } + + const ctx = buildCtx(ast, parserName, treeName) + + // ── JSON + facade + pipeline → 3 files ────────────────────────────────────── + if (transformer === Transformer.JSON && facade && pipeline) { + result[`${ctx.camelBase}-transformer.ts`] = compose( + { + variantLabel: 'JSON transformer', + stepsLines: [ + `// Steps: 1) fill in ${ctx.camelBase}ToJSON handlers 2) fill in jsonTo${ctx.base} handlers`, + `// 3) remove eslint-disable-next-line comments`, + ], + sections: [jsonTransformerSection(ast, ctx)], + }, + ctx, + ) + result[`${ctx.camelBase}-pipeline.ts`] = compose( + { + variantLabel: 'Pipeline:json', + stepsLines: [ + `// Steps: 1) add semantic validation in #validate 2) remove eslint-disable-next-line comments`, + ], + sections: [errorSection(ctx), splitJsonPipelineSection(ctx)], + }, + ctx, + ) + result[`${ctx.camelBase}-facade.ts`] = compose( + { + variantLabel: 'Facade:json', + stepsLines: [], + sections: [splitFacadeJsonWithPipelineSection(ctx)], + }, + ctx, + ) + return result + } + + // ── JSON + facade (no pipeline) → 2 files ─────────────────────────────────── + if (transformer === Transformer.JSON && facade) { + result[`${ctx.camelBase}-transformer.ts`] = compose( + { + variantLabel: 'JSON transformer', + stepsLines: [ + `// Steps: 1) fill in ${ctx.camelBase}ToJSON handlers 2) fill in jsonTo${ctx.base} handlers`, + `// 3) remove eslint-disable-next-line comments`, + ], + sections: [jsonTransformerSection(ast, ctx)], + }, + ctx, + ) + result[`${ctx.camelBase}-facade.ts`] = compose( + { + variantLabel: 'Facade:json', + stepsLines: [ + `// Steps: 1) fill in stubs in ${ctx.camelBase}-transformer.ts 2) remove eslint-disable-next-line comments`, + ], + sections: [splitFacadeJsonNoPipelineSection(ctx)], + }, + ctx, + ) + return result + } + + // ── All other scaffold combinations → single file ──────────────────────────── + const plan = planScaffold(flags, ast, ctx) + result[scaffoldFilename(flags, ctx)] = compose(plan, ctx) + return result } // ── Init scaffold ───────────────────────────────────────────────────────────── @@ -1093,6 +1377,7 @@ function generateSpanLexerScaffold( ast: GrammarAST, parserName: string, evaluating: boolean, + treeName: string = 'ParseTree', ): string { const firstRule = ast.rules[0] if (!firstRule) return '' @@ -1156,55 +1441,19 @@ function generateSpanLexerScaffold( `// ${scaffoldVariant} scaffold generated by rdp-gen — this file is not regenerated; edit freely.`, ) lines.push(`import { TokenRDParser, type TokenStream } from '@configuredthings/rdp.js'`) - lines.push(`//`) - lines.push(`// A two-stage tokeniser pipeline for ${parserName}:`) - lines.push( - `// Stage 1 — spanTokenize(input) : identifies token boundaries without string allocation`, - ) - lines.push( - `// Stage 2 — classify(input, spans): maps raw spans to typed tokens for this grammar`, - ) - if (evaluating) { - lines.push( - `// Stage 3 — TokenParser.parse() : recursive descent that evaluates directly during descent`, - ) - lines.push(`// (no intermediate tree is materialised)`) - } else { - lines.push(`// Stage 3 — TokenParser.parse() : recursive descent on the typed token stream`) - } - lines.push(`//`) - lines.push( - `// Why? A scannerless parser tries every terminal alternative at each character position.`, - ) - lines.push( - `// For grammars with large character-class rules this creates significant repeated work.`, - ) - lines.push( - `// A span tokeniser collapses those checks into one forward scan; the classifier then`, - ) - lines.push( - `// applies grammar-specific typing as a second pass. Result: 3–5× faster on real grammars,`, - ) - lines.push(`// and the whole pipeline is fully mechanisable from the grammar.`) - lines.push(`//`) + lines.push(``) + lines.push(`// ── Parse tree types ───────────────────────────────────────────────────────────`) + lines.push(``) + lines.push(...emitTypeDeclarations(ast, treeName)) lines.push(`// Steps to complete:`) lines.push( `// 1) Review TT — add keyword constants if your grammar distinguishes reserved words`, ) lines.push(`// 2) Adjust SPAN_OPTS if your grammar uses different comment or string delimiters`) - if (evaluating) { - lines.push( - `// 3) Fill in each #parse method to evaluate directly and return your concrete type`, - ) - lines.push( - `// 4) Change the return type of parse() from 'unknown' to your concrete result type`, - ) - } else { - lines.push(`// 3) Fill in each #parse method in TokenParser (all throw by default)`) - lines.push( - `// 4) Change the return type of parse() from 'unknown' to your concrete result type`, - ) - } + lines.push(`// 3) Fill in each #parse method in TokenParser (all throw by default)`) + lines.push( + `// 4) Change the return type of parse() from 'unknown' to your concrete result type`, + ) lines.push(``) // ── TT enum ──────────────────────────────────────────────────────────── @@ -1422,8 +1671,7 @@ function generateSpanLexerScaffold( lines.push(` * Change the return type of \`parse()\` from \`unknown\` to your concrete result.`) } lines.push(` */`) - const tokenParserName = `${stripParserSuffix(parserName)}TokenParser` - lines.push(`export class ${tokenParserName} extends TokenRDParser {`) + lines.push(`export class ${parserName} extends TokenRDParser {`) lines.push(` private constructor(stream: TokenStream) {`) lines.push(` super(stream)`) lines.push(` }`) @@ -1431,7 +1679,7 @@ function generateSpanLexerScaffold( lines.push(` static parse(input: string): unknown {`) lines.push(` const spans = spanTokenize(input)`) lines.push(` const stream = classify(input, spans)`) - lines.push(` return new ${tokenParserName}(stream).#parse${pascalCase(firstRule.name)}()`) + lines.push(` return new ${parserName}(stream).#parse${pascalCase(firstRule.name)}()`) lines.push(` }`) lines.push(``) lines.push(` // ── Production rules ───────────────────────────────────────────────────────`) @@ -1472,6 +1720,7 @@ function stripParserSuffix(parserName: string): string { function pascalCase(name: string): string { return name.replace(/(^|[-_])([a-zA-Z])/g, (_, __, c: string) => c.toUpperCase()) } + /** * Generate `node.field: Type` hint strings for a production rule's fields, * mirroring the shape of the emitted `XxxNode` type. Used as inline comments diff --git a/src/generator/type-gen.ts b/src/generator/type-gen.ts index bd584a9..dd0ceee 100644 --- a/src/generator/type-gen.ts +++ b/src/generator/type-gen.ts @@ -61,13 +61,23 @@ export function generateTypes(ast: GrammarAST, options: TypeGenOptions = {}): st '// This file is generated by rdp-gen (@configuredthings/rdp.js). Do not edit by hand.', ) lines.push('') + lines.push(...emitTypeDeclarations(ast, treeName)) + + return lines.join('\n') +} + +/** + * Emit just the type declaration lines (node types + tree union) without a file header. + * Used when embedding types inside another scaffold file. + */ +export function emitTypeDeclarations(ast: GrammarAST, treeName: string): string[] { + const lines: string[] = [] for (const rule of ast.rules) { lines.push(...emitRuleType(rule)) lines.push('') } - // Root union const nodeNames = ast.rules.map((r) => pascalCase(r.name) + 'Node') lines.push(`export type ${treeName} =`) for (let i = 0; i < nodeNames.length; i++) { @@ -75,7 +85,7 @@ export function generateTypes(ast: GrammarAST, options: TypeGenOptions = {}): st } lines.push('') - return lines.join('\n') + return lines } /** diff --git a/src/index.ts b/src/index.ts index fdbc536..79c5f69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,41 @@ export function visit( return fn?.(node) } +// ── Mixin types ──────────────────────────────────────────────────────────────── + +/** + * Structural type for a parser class that evaluates the parse tree inline. + * + * A class `implements InterpreterMixin` must define one + * method per grammar rule: `evalXxx(node: XxxNode): TResult`. TypeScript + * enforces exhaustiveness at compile time — adding a grammar rule without + * adding the corresponding `eval` method is a type error. + * + * Rule names are PascalCased: a rule named `binaryExpr` produces `evalBinaryExpr`. + * + * @template TTree - The discriminated union of parse-tree node types. + * @template TResult - The concrete evaluation result type (use `unknown` as a placeholder). + */ +export type InterpreterMixin = { + [K in TTree['kind'] as `eval${Capitalize}`]: ( + node: Extract, + ) => TResult +} + +/** + * Module-shape type for a module that exports `childNodes()` and `walk()` over + * a grammar's parse tree. + * + * The generated parser module satisfies this type when `--traversal tree-walker` + * is used. Import and re-export from your application code as needed. + * + * @template TTree - The discriminated union of parse-tree node types. + */ +export type WalkerMixin = { + childNodes(node: TTree): TTree[] + walk(node: TTree, visitor: Visitor): void +} + // ── Transformer ──────────────────────────────────────────────────────────────── /**