From 3d6897b94b92c8633fa2d6c0be9e1a5e71fad668 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:05:04 +1000 Subject: [PATCH 1/4] add type diagram stuff --- Makefile | 8 +- README.md | 51 +- coverage-thresholds.json | 6 +- docs/messaging.md | 17 +- docs/plans/PLAN.md | 2 +- docs/plans/typediagram-integration.md | 97 +-- docs/specs/typediagram-markdown.md | 20 +- examples/storefront/README.md | 15 + examples/storefront/docs/shipping.dmx.md | 132 ++++ examples/storefront/lib/shipping.dart | 79 +++ examples/storefront/lib/shipping_wire.dart | 38 ++ examples/storefront/test/shipping_test.dart | 136 ++++ scripts/typediagram-oracle.mjs | 83 +++ src/dmx/Cargo.lock | 14 +- src/dmx/Cargo.toml | 4 + src/dmx/src/dartmacros.rs | 8 +- src/dmx/src/emit.rs | 191 ++++-- src/dmx/src/engine.rs | 14 +- src/dmx/src/hygiene.rs | 190 ++++++ src/dmx/src/jsoncontent.rs | 30 +- src/dmx/src/lib.rs | 45 +- src/dmx/src/macros/mod.rs | 104 ++- src/dmx/src/macros/typediagram.rs | 267 ++++++++ src/dmx/src/main.rs | 54 +- src/dmx/src/render.rs | 21 +- src/dmx/src/typediagram/ast.rs | 348 ++++++++++ src/dmx/src/typediagram/context.rs | 385 ++++++++++++ src/dmx/src/typediagram/context_tests.rs | 188 ++++++ src/dmx/src/typediagram/diagnostic.rs | 140 +++++ src/dmx/src/typediagram/document.rs | 329 ++++++++++ src/dmx/src/typediagram/emit.rs | 237 +++++++ src/dmx/src/typediagram/json.rs | 226 +++++++ src/dmx/src/typediagram/lexer.rs | 401 ++++++++++++ src/dmx/src/typediagram/markdown.rs | 496 +++++++++++++++ src/dmx/src/typediagram/mod.rs | 201 ++++++ src/dmx/src/typediagram/model.rs | 343 ++++++++++ src/dmx/src/typediagram/parser.rs | 409 ++++++++++++ src/dmx/src/typediagram/parser_tests.rs | 148 +++++ src/dmx/src/typediagram/target.rs | 329 ++++++++++ src/dmx/src/watch.rs | 113 +++- src/dmx/tests/cli.rs | 8 +- src/dmx/tests/support/mod.rs | 17 +- .../corpus/aliases-and-functions.model.json | 238 +++++++ .../corpus/aliases-and-functions.td | 23 + .../typediagram/corpus/records.model.json | 173 +++++ src/dmx/tests/typediagram/corpus/records.td | 30 + .../typediagram/corpus/scalars.model.json | 172 +++++ src/dmx/tests/typediagram/corpus/scalars.td | 20 + .../typediagram/corpus/targeting.model.json | 84 +++ src/dmx/tests/typediagram/corpus/targeting.td | 14 + .../typediagram/corpus/unions.model.json | 258 ++++++++ src/dmx/tests/typediagram/corpus/unions.td | 35 ++ src/dmx/tests/typediagram_cli.rs | 593 ++++++++++++++++++ src/dmx/tests/typediagram_model.rs | 180 ++++++ src/dmx/tests/watch_cli.rs | 198 +++++- src/editors/vscode/e2e/fixture.js | 34 +- src/editors/vscode/e2e/run.js | 6 +- src/editors/vscode/e2e/suite/watch.e2e.js | 37 ++ src/editors/vscode/package.json | 1 + src/editors/vscode/paths.js | 39 +- src/editors/vscode/test/paths.test.js | 52 +- website/e2e/navigation.spec.ts | 7 + website/src/docs/index.md | 5 + website/src/docs/models-in-markdown.md | 173 +++++ 64 files changed, 8103 insertions(+), 213 deletions(-) create mode 100644 examples/storefront/docs/shipping.dmx.md create mode 100644 examples/storefront/lib/shipping.dart create mode 100644 examples/storefront/lib/shipping_wire.dart create mode 100644 examples/storefront/test/shipping_test.dart create mode 100644 scripts/typediagram-oracle.mjs create mode 100644 src/dmx/src/hygiene.rs create mode 100644 src/dmx/src/macros/typediagram.rs create mode 100644 src/dmx/src/typediagram/ast.rs create mode 100644 src/dmx/src/typediagram/context.rs create mode 100644 src/dmx/src/typediagram/context_tests.rs create mode 100644 src/dmx/src/typediagram/diagnostic.rs create mode 100644 src/dmx/src/typediagram/document.rs create mode 100644 src/dmx/src/typediagram/emit.rs create mode 100644 src/dmx/src/typediagram/json.rs create mode 100644 src/dmx/src/typediagram/lexer.rs create mode 100644 src/dmx/src/typediagram/markdown.rs create mode 100644 src/dmx/src/typediagram/mod.rs create mode 100644 src/dmx/src/typediagram/model.rs create mode 100644 src/dmx/src/typediagram/parser.rs create mode 100644 src/dmx/src/typediagram/parser_tests.rs create mode 100644 src/dmx/src/typediagram/target.rs create mode 100644 src/dmx/tests/typediagram/corpus/aliases-and-functions.model.json create mode 100644 src/dmx/tests/typediagram/corpus/aliases-and-functions.td create mode 100644 src/dmx/tests/typediagram/corpus/records.model.json create mode 100644 src/dmx/tests/typediagram/corpus/records.td create mode 100644 src/dmx/tests/typediagram/corpus/scalars.model.json create mode 100644 src/dmx/tests/typediagram/corpus/scalars.td create mode 100644 src/dmx/tests/typediagram/corpus/targeting.model.json create mode 100644 src/dmx/tests/typediagram/corpus/targeting.td create mode 100644 src/dmx/tests/typediagram/corpus/unions.model.json create mode 100644 src/dmx/tests/typediagram/corpus/unions.td create mode 100644 src/dmx/tests/typediagram_cli.rs create mode 100644 src/dmx/tests/typediagram_model.rs create mode 100644 website/src/docs/models-in-markdown.md diff --git a/Makefile b/Makefile index 85b9866..60c1865 100644 --- a/Makefile +++ b/Makefile @@ -308,8 +308,12 @@ dart-package-publish: dart-package ## Prove the pub archive is publishable (need @# so this passes only from a clean checkout — which is what a tag is. cd $(DMX_PACKAGE_DIR) && dart pub publish --dry-run -example: ## Generate the example, analyze it, run its checks - cargo run $(CRATE) --quiet -- build $(EXAMPLE_DIR)/lib --insert-regions +example: ## Generate the example — annotated Dart and its typeDiagram document — analyze it, run its checks + @# One invocation for both backends. Annotated Dart is generated INTO, and a + @# `*.dmx.md` document resolves its outputs against the package it belongs to + @# [typediagram.output] — so neither depends on where this runs from, unlike + @# the macro-worker examples below, whose workers are found from the cwd. + cargo run $(CRATE) --quiet -- build $(EXAMPLE_DIR)/lib $(EXAMPLE_DIR)/docs --insert-regions cd $(EXAMPLE_DIR) && dart pub get && dart analyze --fatal-infos && dart test EXAMPLE run-example: example diff --git a/README.md b/README.md index 8968522..b17cb8e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,41 @@ built-ins use. Two worked examples do exactly that: one reads a live [SQLite database](examples/dmx_sqlite_example/README.md), one reads an [OpenAPI document](examples/dmx_openapi_example/README.md). +**Models defined in Markdown.** Some types have no Dart file to annotate yet. A +`*.dmx.md` document holds a [typeDiagram](https://typediagram.dev/docs/) +definition and, immediately below it, the Mustache templates that generate from +it: + +````markdown +```typeDiagram +type Parcel { + id: Uuid + weightG: Int + insured: Option +} +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +``` +```` + +Save the document and dmx writes `lib/parcel.dart`, relative to the package the +document belongs to. The definition still renders as a diagram in any +typeDiagram viewer, so one page is the model, the documentation, and the build +input. dmx reads the definition itself — no Node, no npm package, no +`typediagram` executable — and the template decides every generated byte. One +definition may feed several templates: the +[shipping document](examples/storefront/docs/shipping.dmx.md) defines four types +once and generates two different Dart files from them. + **It never writes broken Dart.** | Guarantee | Mechanism | @@ -120,17 +155,23 @@ built-ins use. Two worked examples do exactly that: one reads a live | Leaves labelled folds alone | Only the bare, unlabelled `//#region` block is machine-owned | | Repairs a region you gutted | dmx empties the region, re-parses, and regenerates [emission.inline-backend.region-recovery] | | Zero writes when nothing changed | Byte-compare before write [emission.inline-backend.no-op-writes] | -| Generated code obeys the house rules | No `throw`, `as`, `!` or `_$` names — asserted over the whole golden corpus | +| Generated code obeys the house rules | No `throw`, `as`, `!` or `_$` names — asserted over the whole golden corpus, and enforced on the CST for templates dmx did not write [hygiene] | +| Never overwrites a file it does not own | A generated file carries an ownership marker on its first line; anything without one is yours [dartmacros.files] | ## CLI ``` -dmx build [PATHS...] [--insert-regions] [--check] -dmx watch [PATHS...] +dmx build [PATHS...] [--insert-regions] [--check] +dmx watch [PATHS...] +dmx explain FILE ``` -Both default to `lib`. `watch` regenerates changed `.dart` files and debounces -save bursts. `--check` writes nothing and exits 2 on drift, for CI. +`build` and `watch` default to `lib`. Both take Dart sources, `*.dmx.md` +documents found under the paths given, and any Markdown file named explicitly. +`watch` regenerates what changed and debounces save bursts. `--check` writes +nothing and exits 2 on drift, for CI. `dmx explain docs/models.dmx.md` prints +each generation group, its outputs, its dependency digests, and the exact +context its templates will see — without generating anything. ## Working on dmx diff --git a/coverage-thresholds.json b/coverage-thresholds.json index c8d2a61..3653129 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -5,11 +5,11 @@ "_measuring": "Every component reports LCOV, and the gate sums LH/LF per component. Field order differs between producers (Node emits LH before LF, vitest emits LF before LH) — summing each field independently is order-independent, which is why the check does that rather than parsing records.", "components": { "rust": { - "threshold": 90, + "threshold": 93, "lcov": "lcov.info", - "_covers": "The dmx crate at src/dmx — the parser, context builder, renderer, validator and emitter. Everything the binary does.", + "_covers": "The dmx crate at src/dmx — the parser, context builder, renderer, validator and emitter. Everything the binary does, the typeDiagram Markdown front end included.", "_produced_by": "cargo llvm-cov --workspace --all-targets", - "_measured": "91.2% when the macro catalogue landed. [COVERAGE-THRESHOLDS] requires 85 for a CLI tool, which this clears." + "_measured": "93.7% (6003/6404 lines) when the typeDiagram Markdown macro landed, up from 91.2% when the macro catalogue did. [COVERAGE-THRESHOLDS] requires 85 for a CLI tool, which this clears twice over." }, "dart-package": { "threshold": 13, diff --git a/docs/messaging.md b/docs/messaging.md index bbd74a4..343db2c 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -32,7 +32,9 @@ dmx does not replace one fixed model shape with another. Teams encode their exac Custom macros and Mustache templates are one system, not competing options. A macro can return Dart directly, and a small one usually should. It can also hand its model to a Mustache template and let dmx render it with the same engine the built-ins use, which is how a project keeps generation logic and output shape in separate files: the macro answers questions only the project can answer, and the template decides what the emitted Dart looks like. The [OpenAPI example](../examples/dmx_openapi_example/README.md) reads a published API document and renders a typed client and its models through project-owned templates. -> Save the file and keep coding. Use a built-in, change what it emits with a Mustache template, or write a macro in Dart—and render a Mustache template from inside that macro too. +Some models have no Dart file to annotate yet. A `*.dmx.md` document holds a [typeDiagram](https://typediagram.dev/docs/) definition and, immediately below it, the Mustache templates that generate from it. Save the document and dmx writes the `.dart` files those templates name. The definition still renders as a diagram in any typeDiagram viewer, so one page is the model, the documentation, and the build input. The [shipping document](../examples/storefront/docs/shipping.dmx.md) defines four types once and generates two different Dart files from them. + +> Save the file and keep coding. Use a built-in, change what it emits with a Mustache template, write a macro in Dart—and render a Mustache template from inside that macro too—or define the types in Markdown and let the templates write the Dart. ## Ready-to-use copy @@ -48,9 +50,13 @@ Open the project, edit Dart, and save. dmx updates generated code automatically, [Try the real generator in your browser](https://dmx.dev/playground.html)—no install required. +### Models in Markdown + +**Define the types once in a `*.dmx.md` document; the Mustache templates under the diagram write the Dart.** Save the document and every file it names updates—no annotated Dart source, no `part` file, and the diagram still renders. + ### Repository -Fast Dart code generation on every save, with no generated `part` files: built-in macros, team-owned Mustache templates, custom Dart macros, and validated inline output. +Fast Dart code generation on every save, with no generated `part` files: built-in macros, team-owned Mustache templates, custom Dart macros, models defined in Markdown, and validated inline output. ## Message order @@ -60,7 +66,8 @@ Fast Dart code generation on every save, with no generated `part` files: built-i 4. **Useful immediately:** built-ins cover common models, unions, routes, clients, and more. 5. **The team's shape:** Mustache controls the exact generated Dart. 6. **Full custom macros:** inspect typed declaration data, read project data, and generate members or complete files—returning Dart directly, or rendering it through the same Mustache engine the built-ins use. -7. **Reliable writes:** validate complete Dart files before writing and preserve handwritten source on failure. +7. **Models with no Dart to annotate:** a `*.dmx.md` document defines the types once and its Mustache templates generate the `.dart` files. +8. **Reliable writes:** validate complete Dart files before writing and preserve handwritten source on failure. ## Demo order @@ -69,6 +76,7 @@ Fast Dart code generation on every save, with no generated `part` files: built-i 3. Rename a field, save, and show generated members update immediately. 4. Change a Mustache template and show the team's model shape appear. 5. Add a SQLite table and show a complete Dart file appear. +6. Add a field to a `*.dmx.md` diagram, save, and show both generated Dart files change together. ## Positioning @@ -106,6 +114,9 @@ Version control is the team's choice. Commit generated files when a checkout sho - Never present Mustache and custom Dart macros as a choice between two paths. A macro may return Dart directly *or* render a template, and the two are designed to be used together; describing templates as what you use “instead of” a macro, or a macro as what you write “when Mustache runs out”, misstates the design. - “Commit or ignore” applies directly to complete generated files; inline output is committed with its source file. - Never attack `build_runner`, claim it cannot watch, call dmx a drop-in Freezed replacement, or lead with compiler-architecture jargon. Lead with no generated `part` files. +- Never say dmx "runs typeDiagram", "calls the typeDiagram CLI", or "uses `typediagram --to dart`". It does none of those. dmx reads the definition itself, and the Mustache template decides every generated byte—so never imply the output shape comes from typeDiagram either. Installing dmx installs nothing else: no Node, no npm package, no `typediagram` executable. +- A template binds to the definition **immediately above it**. Never show or describe a heading, a paragraph, or another fence between them, and never suggest binding follows a heading's text or a fence's position in the document. +- An output path in a document is relative to the package the document belongs to—the nearest `pubspec.yaml`. Do not describe it as relative to the document, to the repository root, or to wherever dmx was run. ## Calls to action diff --git a/docs/plans/PLAN.md b/docs/plans/PLAN.md index 7abb7b3..75af518 100644 --- a/docs/plans/PLAN.md +++ b/docs/plans/PLAN.md @@ -12,7 +12,7 @@ The backlog is split by topic so each plan stays small and focused. | [Implementation phases](implementation-phases.md) | `[phases]` | Release sequence and phase exit criteria | | [Macro catalogue](macro-catalogue.md) | `[catalogue]` | Built-in and user-defined macro backlog | | [Worked examples](worked-examples.md) | `[corpus]` | Storefront and golden-corpus coverage | -| [typeDiagram integration](typediagram-integration.md) | `[typediagram.delivery]` | typeDiagram Markdown definitions plus Mustache templates to generated Dart | +| [typeDiagram integration](typediagram-integration.md) | `[typediagram.delivery]` | typeDiagram Markdown definitions plus Mustache templates to generated Dart — **delivered**; the normative rules are [typediagram] in `SPEC.md`, and what is left is [typediagram.delivery.next] | | [Semantic front end and static metaprogramming](semantic-metaprogramming.md) | `[semantic-expansion]` | Scope resolution, type inference, typed expansion, and elaborated type-system extensions | Every section in these files has a unique, hierarchical, non-numeric identifier shared with `SPEC.md`. Adding a plan file does not create a second identifier namespace. diff --git a/docs/plans/typediagram-integration.md b/docs/plans/typediagram-integration.md index 2b60b21..8fb2a04 100644 --- a/docs/plans/typediagram-integration.md +++ b/docs/plans/typediagram-integration.md @@ -24,57 +24,57 @@ Use typeDiagram's public parser, model builder, and [versioned `toJSON` model AP ### [typediagram.delivery.phases.contract] TD0 — Freeze the Markdown Contract -- [ ] Add golden `*.dmx.md` fixtures showing one definition/one template, one definition/several templates, several independent groups, documentation-only diagrams, and ordinary Mustache examples that dmx ignores. -- [ ] Add failing fixtures for malformed JSON metadata, orphan templates, duplicate outputs, invalid typeDiagram, unsafe paths, and unowned destination files. -- [ ] Freeze the JSON fence metadata and adjacency rules from [typediagram.binding]. -- [ ] Freeze `typeDiagram` as the built-in macro name and the synthesized invocation shape from [typediagram.macro]. -- [ ] Exit: every syntax choice has a fixture and no binding depends on prose text, headings, or document-global ordering. +- [x] Add golden `*.dmx.md` fixtures showing one definition/one template, one definition/several templates, several independent groups, documentation-only diagrams, and ordinary Mustache examples that dmx ignores. +- [x] Add failing fixtures for malformed JSON metadata, orphan templates, duplicate outputs, invalid typeDiagram, unsafe paths, and unowned destination files. +- [x] Freeze the JSON fence metadata and adjacency rules from [typediagram.binding]. +- [x] Freeze `typeDiagram` as the built-in macro name and the synthesized invocation shape from [typediagram.macro]. +- [x] Exit: every syntax choice has a fixture and no binding depends on prose text, headings, or document-global ordering. ### [typediagram.delivery.phases.markdown] TD1 — Parse and Bind Markdown -- [ ] Parse Markdown into a CommonMark AST with source spans; do not scan fences with regex. -- [ ] Discover `*.dmx.md` recursively while allowing any Markdown file when explicitly named. -- [ ] Preserve non-generation content and distinguish plain examples from dmx-enabled Mustache fences. -- [ ] Build immutable generation-group values containing the definition span, template span, metadata, and normalized output path. -- [ ] Translate every valid group into one macro invocation without creating a parallel rendering path. -- [ ] Exit: the binding golden suite passes over upstream-compatible backtick fences, longer fences, CRLF, Unicode, interleaved prose, unrelated code blocks, and multiple groups. +- [x] Parse Markdown into a CommonMark AST with source spans; do not scan fences with regex. +- [x] Discover `*.dmx.md` recursively while allowing any Markdown file when explicitly named. +- [x] Preserve non-generation content and distinguish plain examples from dmx-enabled Mustache fences. +- [x] Build immutable generation-group values containing the definition span, template span, metadata, and normalized output path. +- [x] Translate every valid group into one macro invocation without creating a parallel rendering path. +- [x] Exit: the binding golden suite passes over upstream-compatible backtick fences, longer fences, CRLF, Unicode, interleaved prose, unrelated code blocks, and multiple groups. ### [typediagram.delivery.phases.model] TD2 — Build the typeDiagram Model -- [ ] Implement the documented typeDiagram grammar as a small tokenizing/LL(1) Rust front end, returning `Result` diagnostics with source spans. -- [ ] Resolve declarations, generic parameters, built-ins, and nested references into one immutable model without casts or exceptions. -- [ ] Serialize the resolved model to the same semantic shape as the pinned typeDiagram model JSON. -- [ ] Differential-test every compatibility fixture against typeDiagram's public parser/model JSON in CI so upstream language drift is visible. -- [ ] Reject unknown or unsupported references before Mustache rendering. -- [ ] Exit: the Rust model and typeDiagram oracle agree structurally for the complete corpus, including diagnostics for invalid definitions. +- [x] Implement the documented typeDiagram grammar as a small tokenizing/LL(1) Rust front end, returning `Result` diagnostics with source spans. +- [x] Resolve declarations, generic parameters, built-ins, and nested references into one immutable model without casts or exceptions. +- [x] Serialize the resolved model to the same semantic shape as the pinned typeDiagram model JSON. +- [x] Differential-test every compatibility fixture against typeDiagram's public parser/model JSON in CI so upstream language drift is visible. +- [x] Reject unknown or unsupported references before Mustache rendering. +- [x] Exit: the Rust model and typeDiagram oracle agree structurally for the complete corpus, including diagnostics for invalid definitions. ### [typediagram.delivery.phases.context] TD3 — Enrich the Mustache Context -- [ ] Add `src/macros/typediagram.rs` and register it as the built-in `typeDiagram` macro for Markdown generation-group targets. -- [ ] Define and version the root context specified by [typediagram.model]. -- [ ] Preserve each declaration exactly once and in source order; add mutually exclusive kind flags instead of duplicating declarations into per-kind lists. -- [ ] Precompute generic declarations, Dart type text, commas, first/last markers, constructor fragments, and every other value required to keep templates logic-free. -- [ ] Reuse the existing Mustache renderer, partial resolver, span mapping, normalizer, and deterministic ordering. -- [ ] Add `dmx explain` snapshots of the exact context for every declaration kind. -- [ ] Exit: a Mustache template generates analyze-clean records and sealed unions without implementing type resolution inside the template. +- [x] Add `src/macros/typediagram.rs` and register it as the built-in `typeDiagram` macro for Markdown generation-group targets. +- [x] Define and version the root context specified by [typediagram.model]. +- [x] Preserve each declaration exactly once and in source order; add mutually exclusive kind flags instead of duplicating declarations into per-kind lists. +- [x] Precompute generic declarations, Dart type text, commas, first/last markers, constructor fragments, and every other value required to keep templates logic-free. +- [x] Reuse the existing Mustache renderer, partial resolver, span mapping, normalizer, and deterministic ordering. +- [x] Add `dmx explain` snapshots of the exact context for every declaration kind. +- [x] Exit: a Mustache template generates analyze-clean records and sealed unions without implementing type resolution inside the template. ### [typediagram.delivery.phases.emission] TD4 — Validate and Emit Whole Files -- [ ] Route rendered Dart through hygiene and full-file parse validation before any write. -- [ ] Add ownership headers containing both fence identities and content hashes. -- [ ] Reuse macro-authored-file safety: atomic writes, no-op writes, unowned-file refusal, stale collection, and `--check` drift. -- [ ] Make build cache keys depend on semantic definition content, template content, resolved partials, context version, and dmx version—not unrelated Markdown prose. -- [ ] Extend watch mode to retain the last valid output during invalid edits and recover deterministically. -- [ ] Exit: build/check/watch tests pass without rewriting the Markdown source or an unowned output. +- [x] Route rendered Dart through hygiene and full-file parse validation before any write. +- [x] Add ownership headers containing both fence identities and content hashes. +- [x] Reuse macro-authored-file safety: atomic writes, no-op writes, unowned-file refusal, stale collection, and `--check` drift. +- [x] Make build cache keys depend on semantic definition content, template content, resolved partials, context version, and dmx version—not unrelated Markdown prose. *(There is no on-disk cache: the ownership marker records those digests and the no-op write compares the whole candidate file, so prose never invalidates an output and a definition or template edit always does. A persistent cache is [execution.caching]'s, not this feature's.)* +- [x] Extend watch mode to retain the last valid output during invalid edits and recover deterministically. +- [x] Exit: build/check/watch tests pass without rewriting the Markdown source or an unowned output. ### [typediagram.delivery.phases.product] TD5 — Product Integration -- [ ] Add `DMX8xxx` diagnostics with Markdown, definition, template, and generated-output spans. -- [ ] Add C16 to the conformance suite and enforce byte-identical generation, zero-write second builds, analyzer-clean Dart, and cross-platform paths. -- [ ] Add a worked storefront document that defines models once and generates at least two Dart files from different Mustache templates. -- [ ] Document the feature in the CLI help, README, website, editor highlighting, and VS Code packaging tests. -- [ ] Add editor diagnostics and regeneration for saved `*.dmx.md` documents through the existing engine contract. -- [ ] Exit: the worked document builds from a clean checkout and remains current under the full `make ci` gate. +- [x] Add `DMX8xxx` diagnostics with Markdown, definition, template, and generated-output spans. +- [x] Add C16 to the conformance suite and enforce byte-identical generation, zero-write second builds, analyzer-clean Dart, and cross-platform paths. +- [x] Add a worked storefront document that defines models once and generates at least two Dart files from different Mustache templates. +- [x] Document the feature in the CLI help, README, website, editor highlighting, and VS Code packaging tests. *(Highlighting: `mustache` and `typeDiagram` fences inside Markdown use the editor's own Markdown grammar; dmx contributes no new grammar.)* +- [x] Add editor diagnostics and regeneration for saved `*.dmx.md` documents through the existing engine contract. +- [x] Exit: the worked document builds from a clean checkout and remains current under the full `make ci` gate. ## [typediagram.delivery.tests] Required Test Matrix @@ -92,6 +92,31 @@ Use typeDiagram's public parser, model builder, and [versioned `toJSON` model AP No mocks: E2E coverage drives the real `dmx` binary over real Markdown, Mustache, and Dart files. typeDiagram oracle tests invoke the pinned real package. +## [typediagram.delivery.done] What Shipped + +Every phase above is implemented, tested, and gated by `make ci`. + +| Where | What it is | +|---|---| +| `src/dmx/src/typediagram/{lexer,parser,ast,model}.rs` | The typeDiagram front end, in Rust, with no typeDiagram dependency | +| `src/dmx/src/typediagram/json.rs` | The model in upstream's JSON shape — the compatibility surface, read only by the differential corpus | +| `src/dmx/src/typediagram/markdown.rs` | CommonMark binding over `pulldown-cmark`, fences as AST nodes | +| `src/dmx/src/typediagram/context.rs` | The Mustache context, versioned by `CONTEXT_VERSION` | +| `src/dmx/src/typediagram/target.rs` | The one place a language appears: type text, extension, project marker, validation | +| `src/dmx/src/typediagram/{emit,document}.rs` | Path safety, ownership markers, stale collection, build/check/explain | +| `src/dmx/src/macros/typediagram.rs` | The built-in macro, in the same registry `@dmx('model')` is in | +| `src/dmx/src/hygiene.rs` | [hygiene] as a CST check, because a user template is nobody's reviewed code | +| `src/dmx/tests/typediagram/corpus` | The `.td` fixtures and the oracle's model JSON | +| `scripts/typediagram-oracle.mjs` | Development-only regeneration of that oracle from a typeDiagram checkout | +| `examples/storefront/docs/shipping.dmx.md` | One definition, two generated Dart files, 9 tests over them | + +### [typediagram.delivery.next] Not Yet Done + +- A second generation target. The abstraction is in place and carries one row; the value of the split is unproven until a second language uses it. +- `dmx explain --stages` for documents: `explain` prints groups, dependencies, paths, and the exact context, but not the render → hygiene → validation stages [execution]. +- A persistent build cache. Outputs are compared whole, which is correct and re-renders more than a cache would. +- Templates in a document cannot use partials; every bound fence is self-contained. + ## [typediagram.delivery.acceptance] Acceptance Criteria - Ordinary typeDiagram Markdown remains valid and renderable outside dmx. diff --git a/docs/specs/typediagram-markdown.md b/docs/specs/typediagram-markdown.md index aec2240..9f708f4 100644 --- a/docs/specs/typediagram-markdown.md +++ b/docs/specs/typediagram-markdown.md @@ -39,7 +39,7 @@ A typeDiagram source fence uses backticks, has the ordinary upstream-compatible A generation group is one typeDiagram fence followed immediately in the Markdown AST by one or more dmx-enabled Mustache fences. Blank lines do not create AST nodes and do not break the group. Any other Markdown node ends the group. -A dmx-enabled Mustache fence uses `mustache` as its language and a JSON object as the remainder of its info string. The object MUST contain `dmx.output`, a workspace-relative Dart output path: +A dmx-enabled Mustache fence uses `mustache` as its language and a JSON object as the remainder of its info string. The object MUST contain `dmx.output`, an output path relative to the document's output root ([typediagram.output]), and MAY contain `dmx.target`, the name of a generation target, defaulting to `dart`. Any other key under `dmx` is an error rather than a value dmx ignores, so a misspelling is reported instead of silently generating nothing. Metadata that does not begin with `{` belongs to another convention and MUST be left alone: ```typeDiagram type Product { @@ -52,6 +52,9 @@ type Product { ````markdown ## Store models +A template binds to the definition immediately above it, so the two fences are +consecutive: a heading between them would end the group. + ```typeDiagram type Product { id: String @@ -90,6 +93,8 @@ Every declaration exposes `kind`, `name`, `generics`, and mutually exclusive `is The context builder MAY add further derived strings and booleans, but it MUST NOT discard or reorder source model data. Context schema changes require a version bump and golden fixtures. +Every target-language decision MUST be confined to one generation target: the mapping from a resolved reference to that language's type text, the extension its outputs carry, and the validation a finished file passes. Nothing else in the feature — tokenizer, parser, model, binder, context builder, emitter — may name a language. A target a document names but this build does not carry is `DMX8007`. + ### [typediagram.templates] Rendering The built-in `typeDiagram` macro renders each bound Mustache body once against its group's complete context. It follows all determinism, partial-resolution, span-mapping, and no-I/O requirements in [rendering]. All target-language decisions needed by the template MUST be finished in the macro's Rust context builder; the template only selects and places prepared values. @@ -98,18 +103,24 @@ A template failure MUST identify the Markdown file, template fence, template lin ### [typediagram.output] Validation and Emission -`dmx.output` MUST normalize to a path inside the workspace, MUST end in `.dart`, and MUST NOT traverse a symbolic link outside the workspace. Absolute paths and parent traversal are errors. +`dmx.output` MUST resolve against the document's **output root**: the nearest ancestor of the document that carries a project marker any target recognises — `pubspec.yaml` for Dart — bounded by the workspace, and the workspace itself when there is none. `lib/models.dart` therefore means *this package's* `lib`, so a document generates the same bytes in the same place whether dmx was run from the package, from the repository root, or from an editor that opened the whole tree. + +`dmx.output` MUST normalize to a path inside that root, MUST carry the extension its target generates, and MUST NOT traverse a symbolic link outside it. Absolute paths and parent traversal are errors, and an output path equal to the source document is an error. A document is identified, in its ownership markers and its templates' contexts, by its path relative to that same root, so nothing recorded in a generated file depends on where dmx was launched. -Rendered output MUST pass the same whitespace normalization, hygiene, full-file Dart re-parse, and `dart analyze --fatal-infos` corpus gates as other generated Dart. It MUST NOT contain `throw`, casts, null assertions, or other constructs forbidden in generated Dart. The file MUST carry a dmx ownership marker containing the source Markdown path, fence identity, template hash, typeDiagram definition hash, and dmx version. +Rendered output MUST pass the same whitespace normalization, hygiene, full-file Dart re-parse, and `dart analyze --fatal-infos` corpus gates as other generated Dart. It MUST NOT contain `throw`, casts, null assertions, or other constructs forbidden in generated Dart; that is [hygiene], enforced over the tree-sitter CST rather than over the text. The file MUST carry a dmx ownership marker containing the source Markdown path, fence identity, template hash, typeDiagram definition hash, context version, and dmx version. Its first line MUST be the same ownership marker whole-file emission already uses [dartmacros.files], so one predicate decides ownership for every backend that writes a file dmx owns. Whole-file emission follows [dartmacros.files]: never overwrite an unmarked file, write atomically, avoid no-op writes, remove stale owned outputs when their template disappears, and report drift without writing under `--check`. The source Markdown is never rewritten. +An output MUST have one live source. A target already carrying another source's ownership marker MUST be refused while that source still exists, because each pass would otherwise undo the other's. A marker naming a source that is gone identifies an orphan, and taking it over is what renaming a document is supposed to do. + ### [typediagram.execution] Build, Check, Watch, and Explain `build`, `check`, and `watch` MUST treat the Markdown document, definition fence, template fence, and resolved partials as dependencies of every output. A change to prose outside a generation group MUST NOT invalidate its output. A semantic definition or template change MUST invalidate every dependent output. `watch` MUST retain the last valid output after an invalid edit and recover on the next valid save. `dmx explain ` MUST print each group, its source spans, normalized output paths, dependency hashes, and exact context JSON without rendering or writing. +Stale collection is scoped to the roots the pass was asked to manage: an output whose ownership marker names this document, which the document no longer produces, MUST be removed (or, under `--check`, reported) when it is inside those roots. + ### [typediagram.diagnostics] Diagnostics The feature owns the `DMX8xxx` range: @@ -123,5 +134,8 @@ The feature owns the `DMX8xxx` range: | `DMX8005` | Output path is absolute, escapes the workspace, crosses an unsafe symlink, or is not Dart | | `DMX8006` | Output exists without the matching dmx ownership marker | | `DMX8007` | typeDiagram compatibility or context schema version is unsupported | +| `DMX8008` | A bound Mustache template does not compile, or its render is not source the target accepts | Every diagnostic MUST carry the Markdown path and fenced-block span. When applicable it also carries the typeDiagram line/column, template line/column, generated Dart line/column, and output path. + +Rendered source that does not parse, or that breaks [hygiene], is refused by the shared diagnostics those stages already own — `DMX4001` and `DMX4003` — wrapped in a `DMX8008` that names the document, the group, and the template fence. A macro name this registry serves from a Markdown group MUST NOT be reachable as an annotation: `@dmx('typeDiagram')` is `DMX2006`. diff --git a/examples/storefront/README.md b/examples/storefront/README.md index a5c8829..d5c0b02 100644 --- a/examples/storefront/README.md +++ b/examples/storefront/README.md @@ -33,6 +33,21 @@ dart test | [inventory.dart](lib/inventory.dart) | `@dmx('diff')` `@dmx('model')` | What changed, as data, for audit trails and unsaved-changes banners. Collections compare by content, so `diff` agrees with `==`. | | [l10n.dart](lib/l10n.dart) | `@dmx('strings')` | A message is a method signature. `{count}` in the template must correspond to a parameter called `count`, checked at generation time rather than by a customer. | +## One model, defined in Markdown + +[docs/shipping.dmx.md](docs/shipping.dmx.md) has no annotated Dart behind it at +all. The types are declared once in a typeDiagram fence, and the two Mustache +fences under it generate [lib/shipping.dart](lib/shipping.dart) — records, +a sealed union, and a typedef — and +[lib/shipping_wire.dart](lib/shipping_wire.dart), a constant wire-name table. +Both are functions of the same definition, so a field added to the diagram +changes both files together. The definition still renders as a diagram, so that +one page is the model, its documentation, and the build input. + +[test/shipping_test.dart](test/shipping_test.dart) constructs the generated +types, switches over the union without a default arm, and checks that the two +generated files agree. + ## Reading order If you read one file, read [catalog.dart](lib/catalog.dart) — the decoder there diff --git a/examples/storefront/docs/shipping.dmx.md b/examples/storefront/docs/shipping.dmx.md new file mode 100644 index 0000000..85d85f2 --- /dev/null +++ b/examples/storefront/docs/shipping.dmx.md @@ -0,0 +1,132 @@ +# Shipping + +Everything under the diagram is generated from it. There is no Dart source of +truth for these types and no `@dmx` annotation anywhere — the definition *is* +the source, the templates decide the shape, and `dmx build docs lib` writes the +files. The fence renders as a diagram in any typeDiagram viewer, so this page is +documentation and a build input at the same time. + +## What the two templates do + +The first template turns every declaration into immutable Dart: records become +`final class`es with a `const` constructor, the union becomes a sealed class +with one subclass per variant, and the alias becomes a `typedef`. It places +prepared values and computes nothing — `dartType`, `constructorParameters` and +`owner` are all finished before it runs. + +The second reads the same definition and writes something completely different: +the snake-case wire names each declaration uses, as a constant table a +serializer can index. One definition, two outputs, no copying. + +## The definition and its templates + +A template binds to the definition immediately above it, so the fences below are +consecutive: a heading between them would end the group and orphan the template. +That is the whole binding rule — no ordinals, no headings, no document-global +state. + +```typeDiagram +# A parcel on its way to a customer. +type Parcel { + id: Uuid + weightG: Int + insured: Option + labels: List +} + +# Where the parcel has got to. One of these, never two. +union Leg { + Pickup { at: DateTime } + Transit { carrier: String, etaHours: Int } + Delivered { at: DateTime, signedBy: Option } +} + +alias TrackingNumber = String + +type Shipment { + parcel: Parcel + legs: List + tracking: TrackingNumber +} +``` + +```mustache {"dmx":{"output":"lib/shipping.dart"}} +// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. +{{#declarations}} +{{#isAlias}} + +/// `{{name}}` as the diagram declares it. +typedef {{name}}{{genericDeclaration}} = {{{dartType}}}; +{{/isAlias}} +{{#isRecord}} + +/// {{label}}, generated from the shipping diagram. +final class {{name}}{{genericDeclaration}} { + /// Every field of {{label}}, in the order the diagram declares them. + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/isRecord}} +{{#isUnion}} + +/// {{label}} — exactly one of the variants below. +sealed class {{name}}{{genericDeclaration}} { + /// The shared constructor every variant delegates to. + const {{name}}(); +} +{{#variants}} + +/// The `{{name}}` case of {{owner}}. +final class {{name}} extends {{owner}}{{ownerGenericDeclaration}} { + /// Every field of this case, in diagram order. + const {{name}}({{{constructorParameters}}}) : super(); +{{#fields}} + + /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/variants}} +{{/isUnion}} +{{/declarations}} +``` + +```mustache {"dmx":{"output":"lib/shipping_wire.dart"}} +// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. + +/// The wire name of every field, keyed by declaration and then by Dart name. +const shippingWireNames = >{ +{{#declarations}} +{{#isRecord}} + '{{name}}': { +{{#fields}} + '{{name}}': '{{snakeName}}', +{{/fields}} + }, +{{/isRecord}} +{{#isUnion}} +{{#variants}} + '{{owner}}.{{name}}': { +{{#fields}} + '{{name}}': '{{snakeName}}', +{{/fields}} + }, +{{/variants}} +{{/isUnion}} +{{/declarations}} +}; + +/// Every declaration the shipping diagram carries, in source order. +const shippingDeclarations = [ +{{#declarations}} + '{{name}}', +{{/declarations}} +]; +``` + +Delete either fence and its file goes with it. Change a field and both files +move together, because both are functions of the same definition. diff --git a/examples/storefront/lib/shipping.dart b/examples/storefront/lib/shipping.dart new file mode 100644 index 0000000..b485c4d --- /dev/null +++ b/examples/storefront/lib/shipping.dart @@ -0,0 +1,79 @@ +// dmx: generated from docs/shipping.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition bd16c86d530f3daa, template 861e8207f9496f03, context v1, dmx 0.0.0. + +// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. + +/// Parcel, generated from the shipping diagram. +final class Parcel { + /// Every field of Parcel, in the order the diagram declares them. + const Parcel({required this.id, required this.weightG, this.insured, required this.labels}); + + /// The `id` field, declared as `Uuid`. + final String id; + + /// The `weightG` field, declared as `Int`. + final int weightG; + + /// The `insured` field, declared as `Option`. + final String? insured; + + /// The `labels` field, declared as `List`. + final List labels; +} + +/// Leg — exactly one of the variants below. +sealed class Leg { + /// The shared constructor every variant delegates to. + const Leg(); +} + +/// The `Pickup` case of Leg. +final class Pickup extends Leg { + /// Every field of this case, in diagram order. + const Pickup({required this.at}) : super(); + + /// The `at` field, declared as `DateTime`. + final DateTime at; +} + +/// The `Transit` case of Leg. +final class Transit extends Leg { + /// Every field of this case, in diagram order. + const Transit({required this.carrier, required this.etaHours}) : super(); + + /// The `carrier` field, declared as `String`. + final String carrier; + + /// The `etaHours` field, declared as `Int`. + final int etaHours; +} + +/// The `Delivered` case of Leg. +final class Delivered extends Leg { + /// Every field of this case, in diagram order. + const Delivered({required this.at, this.signedBy}) : super(); + + /// The `at` field, declared as `DateTime`. + final DateTime at; + + /// The `signedBy` field, declared as `Option`. + final String? signedBy; +} + +/// `TrackingNumber` as the diagram declares it. +typedef TrackingNumber = String; + +/// Shipment, generated from the shipping diagram. +final class Shipment { + /// Every field of Shipment, in the order the diagram declares them. + const Shipment({required this.parcel, required this.legs, required this.tracking}); + + /// The `parcel` field, declared as `Parcel`. + final Parcel parcel; + + /// The `legs` field, declared as `List`. + final List legs; + + /// The `tracking` field, declared as `TrackingNumber`. + final TrackingNumber tracking; +} diff --git a/examples/storefront/lib/shipping_wire.dart b/examples/storefront/lib/shipping_wire.dart new file mode 100644 index 0000000..0063ad6 --- /dev/null +++ b/examples/storefront/lib/shipping_wire.dart @@ -0,0 +1,38 @@ +// dmx: generated from docs/shipping.dmx.md — do not edit. +// dmx: group 1, fences 1/3, definition bd16c86d530f3daa, template 6737510090829881, context v1, dmx 0.0.0. + +// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. + +/// The wire name of every field, keyed by declaration and then by Dart name. +const shippingWireNames = >{ + 'Parcel': { + 'id': 'id', + 'weightG': 'weight_g', + 'insured': 'insured', + 'labels': 'labels', + }, + 'Leg.Pickup': { + 'at': 'at', + }, + 'Leg.Transit': { + 'carrier': 'carrier', + 'etaHours': 'eta_hours', + }, + 'Leg.Delivered': { + 'at': 'at', + 'signedBy': 'signed_by', + }, + 'Shipment': { + 'parcel': 'parcel', + 'legs': 'legs', + 'tracking': 'tracking', + }, +}; + +/// Every declaration the shipping diagram carries, in source order. +const shippingDeclarations = [ + 'Parcel', + 'Leg', + 'TrackingNumber', + 'Shipment', +]; diff --git a/examples/storefront/test/shipping_test.dart b/examples/storefront/test/shipping_test.dart new file mode 100644 index 0000000..261dde1 --- /dev/null +++ b/examples/storefront/test/shipping_test.dart @@ -0,0 +1,136 @@ +// Proves the two files generated from docs/shipping.dmx.md [typediagram]. +// +// Nothing here is generated. The point of the suite is that a definition +// written once in Markdown, with no Dart source of truth and no `@dmx` +// annotation anywhere, produces Dart you can actually construct, match on, and +// index — and that both outputs agree, because both are functions of the same +// definition. + +import 'package:dmx_storefront_example/shipping.dart'; +import 'package:dmx_storefront_example/shipping_wire.dart'; +import 'package:test/test.dart'; + +/// A leg description that proves the switch is exhaustive: no default arm, no +/// cast, no null assertion — the sealed class is what makes that possible. +String describe(Leg leg) => switch (leg) { + Pickup(at: final at) => 'picked up at ${at.toIso8601String()}', + Transit(carrier: final carrier, etaHours: final hours) => + '$carrier, $hours hours out', + Delivered(signedBy: final signedBy) when signedBy == null => + 'delivered, unsigned', + Delivered(signedBy: final signedBy) => 'delivered, signed by $signedBy', + }; + +void main() { + group('records', () { + test('every field arrives with the Dart type the diagram implies', () { + const parcel = Parcel( + id: 'b0a1', + weightG: 1200, + labels: ['fragile', 'this way up'], + ); + + expect(parcel.id, 'b0a1'); + expect(parcel.weightG, 1200); + expect(parcel.labels, ['fragile', 'this way up']); + expect(parcel.insured, isNull, + reason: 'Option is a nullable Dart field, so it defaults'); + }); + + test('an optional field is optional and a required one is required', () { + const insured = Parcel( + id: 'b0a2', + weightG: 40, + insured: '19.99', + labels: [], + ); + expect(insured.insured, '19.99'); + expect(insured.labels, isEmpty); + }); + + test('a record composes with the other declarations', () { + final shipment = Shipment( + parcel: const Parcel(id: 'c3', weightG: 10, labels: []), + legs: [ + Pickup(at: DateTime.utc(2026, 8, 19, 9)), + const Transit(carrier: 'Nimble Freight', etaHours: 30), + ], + tracking: 'NF-0001', + ); + + expect(shipment.parcel.id, 'c3'); + expect(shipment.legs, hasLength(2)); + expect(shipment.tracking, 'NF-0001'); + expect(shipment.tracking, isA(), + reason: 'the alias is a typedef, so it is the same type'); + }); + }); + + group('the union', () { + test('every variant is a subtype of the sealed base', () { + final legs = [ + Pickup(at: DateTime.utc(2026, 8, 19, 9)), + const Transit(carrier: 'Nimble Freight', etaHours: 30), + Delivered(at: DateTime.utc(2026, 8, 21, 14), signedBy: 'R. Patel'), + ]; + expect(legs.whereType(), hasLength(1)); + expect(legs.whereType(), hasLength(1)); + expect(legs.whereType(), hasLength(1)); + }); + + test('a switch over it is exhaustive without a default arm', () { + expect(describe(Pickup(at: DateTime.utc(2026, 8, 19, 9))), + 'picked up at 2026-08-19T09:00:00.000Z'); + expect(describe(const Transit(carrier: 'Nimble Freight', etaHours: 30)), + 'Nimble Freight, 30 hours out'); + expect(describe(Delivered(at: DateTime.utc(2026, 8, 21), signedBy: null)), + 'delivered, unsigned'); + expect( + describe( + Delivered(at: DateTime.utc(2026, 8, 21), signedBy: 'R. Patel')), + 'delivered, signed by R. Patel'); + }); + }); + + group('the wire-name table', () { + test('it carries every record and every variant', () { + expect( + shippingWireNames.keys, + containsAll([ + 'Parcel', + 'Leg.Pickup', + 'Leg.Transit', + 'Leg.Delivered', + 'Shipment', + ]), + ); + }); + + test('camel case becomes snake case, and single words do not change', () { + expect(shippingWireNames['Parcel']!['weightG'], 'weight_g'); + expect(shippingWireNames['Parcel']!['id'], 'id'); + expect(shippingWireNames['Leg.Transit']!['etaHours'], 'eta_hours'); + expect(shippingWireNames['Leg.Delivered']!['signedBy'], 'signed_by'); + }); + + test('the declaration list is the diagram order, aliases included', () { + expect(shippingDeclarations, + ['Parcel', 'Leg', 'TrackingNumber', 'Shipment']); + }); + + test('both generated files describe the same declarations', () { + final fromTable = shippingWireNames.keys + .map((key) => key.split('.').first) + .toSet(); + expect( + fromTable, + {'Parcel', 'Leg', 'Shipment'}, + reason: 'one definition, two outputs, and no way for them to disagree', + ); + expect( + shippingDeclarations.toSet().containsAll(fromTable), + isTrue, + ); + }); + }); +} diff --git a/scripts/typediagram-oracle.mjs b/scripts/typediagram-oracle.mjs new file mode 100644 index 0000000..373c7d7 --- /dev/null +++ b/scripts/typediagram-oracle.mjs @@ -0,0 +1,83 @@ +// Regenerates the typeDiagram compatibility corpus from the upstream package +// [typediagram.delivery.baseline]. +// +// This is a DEVELOPMENT tool. The dmx binary never runs it, never depends on +// Node, and never calls typeDiagram: production parsing and model construction +// are the Rust front end's, and Mustache is the only authority on code shape. +// What this script produces is the *oracle* — the model JSON upstream's own +// parser and model builder emit for each fixture — which the Rust differential +// test compares against so that upstream language drift is visible. +// +// Usage: +// +// node scripts/typediagram-oracle.mjs --typediagram +// +// The checkout must have been built (`npm run build` in the typeDiagram repo), +// because the script imports its compiled `dist`. Each `*.td` fixture in +// src/dmx/tests/typediagram/corpus is written back as `.model.json`, and +// the exit status is non-zero if any fixture fails to parse or resolve. + +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CORPUS = resolve(HERE, "..", "src", "dmx", "tests", "typediagram", "corpus"); + +function upstreamDirectory() { + const flag = process.argv.indexOf("--typediagram"); + const named = flag === -1 ? process.env.TYPEDIAGRAM_DIR : process.argv[flag + 1]; + if (named === undefined || named === "") { + console.error("usage: node scripts/typediagram-oracle.mjs --typediagram "); + console.error(" or: TYPEDIAGRAM_DIR= node scripts/typediagram-oracle.mjs"); + process.exit(2); + } + return resolve(named); +} + +async function load(upstream) { + const dist = join(upstream, "packages", "typediagram", "dist"); + const importFrom = (relative) => import(pathToFileURL(join(dist, relative)).href); + const [parser, model, json] = await Promise.all([ + importFrom("parser/index.js"), + importFrom("model/index.js"), + importFrom("model/json.js"), + ]); + return { parse: parser.parse, buildModel: model.buildModel, toJSON: json.toJSON }; +} + +function describe(diagnostics) { + return diagnostics + .map((d) => `${String(d.line)}:${String(d.col)} ${d.severity} ${d.message}`) + .join("\n"); +} + +const upstream = upstreamDirectory(); +const { parse, buildModel, toJSON } = await load(upstream); + +const fixtures = (await readdir(CORPUS)).filter((name) => name.endsWith(".td")).sort(); +let failed = 0; + +for (const fixture of fixtures) { + const source = await readFile(join(CORPUS, fixture), "utf8"); + const ast = parse(source); + if (!ast.ok) { + console.error(`${fixture}: parse failed\n${describe(ast.error)}`); + failed += 1; + continue; + } + const built = buildModel(ast.value); + if (!built.ok) { + console.error(`${fixture}: model failed\n${describe(built.error)}`); + failed += 1; + continue; + } + const target = join(CORPUS, `${fixture.slice(0, -3)}.model.json`); + await writeFile(target, `${JSON.stringify(toJSON(built.value), null, 2)}\n`, "utf8"); + console.log(`wrote ${target}`); +} + +if (failed > 0) { + console.error(`${String(failed)} fixture(s) failed`); + process.exit(1); +} diff --git a/src/dmx/Cargo.lock b/src/dmx/Cargo.lock index dc4a712..353bc74 100644 --- a/src/dmx/Cargo.lock +++ b/src/dmx/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "json_comments", "lspkit", "notify", + "pulldown-cmark 0.13.4", "ramhorns", "serde_json", "tokio", @@ -434,6 +435,17 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pulldown-cmark-escape" version = "0.11.0" @@ -459,7 +471,7 @@ dependencies = [ "beef", "fnv", "logos", - "pulldown-cmark", + "pulldown-cmark 0.12.2", "ramhorns-derive", ] diff --git a/src/dmx/Cargo.toml b/src/dmx/Cargo.toml index b0af846..430e9ce 100644 --- a/src/dmx/Cargo.toml +++ b/src/dmx/Cargo.toml @@ -20,6 +20,10 @@ crate-type = ["cdylib", "rlib"] anyhow = "1.0.104" blake3 = "1.8.6" ramhorns = "1.0.1" +# CommonMark, for the typeDiagram Markdown front end [typediagram.documents]: a +# fence is an AST node, never a pattern match over the document text. Default +# features off — dmx reads the event stream and never renders HTML. +pulldown-cmark = { version = "0.13.4", default-features = false } # Common, NOT wasm-excluded: `render` is the pipeline's render stage and # `jsoncontent` is the model it renders, and the playground compiles both — # [playground.wasm] requires the WASM exports to call the same pipeline as diff --git a/src/dmx/src/dartmacros.rs b/src/dmx/src/dartmacros.rs index d1a828a..b4a0bc0 100644 --- a/src/dmx/src/dartmacros.rs +++ b/src/dmx/src/dartmacros.rs @@ -285,9 +285,13 @@ fn render_reply(request: &Value) -> Value { "error": format!("DMX7009: the `render` request for `{name}` carries no string `template`"), }); }; - match render::render_json(name, template, request.get("context").unwrap_or(&NOTHING)) { + match render::render_json(template, request.get("context").unwrap_or(&NOTHING)) { Ok(text) => json!({"v": 1, "id": id, "text": text}), - Err(error) => json!({"v": 1, "id": id, "error": format!("{error:#}")}), + Err(error) => json!({ + "v": 1, + "id": id, + "error": format!("DMX7009: macro template `{name}` does not compile: {error:#}"), + }), } } diff --git a/src/dmx/src/emit.rs b/src/dmx/src/emit.rs index 8acea66..9527e22 100644 --- a/src/dmx/src/emit.rs +++ b/src/dmx/src/emit.rs @@ -20,13 +20,15 @@ use std::path::{Path, PathBuf}; use crate::frontend::{REGION_END, REGION_START, RawDecl, is_region_end, region_opener}; -/// One whole sibling Dart file a macro authored and named -/// [dartmacros.files]. +/// One whole file a macro authored and named [dartmacros.files]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratedFile { - /// A bare `*.dart` file name, validated on receipt from the worker. + /// Where it goes: a bare sibling file name for a macro authored in Dart, + /// validated on receipt from the worker [dartmacros.files]; a + /// workspace-relative path for a Markdown generation group, validated by + /// its emitter [typediagram.output]. pub name: String, - /// The file's complete Dart source, normalized like any fragment. + /// The file's complete source, normalized like any fragment. pub text: String, } @@ -170,22 +172,27 @@ pub fn strip_region_bodies(src: &str) -> String { out } -/// The prefix every macro-authored file's first line carries — the whole +/// The prefix every machine-authored file's first line carries — the whole /// ownership protocol [dartmacros.files]: a file that starts with it is /// machine-owned outright, and one that does not is somebody's hand-written /// Dart that dmx must never touch. -#[cfg(not(target_arch = "wasm32"))] -const FILE_MARKER_PREFIX: &str = "// dmx: generated from "; +/// +/// Shared with whole-file generation from a Markdown document +/// [typediagram.output], so one predicate decides ownership for every backend +/// that writes a file dmx owns. The marker is a string, not a write, so it is +/// the same on every target this crate compiles for. +pub const FILE_MARKER_PREFIX: &str = "// dmx: generated from "; /// What that first line ends with, so the seed's name can be read back out of /// it [dartmacros.files]. -#[cfg(not(target_arch = "wasm32"))] -const FILE_MARKER_SUFFIX: &str = " — do not edit."; +pub const FILE_MARKER_SUFFIX: &str = " — do not edit."; -/// The exact marker line for files generated from `seed_file_name`. -#[cfg(not(target_arch = "wasm32"))] -fn file_marker(seed_file_name: &str) -> String { - format!("{FILE_MARKER_PREFIX}{seed_file_name}{FILE_MARKER_SUFFIX}") +/// The exact marker line for files generated from `seed`, which is a sibling +/// file name for a Dart macro and a workspace-relative document path for a +/// Markdown generation group [typediagram.output]. +#[must_use] +pub fn file_marker(seed: &str) -> String { + format!("{FILE_MARKER_PREFIX}{seed}{FILE_MARKER_SUFFIX}") } /// The seed a macro-authored file names on its first line, when that seed is @@ -207,8 +214,14 @@ pub fn seed_of(path: &Path) -> Option { .trim_end_matches('\n') .strip_prefix(FILE_MARKER_PREFIX)? .strip_suffix(FILE_MARKER_SUFFIX)?; - let seed = path.parent().unwrap_or_else(|| Path::new(".")).join(name); - seed.is_file().then_some(seed) + // Beside the generated file for a Dart macro's sibling [dartmacros.files]; + // against the working directory for a Markdown document, whose marker + // names a workspace-relative path [typediagram.output]. + let beside = path.parent().unwrap_or_else(|| Path::new(".")).join(name); + let from_workspace = PathBuf::from(name); + [beside, from_workspace] + .into_iter() + .find(|candidate| candidate.is_file()) } /// Emits every macro-authored file beside `seed`, and collects the ones a @@ -242,62 +255,127 @@ pub fn emit_macro_files(seed: &Path, files: &[GeneratedFile], opts: &Options) -> } let target = dir.join(&file.name); let content = format!("{marker}\n\n{}\n", file.text); - match fs::read_to_string(&target) { - Ok(existing) if existing == content => continue, - Ok(existing) if !existing.starts_with(FILE_MARKER_PREFIX) => bail!( - "DMX7008: `{}` already exists and carries no dmx marker — a hand-written \ - file is never overwritten [dartmacros.files]", - target.display() - ), - Ok(_) => {} - Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("DMX1002: cannot read {}", target.display())); + changed |= write_owned( + &target, + &content, + opts.check, + "DMX7008", + "[dartmacros.files]", + )?; + } + let kept: Vec = files.iter().map(|file| dir.join(&file.name)).collect(); + Ok(collect_stale(&dart_files_in(dir)?, &marker, &kept, opts.check)? || changed) +} + +/// Writes one file dmx owns, and says whether that changed anything. +/// +/// The ownership rule is the whole of the protocol: a target that exists +/// without the marker on its first line is somebody's hand-written source, and +/// dmx refuses it rather than replacing it. An identical target is a no-op +/// [emission.inline-backend.no-op-writes], and under `check` nothing is ever +/// written [execution]. +/// +/// # Errors +/// +/// Fails when the target exists without a marker, or on I/O. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_owned( + target: &Path, + content: &str, + check: bool, + code: &str, + spec: &str, +) -> Result { + match fs::read_to_string(target) { + Ok(existing) if existing == content => return Ok(false), + Ok(existing) if !existing.starts_with(FILE_MARKER_PREFIX) => bail!( + "{code}: `{}` already exists and carries no dmx marker — a hand-written \ + file is never overwritten {spec}", + target.display() + ), + // Marked, but by something else that is still there: two live sources + // claim one file, and each pass would undo the other's. A marker naming + // a source that is GONE is an orphan, and taking it over is exactly + // what a renamed source should do. + Ok(existing) if existing.lines().next() != content.lines().next() => { + if let Some(other) = seed_of(target) { + bail!( + "{code}: `{}` is already generated from {} {spec}", + target.display(), + other.display() + ); } } - changed = true; - if !opts.check { - write_atomic(&target, &content) - .with_context(|| format!("DMX1003: cannot write {}", target.display()))?; + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("DMX1002: cannot read {}", target.display())); + } + } + if !check { + if let Some(parent) = target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("DMX1003: cannot create {}", parent.display()))?; + } + write_atomic(target, content) + .with_context(|| format!("DMX1003: cannot write {}", target.display()))?; + } + Ok(true) +} + +/// Every `.dart` file directly inside `dir`. +#[cfg(not(target_arch = "wasm32"))] +fn dart_files_in(dir: &Path) -> Result> { + let mut found = Vec::new(); + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dart")) + { + found.push(path); } } - Ok(collect_stale_files(dir, files, &marker, opts.check)? || changed) + Ok(found) } -/// Deletes (or, under `check`, reports) every `.dart` file in `dir` whose -/// marker names this seed and which this pass did not produce — a dropped -/// table means a dropped file [dartmacros.files]. +/// Deletes (or, under `check`, reports) every candidate whose marker names +/// this seed and which this pass did not produce — a dropped table means a +/// dropped file [dartmacros.files], and a dropped template means a dropped +/// output [typediagram.output]. +/// +/// # Errors +/// +/// Fails when a stale file cannot be removed. #[cfg(not(target_arch = "wasm32"))] -fn collect_stale_files( - dir: &Path, - files: &[GeneratedFile], +pub fn collect_stale( + candidates: &[PathBuf], marker: &str, + kept: &[PathBuf], check: bool, ) -> Result { + // Resolved, not compared as written: the same file reaches this function + // as `lib/a.dart` from a directory sweep and as an absolute path from the + // pass that just wrote it, and reading those as two files would delete the + // output every second build [typediagram.output]. + let kept: Vec = kept.iter().map(|path| resolved(path)).collect(); let mut changed = false; - for entry in fs::read_dir(dir)? { - let path = entry?.path(); - let Some(name) = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - else { - continue; - }; - let is_dart = path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("dart")); - if !is_dart || files.iter().any(|file| file.name == name) { + for path in candidates { + if kept.contains(&resolved(path)) { continue; } // A file that is not UTF-8 cannot carry the ASCII marker; skip it. - let Ok(existing) = fs::read_to_string(&path) else { + let Ok(existing) = fs::read_to_string(path) else { continue; }; if existing.lines().next() == Some(marker) { changed = true; if !check { - fs::remove_file(&path) + fs::remove_file(path) .with_context(|| format!("DMX1003: cannot remove {}", path.display()))?; } } @@ -305,6 +383,15 @@ fn collect_stale_files( Ok(changed) } +/// A path in the one form two spellings of it agree on. +/// +/// A path that is not there yet cannot be resolved, and is its own answer: +/// nothing this function is asked about can be two files at once. +#[cfg(not(target_arch = "wasm32"))] +fn resolved(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_owned()) +} + /// Atomic write: temp file in the same directory, then rename [validation]. /// /// # Errors diff --git a/src/dmx/src/engine.rs b/src/dmx/src/engine.rs index 36997b7..3c6f8c8 100644 --- a/src/dmx/src/engine.rs +++ b/src/dmx/src/engine.rs @@ -19,8 +19,8 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt as _}; use tokio_util::sync::CancellationToken; -use crate::watch::collect_dart_files; -use crate::{Options, Outcome, process_file}; +use crate::watch::collect_sources; +use crate::{Options, Outcome, process_path}; /// Generation events a slow subscriber may fall behind by before it starts /// missing them. A missed event costs a subscriber one redundant re-query, not @@ -126,7 +126,7 @@ impl Engine { .unwrap_or_else(std::sync::PoisonError::into_inner) } - /// The Dart sources a scope resolves to, with the zero-config exclusions + /// The sources a scope resolves to, with the zero-config exclusions /// applied [surface.zero-config]. fn targets(&self, scope: &RescanScope) -> Result, EngineError> { let paths = match scope { @@ -136,7 +136,7 @@ impl Engine { // slower than the narrower one it stood in for. _ => self.roots.as_slice(), }; - collect_dart_files(paths).map_err(|error| EngineError::Scan(format!("{error:#}"))) + collect_sources(paths).map_err(|error| EngineError::Scan(format!("{error:#}"))) } /// Records `pass` as the current state and publishes the new generation. @@ -157,8 +157,8 @@ impl Engine { /// One file through the whole pipeline, with failure recorded rather than /// propagated: a source that does not parse must not stop its neighbours. -fn run_one(path: &Path, opts: Options) -> FileOutcome { - match process_file(path, &opts) { +fn run_one(path: &Path, roots: &[PathBuf], opts: Options) -> FileOutcome { + match process_path(path, roots, &opts) { Ok(Outcome::Updated) => FileOutcome::Written, Ok(Outcome::Unchanged) => FileOutcome::Unchanged, Err(error) => FileOutcome::Refused(format!("{error:#}")), @@ -208,7 +208,7 @@ impl EngineApi for Engine { let files = targets .into_iter() .map(|path| { - let outcome = run_one(&path, self.opts); + let outcome = run_one(&path, &self.roots, self.opts); (path, outcome) }) .collect(); diff --git a/src/dmx/src/hygiene.rs b/src/dmx/src/hygiene.rs new file mode 100644 index 0000000..7d79a2a --- /dev/null +++ b/src/dmx/src/hygiene.rs @@ -0,0 +1,190 @@ +//! Stage 6: hygiene [hygiene]. +//! +//! Generated Dart obeys the same rules the hand-written Dart in this +//! repository does: it never throws, never casts with `as`, and never asserts +//! away a null with `!`. A built-in macro keeps that promise through its +//! template, which is reviewed. A user-authored template is not reviewed by +//! anyone, so the promise has to be *checked* — and checked on the tree-sitter +//! CST, because `throw` inside a string literal is a string and `x!` is a +//! different thing from `!x`. + +use anyhow::{Result, bail}; +use tree_sitter::Node; + +use crate::frontend::Frontend; + +/// A construct generated Dart may not contain, and how to say so. +struct Forbidden { + /// The CST node kind that identifies it. + kind: &'static str, + /// What the author has to do instead. + advice: &'static str, +} + +/// What generated code never throws with. +const NO_THROW: &str = "generated code never throws — return a `Result` instead"; + +/// What generated code never casts with. +const NO_CAST: &str = "generated code never casts — test with `is` and use the smart cast"; + +/// What generated code never asserts a null away with. +const NO_ASSERT: &str = "generated code never asserts non-null — handle the null case"; + +/// Everything [hygiene] forbids in generated Dart. +/// +/// Every entry is a node kind the Dart grammar produces only for the construct +/// it names, which is why this is a table and not a scan for characters: +/// `postfix_expression` would also match `i++`, and matching text would also +/// match a comment. +const FORBIDDEN: &[Forbidden] = &[ + Forbidden { + kind: "throw_expression", + advice: NO_THROW, + }, + Forbidden { + kind: "rethrow_statement", + advice: NO_THROW, + }, + Forbidden { + kind: "type_cast_expression", + advice: NO_CAST, + }, + Forbidden { + kind: "cast_pattern", + advice: NO_CAST, + }, + Forbidden { + kind: "null_assertion_expression", + advice: NO_ASSERT, + }, + Forbidden { + kind: "cascade_null_assertion_expression", + advice: NO_ASSERT, + }, + Forbidden { + kind: "null_assert_pattern", + advice: NO_ASSERT, + }, + Forbidden { + kind: "null_check_pattern", + advice: NO_ASSERT, + }, +]; + +/// Refuses generated Dart that contains a construct [hygiene] forbids. +/// +/// # Errors +/// +/// Fails naming the construct, its line and column, and what to write instead. +/// Also fails when `source` cannot be parsed at all, which the caller has +/// normally already ruled out. +pub fn check(source: &str, origin: &str) -> Result<()> { + let tree = Frontend::new()?.parse(source)?; + let mut cursor = tree.walk(); + loop { + if let Some(found) = offence(cursor.node()) { + let position = cursor.node().start_position(); + bail!( + "DMX4003 [hygiene]: {origin} is not valid generated Dart at line {}, column {}: {}", + position.row.saturating_add(1), + position.column.saturating_add(1), + found + ); + } + if cursor.goto_first_child() { + continue; + } + while !cursor.goto_next_sibling() { + if !cursor.goto_parent() { + return Ok(()); + } + } + } +} + +/// What is wrong with this node, if anything. +fn offence(node: Node<'_>) -> Option<&'static str> { + FORBIDDEN + .iter() + .find(|forbidden| forbidden.kind == node.kind()) + .map(|forbidden| forbidden.advice) +} + +#[cfg(test)] +mod tests { + use super::check; + + /// A whole Dart file around one statement, so the parse is a real file. + fn file(body: &str) -> String { + format!("Object? probe(Object? value) {{\n {body}\n}}\n") + } + + /// [hygiene]: the four hazards are refused, each naming its position. + #[test] + fn generated_dart_never_throws_casts_or_asserts() { + for (body, expected) in [ + ("throw StateError('no');", "never throws"), + ("return value as String;", "never casts"), + ("return value!;", "never asserts non-null"), + ( + "value!..toString();\n return value;", + "never asserts non-null", + ), + ( + "return switch (value) { String() && final s? => s, _ => null };", + "never asserts non-null", + ), + ( + "switch (value) { case var s as String: return s; default: return null; }", + "never casts", + ), + ] { + let error = format!("{:#}", check(&file(body), "test").expect_err(body)); + assert!(error.contains("DMX4003"), "{body}: {error}"); + assert!(error.contains(expected), "{body}: {error}"); + assert!(error.contains("line 2"), "{body}: {error}"); + } + } + + /// [hygiene]: a `rethrow` inside a `catch` is still a throw. + #[test] + fn rethrow_is_a_throw() { + let source = file("try { } catch (e) { rethrow; }"); + assert!( + format!("{:#}", check(&source, "test").expect_err("rethrow")).contains("never throws") + ); + } + + /// [hygiene]: the words appearing inside a string or a comment are not + /// constructs, and a prefix `!` is not a null assertion. + #[test] + fn only_real_constructs_are_refused() { + for body in [ + "return 'throw x as y!';", + "// throw, as, and ! in a comment\n return value;", + "return value == null ? null : !identical(value, 1);", + "var i = 0; i++; return i;", + "return value is String ? value : null;", + "return switch (value) { final String s => s, _ => null };", + ] { + check(&file(body), "test").unwrap_or_else(|e| panic!("{body}: {e:#}")); + } + } + + /// [hygiene]: what the catalogue's own templates emit passes, so the gate + /// can be applied to every backend without a rewrite. + #[test] + fn the_built_in_catalogue_output_is_hygienic() { + let source = crate::process_source( + include_str!("../tests/golden/plain.dart"), + &crate::Options { + insert_regions: true, + check: false, + }, + ) + .expect("pipeline") + .output + .expect("output"); + check(&source, "golden/plain.dart").expect("built-in output is hygienic"); + } +} diff --git a/src/dmx/src/jsoncontent.rs b/src/dmx/src/jsoncontent.rs index 50b9c9f..db035e7 100644 --- a/src/dmx/src/jsoncontent.rs +++ b/src/dmx/src/jsoncontent.rs @@ -132,14 +132,20 @@ impl<'a> Json<'a> { /// One member of a JSON object, or nothing when this value is not an /// object or does not carry that name. /// + /// A dotted name walks into nested objects, so `{{source.path}}` reads what + /// its spelling says it reads. Mustache calls this a dotted name and the + /// template engine does not resolve it — the tag arrives here whole — so + /// this is where it has to be understood [dartmacros.render]. + /// /// Returning `None` rather than a null is what lets ramhorns walk out to /// the enclosing context, so a nested section still reads a name declared /// at the root of the model. fn field(self, name: &str) -> Option<&'a Value> { - match self.0 { - Value::Object(fields) => fields.get(name), - _ => None, - } + name.split('.') + .try_fold(self.0, |value, segment| match value { + Value::Object(fields) => fields.get(segment), + _ => None, + }) } /// Renders the member called `name` with `render`, reporting whether this @@ -168,7 +174,7 @@ mod tests { /// Renders `template` against `model` the way the driver does. fn render(template: &str, model: &serde_json::Value) -> String { - crate::render::render_json("test", template, model).expect("render") + crate::render::render_json(template, model).expect("render") } /// [dartmacros.render]: scalars, lists, and nesting read as Mustache says. @@ -201,6 +207,20 @@ mod tests { assert_eq!(out, "none"); } + /// [dartmacros.render]: a dotted name walks into a nested object, and one + /// that names nothing renders nothing. + #[test] + fn a_dotted_name_reads_a_nested_member() { + let model = json!({"source": {"path": "docs/a.dmx.md", "fence": {"line": 7}}}); + assert_eq!( + render( + "{{source.path}}:{{source.fence.line}}|{{source.missing}}|{{a.b}}", + &model + ), + "docs/a.dmx.md:7||" + ); + } + /// [dartmacros.render]: a section reads names from the enclosing model. #[test] fn a_section_still_sees_the_root_model() { diff --git a/src/dmx/src/lib.rs b/src/dmx/src/lib.rs index 2dc72bf..4bf724e 100644 --- a/src/dmx/src/lib.rs +++ b/src/dmx/src/lib.rs @@ -24,9 +24,11 @@ pub mod emit; #[cfg(not(target_arch = "wasm32"))] pub mod engine; pub mod frontend; +pub mod hygiene; pub mod jsoncontent; pub mod macros; pub mod render; +pub mod typediagram; pub mod types; #[cfg(not(target_arch = "wasm32"))] pub mod watch; @@ -39,6 +41,23 @@ use std::path::Path; pub use emit::{GeneratedFile, Options}; +/// The version this build reports [release.version]. +/// +/// The tag is the version. `Cargo.toml` carries the placeholder `0.0.0` and is +/// never rewritten — cargo owns that file, and nothing in this repository may +/// edit a structured file by pattern. The release passes the version the tag +/// names in `DMX_VERSION` instead, so the number is a property of the build +/// rather than of a commit somebody had to remember to bump. +/// +/// A build with nothing to inject reports the placeholder, which is the honest +/// answer: a local build is not a release. It lives in the library rather than +/// the binary because generated files record which build wrote them +/// [typediagram.output]. +pub const VERSION: &str = match option_env!("DMX_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + /// What one file's pass through the pipeline came to. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum Outcome { @@ -222,8 +241,32 @@ fn process_source_inner( }) } +/// Runs the pipeline over one source, whatever kind it is +/// [typediagram.execution]. +/// +/// A Dart file is generated into; a Markdown document generates whole files +/// from its typeDiagram groups. `roots` is the scope this pass was asked to +/// manage, which is where a document's stale outputs are collected from. +/// +/// # Errors +/// +/// Fails for the same reasons the kind-specific entry point does. +#[cfg(not(target_arch = "wasm32"))] +pub fn process_path(path: &Path, roots: &[std::path::PathBuf], opts: &Options) -> Result { + // A pass enumerates its sources up front. One of them can be a generated + // file that a Markdown document in the same pass has since collected + // [typediagram.output] — a file that is gone is not a file that failed. + if !path.exists() { + return Ok(Outcome::Unchanged); + } + if typediagram::is_markdown(path) { + return typediagram::document::process(path, roots, opts); + } + process_file(path, opts) +} + #[cfg(not(target_arch = "wasm32"))] -/// Runs the pipeline over one file, writing it — and every file its macros +/// Runs the pipeline over one Dart file, writing it — and every file its macros /// authored [dartmacros.files] — only when something changed. /// /// # Errors diff --git a/src/dmx/src/macros/mod.rs b/src/dmx/src/macros/mod.rs index 65843c8..830b307 100644 --- a/src/dmx/src/macros/mod.rs +++ b/src/dmx/src/macros/mod.rs @@ -12,6 +12,13 @@ //! Several macros may sit on one declaration. Each contributes a fragment, and //! the fragments emit in the order the author wrote the annotations //! [rendering], into the single region that declaration owns. +//! +//! Not every macro is triggered by an annotation. `typeDiagram` is triggered by +//! a generation group in a Markdown document and contributes whole files rather +//! than a region fragment [typediagram.macro] — the same registry, resolved the +//! same way, so a Dart-authored macro can no more shadow it than it can shadow +//! `model`. That is the whole of the generalization: a macro is a name, an +//! input, and a way to render. mod cli; mod diff; @@ -24,20 +31,33 @@ mod route; mod table; #[cfg(test)] mod testing; +mod typediagram; mod union; mod validate; use anyhow::{Context as _, Result, bail}; +use crate::emit::GeneratedFile; use crate::frontend::{Annotated, DeclKind, RawDecl, RawField}; use crate::types::DartType; -/// What every macro is: a trigger and a way to render. +/// What triggers a macro, and what it produces. +enum Trigger { + /// `@dmx('name')` on a Dart declaration; produces one region fragment + /// [catalogue]. + Declaration(fn(&RawDecl, &[RawDecl]) -> Result), + /// A generation group in a Markdown document; produces whole files + /// [typediagram.macro]. + Group(fn(&crate::typediagram::Invocation<'_>) -> Result>), +} + +/// What every macro is: a name and a trigger. struct MacroDef { - /// The annotation that triggers it, without the `@`. + /// The name that triggers it — an annotation without the `@`, or the + /// built-in name a synthesized invocation resolves. annotation: &'static str, - /// Builds its context and renders its template. - expand: fn(&RawDecl, &[RawDecl]) -> Result, + /// What triggers it, and how it renders. + trigger: Trigger, } /// Order here is documentation only — a declaration's fragments follow the @@ -45,47 +65,54 @@ struct MacroDef { const REGISTRY: &[MacroDef] = &[ MacroDef { annotation: "model", - expand: model::expand, + trigger: Trigger::Declaration(model::expand), }, MacroDef { annotation: "union", - expand: union::expand, + trigger: Trigger::Declaration(union::expand), }, MacroDef { annotation: "enum", - expand: enums::expand, + trigger: Trigger::Declaration(enums::expand), }, MacroDef { annotation: "diff", - expand: diff::expand, + trigger: Trigger::Declaration(diff::expand), }, MacroDef { annotation: "lerp", - expand: lerp::expand, + trigger: Trigger::Declaration(lerp::expand), }, MacroDef { annotation: "validate", - expand: validate::expand, + trigger: Trigger::Declaration(validate::expand), }, MacroDef { annotation: "table", - expand: table::expand, + trigger: Trigger::Declaration(table::expand), }, MacroDef { annotation: "route", - expand: route::expand, + trigger: Trigger::Declaration(route::expand), }, MacroDef { annotation: "cli", - expand: cli::expand, + trigger: Trigger::Declaration(cli::expand), }, MacroDef { annotation: "fake", - expand: fake::expand, + trigger: Trigger::Declaration(fake::expand), }, MacroDef { annotation: "restClient", - expand: rest::expand, + trigger: Trigger::Declaration(rest::expand), + }, + // Triggered by a Markdown generation group rather than by an annotation + // [typediagram.macro]. It sits in this table so that resolution, shadowing + // rules, and diagnostics are the ones every other macro already has. + MacroDef { + annotation: "typeDiagram", + trigger: Trigger::Group(typediagram::expand), }, ]; @@ -139,7 +166,17 @@ pub fn expand( ); } if let Some(def) = REGISTRY.iter().find(|m| m.annotation == annotation.name) { - fragments.push((def.expand)(decl, file).with_context(|| { + let Trigger::Declaration(expand) = &def.trigger else { + // A macro this registry serves from a different trigger is not + // one an annotation can reach [typediagram.macro]. + bail!( + "DMX2006: `@dmx('{}')` is not an annotation; `{}` generates from a Markdown \ + generation group [typediagram.macro]", + def.annotation, + def.annotation + ); + }; + fragments.push(expand(decl, file).with_context(|| { format!("DMX2100: `@dmx('{}')` on `{}`", def.annotation, decl.name) })?); continue; @@ -169,6 +206,34 @@ pub fn is_builtin(name: &str) -> bool { REGISTRY.iter().any(|m| m.annotation == name) } +/// The name the Markdown front end resolves for a generation group +/// [typediagram.macro]. +pub const GROUP_MACRO: &str = "typeDiagram"; + +/// Every file the built-in group macro produced for one synthesized invocation +/// [typediagram.macro]. +/// +/// Resolution goes through [`REGISTRY`] exactly as an annotation's does, so +/// there is one place a macro name means something and one set of rules about +/// what may shadow it. +/// +/// # Errors +/// +/// Fails when the macro refuses the group, carrying its own diagnostic +/// [typediagram.diagnostics]. +pub fn expand_group(invocation: &crate::typediagram::Invocation<'_>) -> Result> { + match REGISTRY + .iter() + .find(|m| m.annotation == GROUP_MACRO) + .map(|def| &def.trigger) + { + Some(Trigger::Group(expand)) => expand(invocation), + // Both arms are unreachable while the table above holds the row, and + // saying so beats a panic that claims the same thing less usefully. + _ => bail!("DMX2000: internal error — no `{GROUP_MACRO}` macro is registered"), + } +} + /// Whether any macro — built-in or potentially user-defined — triggers on /// this declaration. Any class-level `@dmx` qualifies: an unregistered name /// may be served by the project's Dart worker [dartmacros.discovery], and one @@ -190,9 +255,10 @@ pub(crate) fn application_count(declarations: &[RawDecl]) -> usize { .flat_map(|declaration| &declaration.annotations) .filter(|annotation| { annotation.dmx - && REGISTRY - .iter() - .any(|definition| definition.annotation == annotation.name) + && REGISTRY.iter().any(|definition| { + definition.annotation == annotation.name + && matches!(&definition.trigger, Trigger::Declaration(_)) + }) }) .count() } diff --git a/src/dmx/src/macros/typediagram.rs b/src/dmx/src/macros/typediagram.rs new file mode 100644 index 0000000..debf750 --- /dev/null +++ b/src/dmx/src/macros/typediagram.rs @@ -0,0 +1,267 @@ +//! The built-in `typeDiagram` macro [typediagram.macro]. +//! +//! Every other entry in the registry is triggered by an annotation on a Dart +//! declaration and contributes a region fragment. This one is triggered by a +//! Markdown generation group and contributes whole files — but it is the same +//! registry, the same Mustache engine, the same whitespace normalizer, and the +//! same "validate before anything is written" rule. The different trigger buys +//! a different input, not a second pipeline. +//! +//! What the macro owns is everything target-shaped: which target a template +//! named, whether that target can render every type the definition uses, +//! whether the declared output is a file that target generates, and whether +//! what came out is source that target will accept. What it does not own is +//! where the file goes — that is emission's question [typediagram.output]. + +use anyhow::{Context as _, Result, bail}; + +use crate::emit::GeneratedFile; +use crate::render; +use crate::typediagram::{Invocation, context, file_text, target}; + +/// Every file one generation group produces [typediagram.macro]. +/// +/// Each template renders once, against its own context, in document order. +/// Rendering one output cannot reach another's context: each is built fresh +/// from the model, which is immutable [typediagram.templates]. +/// +/// # Errors +/// +/// Fails when a template names an unknown target (`DMX8007`), when the +/// definition uses a type the target cannot render (`DMX8004`), when the +/// declared output is not a file that target generates (`DMX8005`), when the +/// template does not compile (`DMX8008`), or when the rendered source is not +/// valid for that target (`DMX4001`, `DMX4003`). +pub fn expand(invocation: &Invocation<'_>) -> Result> { + invocation + .group + .templates + .iter() + .map(|template| { + let target = target::find(&template.target) + .with_context(|| where_it_is(invocation, template.fence.line))?; + invocation + .model + .validate_for_target(target.name) + .map_err(|found| { + anyhow::anyhow!( + "DMX8004 [typediagram.model]: the typeDiagram definition in {} (fence {}, \ + line {}) uses types the `{}` target cannot generate:\n{}", + invocation.document, + invocation.group.definition.ordinal, + invocation.group.definition.line, + target.name, + found.in_document(invocation.group.definition.line) + ) + })?; + require_target_extension(&template.output, target) + .with_context(|| where_it_is(invocation, template.fence.line))?; + + let model = context::build( + invocation.document, + invocation.group, + template, + invocation.model, + target, + ) + .with_context(|| where_it_is(invocation, template.fence.line))?; + let body = render::render_json(&template.fence.body, &model).with_context(|| { + format!( + "DMX8008 [typediagram.templates]: the Mustache template generating `{}` does \ + not compile ({})", + template.output, + where_it_is(invocation, template.fence.line) + ) + })?; + + let text = file_text(invocation.document, invocation.group, template, &body); + (target.validate)(&text, &format!("`{}`", template.output)).with_context(|| { + format!( + "DMX8008 [typediagram.output]: the Mustache template generating `{}` produced \ + source the `{}` target refuses ({})", + template.output, + target.name, + where_it_is(invocation, template.fence.line) + ) + })?; + Ok(GeneratedFile { + name: template.output.clone(), + text, + }) + }) + .collect() +} + +/// Where in the document a failure happened, in the terms the author reads. +fn where_it_is(invocation: &Invocation<'_>, line: usize) -> String { + format!( + "in {} group {}, definition fence on line {}, template fence on line {line}", + invocation.document, invocation.group.ordinal, invocation.group.definition.line + ) +} + +/// Refuses an output the named target does not generate [typediagram.output]. +fn require_target_extension(output: &str, target: &target::Target) -> Result<()> { + if std::path::Path::new(output) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(target.extension)) + { + return Ok(()); + } + bail!( + "DMX8005 [typediagram.output]: `{output}` does not end in `.{}`, which the `{}` target \ + generates", + target.extension, + target.name + ) +} + +#[cfg(test)] +mod tests { + use crate::typediagram::{Invocation, markdown::groups, resolve}; + + /// The files a one-group document produces, or the failure it reports. + fn run(definition: &str, meta: &str, template: &str) -> anyhow::Result> { + let document = + format!("```typeDiagram\n{definition}\n```\n\n```mustache {meta}\n{template}\n```\n"); + let bound = groups(&document)?; + let group = &bound[0]; + let model = resolve("docs/a.dmx.md", group)?; + let invocation = Invocation { + document: "docs/a.dmx.md", + group, + model: &model, + }; + super::expand(&invocation).map(|files| files.into_iter().map(|f| f.text).collect()) + } + + /// The default one-output metadata. + const OUT: &str = "{\"dmx\":{\"output\":\"lib/a.dart\"}}"; + + /// [typediagram.macro]: a template that only places prepared values + /// generates a complete, owned Dart file. + #[test] + fn a_logic_free_template_generates_dart() { + let files = run( + "type Product { id: Uuid\n name: String\n price: Decimal\n note: Option }", + OUT, + "{{#declarations}}\n{{#isRecord}}\nfinal class {{name}}{{genericDeclaration}} {\n const {{name}}({{{constructorParameters}}});\n{{#fields}}\n final {{{dartType}}} {{name}};\n{{/fields}}\n}\n{{/isRecord}}\n{{/declarations}}", + ) + .expect("generate"); + assert_eq!(files.len(), 1); + let text = &files[0]; + assert!( + text.starts_with("// dmx: generated from docs/a.dmx.md"), + "{text}" + ); + assert!(text.contains("final class Product {"), "{text}"); + assert!( + text.contains("const Product({required this.id, required this.name, required this.price, this.note});"), + "{text}" + ); + assert!(text.contains(" final String? note;"), "{text}"); + } + + /// [typediagram.templates]: one definition, several templates, each with + /// its own context and its own output. + #[test] + fn one_definition_generates_several_files() { + let document = "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\n// {{source.output}}\nfinal class {{#declarations}}{{name}}{{/declarations}} {}\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/b.dart\"}}\n// {{source.output}}\nfinal class {{#declarations}}{{name}}Dto{{/declarations}} {}\n```\n"; + let bound = groups(document).expect("bind"); + let model = resolve("docs/a.dmx.md", &bound[0]).expect("resolve"); + let files = super::expand(&Invocation { + document: "docs/a.dmx.md", + group: &bound[0], + model: &model, + }) + .expect("generate"); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "lib/a.dart"); + assert!(files[0].text.contains("// lib/a.dart"), "{}", files[0].text); + assert!(files[0].text.contains("final class A {}")); + assert_eq!(files[1].name, "lib/b.dart"); + assert!(files[1].text.contains("final class ADto {}")); + assert!(files[1].text.contains("fences 1/3"), "{}", files[1].text); + } + + /// [typediagram.output]: a template whose render is not valid Dart, or is + /// Dart that generated code may not contain, fails before any write. + #[test] + fn invalid_or_unhygienic_output_is_refused() { + let error = format!( + "{:#}", + run( + "type A { x: Int }", + OUT, + "final class {{#declarations}}{{name}}{{/declarations}} {" + ) + .expect_err("unbalanced Dart") + ); + assert!(error.contains("DMX4001"), "{error}"); + assert!(error.contains("template fence on line 5"), "{error}"); + + let error = format!( + "{:#}", + run( + "type A { x: Int }", + OUT, + "int probe(Object? v) => throw StateError('{{#declarations}}{{name}}{{/declarations}}');", + ) + .expect_err("throwing Dart") + ); + assert!(error.contains("DMX4003"), "{error}"); + assert!(error.contains("never throws"), "{error}"); + } + + /// [typediagram.model]: a type the target cannot render fails before the + /// template runs, naming the document line. + #[test] + fn an_unrenderable_type_fails_before_rendering() { + let error = format!( + "{:#}", + run("type A { at: Timestamp }", OUT, "// {{name}}").expect_err("unknown type") + ); + assert!(error.contains("DMX8004"), "{error}"); + assert!(error.contains("unknown type 'Timestamp'"), "{error}"); + } + + /// [typediagram.output]: a target only generates its own kind of file, and + /// only targets dmx knows may be named. + #[test] + fn targets_and_extensions_are_checked() { + let error = format!( + "{:#}", + run( + "type A { x: Int }", + "{\"dmx\":{\"output\":\"lib/a.txt\"}}", + "// x" + ) + .expect_err("not a Dart file") + ); + assert!(error.contains("DMX8005"), "{error}"); + assert!(error.contains("does not end in `.dart`"), "{error}"); + + let error = format!( + "{:#}", + run( + "type A { x: Int }", + "{\"dmx\":{\"output\":\"lib/a.dart\",\"target\":\"kotlin\"}}", + "// x" + ) + .expect_err("no such target") + ); + assert!(error.contains("DMX8007"), "{error}"); + } + + /// [typediagram.templates]: a template that does not compile names the + /// document, the group, and its own fence. + #[test] + fn a_broken_template_names_where_it_is() { + let error = format!( + "{:#}", + run("type A { x: Int }", OUT, "{{> nowhere}}").expect_err("unresolvable partial") + ); + assert!(error.contains("DMX8008"), "{error}"); + assert!(error.contains("docs/a.dmx.md group 1"), "{error}"); + } +} diff --git a/src/dmx/src/main.rs b/src/dmx/src/main.rs index dd4a1c8..9bc37b3 100644 --- a/src/dmx/src/main.rs +++ b/src/dmx/src/main.rs @@ -4,26 +4,11 @@ use anyhow::{Result, bail}; use std::path::PathBuf; use std::process::ExitCode; -use dmx::{Options, Outcome, process_file, watch}; +use dmx::{Options, Outcome, process_path, typediagram, watch}; /// What `dmx` prints when it cannot tell what was asked of it. const USAGE: &str = "usage:\n dmx build [PATHS...] [--insert-regions] [--check]\n \ - dmx watch [PATHS...]\n dmx --version\n dmx --help"; - -/// The version this build reports [release.version]. -/// -/// The tag is the version. `Cargo.toml` carries the placeholder `0.0.0` and is -/// never rewritten — cargo owns that file, and nothing in this repository may -/// edit a structured file by pattern. The release passes the version the tag -/// names in `DMX_VERSION` instead, so the number is a property of the build -/// rather than of a commit somebody had to remember to bump. -/// -/// A build with nothing to inject reports the placeholder, which is the honest -/// answer: a local build is not a release. -const VERSION: &str = match option_env!("DMX_VERSION") { - Some(version) => version, - None => env!("CARGO_PKG_VERSION"), -}; + dmx watch [PATHS...]\n dmx explain FILE\n dmx --version\n dmx --help"; /// The subcommand this invocation is [cli]. #[derive(Clone, Copy)] @@ -32,6 +17,8 @@ enum Command { Build, /// Generate, then keep generating as the sources change. Watch, + /// Print what a source produces, without producing it [typediagram.execution]. + Explain, } fn main() -> ExitCode { @@ -50,11 +37,12 @@ fn run() -> Result { let command = match args.next().as_deref() { Some("build") => Command::Build, Some("watch") => Command::Watch, + Some("explain") => Command::Explain, // A binary that ships inside a VSIX has to be able to say which one it // is: `dmx.path` points at a build of somebody's choosing, and the // first question any report about it raises is which build [cli]. Some("--version" | "-V") => { - println!("dmx {VERSION}"); + println!("dmx {}", dmx::VERSION); return Ok(ExitCode::SUCCESS); } Some("--help" | "-h") => { @@ -76,29 +64,49 @@ fn run() -> Result { _ => paths.push(PathBuf::from(arg)), } } - if paths.is_empty() { + // `lib` is the zero-config source root [surface.zero-config], and it is the + // default for the subcommands that sweep. `explain` names one file, so a + // default there would only ever be the wrong file. + if paths.is_empty() && !matches!(command, Command::Explain) { paths.push(PathBuf::from("lib")); } match command { Command::Build => build(&paths, opts), - Command::Watch if opts.insert_regions || opts.check => { - bail!("[cli] build-only flags are not accepted by `dmx watch`\n{USAGE}") + Command::Watch | Command::Explain if opts.insert_regions || opts.check => { + bail!("[cli] build-only flags are not accepted by this subcommand\n{USAGE}") } Command::Watch => { watch::run(&paths, &opts)?; Ok(ExitCode::SUCCESS) } + Command::Explain => explain(&paths), + } +} + +/// Prints the generation groups, dependencies, and exact context of one +/// Markdown document [typediagram.execution]. +fn explain(paths: &[PathBuf]) -> Result { + let [path] = paths else { + bail!("[cli] `dmx explain` takes exactly one file\n{USAGE}"); + }; + if !typediagram::is_markdown(path) { + bail!( + "[cli] `dmx explain` currently explains Markdown documents; {} is not one", + path.display() + ); } + print!("{}", typediagram::document::explain(path)?); + Ok(ExitCode::SUCCESS) } /// One generation pass, reporting what it wrote and exiting non-zero under /// `--check` when anything was out of date [execution]. fn build(paths: &[PathBuf], opts: Options) -> Result { - let files = watch::collect_dart_files(paths)?; + let files = watch::collect_sources(paths)?; let mut updated = 0usize; for file in &files { - if let Outcome::Updated = process_file(file, &opts)? { + if let Outcome::Updated = process_path(file, paths, &opts)? { updated = updated.saturating_add(1); println!( "{} {}", diff --git a/src/dmx/src/render.rs b/src/dmx/src/render.rs index 5786857..c12831e 100644 --- a/src/dmx/src/render.rs +++ b/src/dmx/src/render.rs @@ -52,22 +52,27 @@ pub fn render(template: &str, ctx: &C) -> Result { }) } -/// Renders `template` against a model a macro worker computed +/// Renders `template` against a model computed outside this crate /// [dartmacros.render]. /// /// This is [`render`] with the context supplied as JSON instead of as a Rust -/// struct, so a macro written in Dart reaches the very engine, standalone-tag +/// struct, so a macro written in Dart — or a Mustache fence in a Markdown +/// document [typediagram.templates] — reaches the very engine, standalone-tag /// handling, and normalizer the catalogue's own templates go through. The /// playground's template override deliberately does not apply: it replaces one -/// inferred built-in's template [playground.wasm], and a project's macro -/// brought its own. +/// inferred built-in's template [playground.wasm], and these callers brought +/// their own. +/// +/// The failure is deliberately uncoded: a template that does not compile means +/// something different to each caller — a worker's bug, an author's typo — so +/// each adds its own diagnostic around this one. /// /// # Errors /// -/// Fails when `template` does not compile, naming the template the macro sent. -pub fn render_json(name: &str, template: &str, model: &Value) -> Result { - let compiled = Template::new(strip_standalone(template)) - .with_context(|| format!("DMX7009: macro template `{name}` does not compile"))?; +/// Fails when `template` does not compile. +pub fn render_json(template: &str, model: &Value) -> Result { + let compiled = + Template::new(strip_standalone(template)).context("the template is not valid Mustache")?; Ok(normalize(&compiled.render(&Json(model)))) } diff --git a/src/dmx/src/typediagram/ast.rs b/src/dmx/src/typediagram/ast.rs new file mode 100644 index 0000000..04c1498 --- /dev/null +++ b/src/dmx/src/typediagram/ast.rs @@ -0,0 +1,348 @@ +//! The typeDiagram syntax tree [typediagram.model]. +//! +//! One immutable value per production in the published grammar, in the order +//! the author wrote them. Nothing here resolves a name — a `TypeRef` is a +//! spelling until [`super::model`] says what it refers to — so the parser can +//! be read against the grammar without knowing anything about resolution. + +/// Where a node came from, inside the definition it was parsed from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Span { + /// One-based line within the definition. + pub line: usize, + /// One-based column within the line. + pub col: usize, + /// How many characters the node spans on its opening line. + pub length: usize, +} + +/// A whole definition: every declaration, in source order. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Diagram { + /// The declarations, in the order they were written. + pub decls: Vec, +} + +/// The four things a typeDiagram definition can declare. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Decl { + /// `type Name<..> { field: Type … }`. + Record(Record), + /// `union Name<..> { Variant … }`, optionally `untagged`. + Union(Union), + /// `alias Name<..> = Type`. + Alias(Alias), + /// `function name<..>(..) -> Type`, or an overload block. + Function(Function), +} + +impl Decl { + /// The declared name, whichever form this is. + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::Record(d) => &d.name, + Self::Union(d) => &d.name, + Self::Alias(d) => &d.name, + Self::Function(d) => &d.name, + } + } + + /// The generic parameters it introduces, in declaration order. + #[must_use] + pub fn generics(&self) -> &[String] { + match self { + Self::Record(d) => &d.generics, + Self::Union(d) => &d.generics, + Self::Alias(d) => &d.generics, + Self::Function(d) => &d.generics, + } + } + + /// Where the declaration begins. + #[must_use] + pub fn span(&self) -> Span { + match self { + Self::Record(d) => d.span, + Self::Union(d) => d.span, + Self::Alias(d) => d.span, + Self::Function(d) => d.span, + } + } + + /// The `@targets` / `@skipTargets` filter written above it, if any. + #[must_use] + pub fn targeting(&self) -> Option<&Targeting> { + match self { + Self::Record(d) => d.targeting.as_ref(), + Self::Union(d) => d.targeting.as_ref(), + Self::Alias(d) => d.targeting.as_ref(), + Self::Function(d) => d.targeting.as_ref(), + } + } +} + +/// Which generation targets a declaration is meant for. +/// +/// Each list is absent rather than empty when the author did not write the +/// annotation, because upstream's model JSON distinguishes the two and the +/// differential corpus compares against it [typediagram.delivery.baseline]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Targeting { + /// `@targets(a, b)` — an allow list. + pub targets: Option>, + /// `@skipTargets(a, b)` — a deny list. + pub skip_targets: Option>, +} + +impl Targeting { + /// Whether a declaration carrying this filter is visible to `target`. + /// + /// An allow list that was written but left empty filters nothing, which is + /// upstream's rule and the only reading that keeps `@targets()` harmless. + #[must_use] + pub fn admits(&self, target: &str) -> bool { + let allowed = self + .targets + .as_ref() + .is_none_or(|names| names.is_empty() || names.iter().any(|name| name == target)); + allowed + && !self + .skip_targets + .as_ref() + .is_some_and(|names| names.iter().any(|name| name == target)) + } +} + +/// `type Name<..> { … }`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Record { + /// The declared name. + pub name: String, + /// Its generic parameters, in declaration order. + pub generics: Vec, + /// Its fields, in source order. + pub fields: Vec, + /// The target filter written above it. + pub targeting: Option, + /// Where the declaration begins. + pub span: Span, +} + +/// `union Name<..> { … }`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Union { + /// The declared name. + pub name: String, + /// Its generic parameters, in declaration order. + pub generics: Vec, + /// Whether it was declared `untagged`. + pub untagged: bool, + /// Its variants, in source order. + pub variants: Vec, + /// The target filter written above it. + pub targeting: Option, + /// Where the declaration begins. + pub span: Span, +} + +/// `alias Name<..> = Target`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Alias { + /// The declared name. + pub name: String, + /// Its generic parameters, in declaration order. + pub generics: Vec, + /// What the name stands for. + pub target: TypeRef, + /// The target filter written above it. + pub targeting: Option, + /// Where the declaration begins. + pub span: Span, +} + +/// `function name<..> …` — one or more overload signatures under one name. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Function { + /// The declared name. + pub name: String, + /// Its generic parameters, in declaration order. + pub generics: Vec, + /// Its signatures, in source order; a bare form declares exactly one. + pub signatures: Vec, + /// The target filter written above it. + pub targeting: Option, + /// Where the declaration begins. + pub span: Span, +} + +/// One `(params) -> Return` signature. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Signature { + /// The parameters, in source order. + pub params: Vec, + /// What the signature returns. + pub returns: TypeRef, + /// Whether the signature itself was written `async`. + pub is_async: bool, + /// Where the signature begins. + pub span: Span, +} + +/// A named, typed member: a record field, a variant field, or a parameter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Field { + /// The member's name. A tuple variant's positional members are `_0`, `_1`, + /// … exactly as upstream names them. + pub name: String, + /// Its declared type. + pub ty: TypeRef, + /// Where the member begins. + pub span: Span, +} + +/// One arm of a union. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Variant { + /// The variant's name. + pub name: String, + /// The pinned wire value, as written, when the author gave one. + pub discriminant: Option, + /// Its payload, empty for a bare variant. + pub fields: Vec, + /// Where the variant begins. + pub span: Span, +} + +impl Variant { + /// Whether the payload was written in tuple form — which upstream records + /// by naming the members `_0`, `_1`, … and nothing else. + #[must_use] + pub fn is_tuple(&self) -> bool { + !self.fields.is_empty() + && self + .fields + .iter() + .enumerate() + .all(|(index, field)| field.name == format!("_{index}")) + } +} + +/// A type as written: a name and its type arguments. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TypeRef { + /// The name as the author spelled it. + pub name: String, + /// Its type arguments, in source order. + pub args: Vec, + /// Where the reference begins. + pub span: Span, +} + +impl TypeRef { + /// The canonical typeDiagram spelling, arguments included + /// [typediagram.model]. + #[must_use] + pub fn canonical(&self) -> String { + if self.args.is_empty() { + return self.name.clone(); + } + format!( + "{}<{}>", + self.name, + self.args + .iter() + .map(Self::canonical) + .collect::>() + .join(", ") + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Field, Span, Targeting, TypeRef, Variant}; + + /// A span used where the test is not about positions. + const SPAN: Span = Span { + line: 1, + col: 1, + length: 1, + }; + + /// A reference with no arguments, for the tests below. + fn plain(name: &str) -> TypeRef { + TypeRef { + name: name.to_owned(), + args: Vec::new(), + span: SPAN, + } + } + + /// [typediagram.model]: the canonical spelling round-trips nesting. + #[test] + fn canonical_spelling_keeps_nested_arguments() { + let nested = TypeRef { + name: "Map".to_owned(), + args: vec![ + plain("String"), + TypeRef { + name: "List".to_owned(), + args: vec![plain("Product")], + span: SPAN, + }, + ], + span: SPAN, + }; + assert_eq!(nested.canonical(), "Map>"); + assert_eq!(plain("Int").canonical(), "Int"); + } + + /// [typediagram.model]: tuple form is exactly the `_0`, `_1`, … naming. + #[test] + fn tuple_variants_are_recognised_by_their_member_names() { + let field = |name: &str| Field { + name: name.to_owned(), + ty: plain("Int"), + span: SPAN, + }; + let variant = |fields: Vec| Variant { + name: "V".to_owned(), + discriminant: None, + fields, + span: SPAN, + }; + assert!(variant(vec![field("_0"), field("_1")]).is_tuple()); + assert!(!variant(vec![field("_0"), field("width")]).is_tuple()); + assert!(!variant(Vec::new()).is_tuple()); + } + + /// [typediagram.model]: an allow list excludes everything not on it; a + /// deny list excludes only what is. + #[test] + fn targeting_filters_by_allow_then_deny() { + let allow = Targeting { + targets: Some(vec!["dart".to_owned()]), + skip_targets: None, + }; + assert!(allow.admits("dart")); + assert!(!allow.admits("rust")); + + let deny = Targeting { + targets: None, + skip_targets: Some(vec!["dart".to_owned()]), + }; + assert!(!deny.admits("dart")); + assert!(deny.admits("rust")); + assert!(Targeting::default().admits("anything")); + + let empty = Targeting { + targets: Some(Vec::new()), + skip_targets: None, + }; + assert!( + empty.admits("anything"), + "an empty allow list filters nothing" + ); + } +} diff --git a/src/dmx/src/typediagram/context.rs b/src/dmx/src/typediagram/context.rs new file mode 100644 index 0000000..64514f5 --- /dev/null +++ b/src/dmx/src/typediagram/context.rs @@ -0,0 +1,385 @@ +//! The Mustache context one generation group renders against +//! [typediagram.model]. +//! +//! Everything a template could otherwise be tempted to compute is finished +//! here: casings, target type text, generic declarations, constructor +//! fragments, separators, and the first/last markers that let a template lay +//! out a list without arithmetic [context.discipline]. A template selects and +//! places prepared values; it never resolves a type and never decides what a +//! language spells something. +//! +//! Two names carry the same value on purpose. `targetType` is what a +//! language-neutral template asks for, and `dartType` is the name +//! [typediagram.model] pins for the Dart target. Templates written against +//! either keep working when a second target lands. + +use anyhow::Result; +use serde_json::{Map, Value, json}; + +use super::ast::{Decl, Field, Signature, TypeRef, Variant}; +use super::markdown::{BoundTemplate, Group}; +use super::model::{Model, Resolution}; +use super::target::Target; +use crate::casing; + +/// The context schema version. A change to the shape below bumps it, and the +/// golden fixtures move in the same commit [typediagram.model]. +pub const CONTEXT_VERSION: u64 = 1; + +/// Everything one bound template renders against. +/// +/// # Errors +/// +/// Fails when a reference has no text in this target — a container built-in +/// given the wrong number of arguments is the only way that happens, because +/// every other unresolvable name was refused before this point. +pub fn build( + document: &str, + group: &Group, + template: &BoundTemplate, + model: &Model, + target: &Target, +) -> Result { + let declarations = model + .visible(target.name) + .map(|decl| declaration(decl, model, target)) + .collect::>>()?; + Ok(json!({ + "modelVersion": CONTEXT_VERSION, + "target": target.name, + "source": { + "path": document, + "group": group.ordinal, + "definitionFence": group.definition.ordinal, + "definitionLine": group.definition.line, + "templateFence": template.fence.ordinal, + "templateLine": template.fence.line, + "output": template.output, + }, + "declarations": positioned(declarations), + })) +} + +/// Adds one prepared value to a context object. +/// +/// `Map::insert` returns whatever it displaced, which is never anything here +/// and which `unused_results` obliges every caller to discard. Written out, the +/// builders below would be `let _ =` noise wrapped around the one thing that +/// matters — the name and the value. +fn put(out: &mut Map, name: &str, value: impl Into) { + drop(out.insert(name.to_owned(), value.into())); +} + +/// One declaration, with the flags and members its kind carries. +fn declaration(decl: &Decl, model: &Model, target: &Target) -> Result> { + let mut out = named(decl.name()); + let generics = decl.generics(); + put(&mut out, "kind", kind_name(decl)); + put( + &mut out, + "generics", + positioned(generics.iter().map(|name| named(name)).collect()), + ); + put(&mut out, "hasGenerics", !generics.is_empty()); + put(&mut out, "genericDeclaration", generic_list(generics)); + // Mutually exclusive kind flags, so a template selects a shape without a + // per-kind list duplicating the declaration [typediagram.model]. + for kind in ["record", "union", "alias", "function"] { + put( + &mut out, + &format!("is{}", casing::pascal(kind)), + kind_name(decl) == kind, + ); + } + match decl { + Decl::Record(record) => { + put(&mut out, "hasFields", !record.fields.is_empty()); + members(&mut out, "fields", &record.fields, model, target)?; + } + Decl::Union(union) => { + put(&mut out, "untagged", union.untagged); + put(&mut out, "hasVariants", !union.variants.is_empty()); + let owner = Owner { + name: &union.name, + generic_declaration: generic_list(generics), + }; + let variants = union + .variants + .iter() + .map(|variant| self::variant(variant, &owner, model, target)) + .collect::>>()?; + put(&mut out, "variants", positioned(variants)); + } + Decl::Alias(alias) => { + let typed = type_ref(&alias.target, model, target)?; + typed.place_into(&mut out); + put(&mut out, "target", typed.value); + } + Decl::Function(function) => { + let signatures = function + .signatures + .iter() + .map(|signature| self::signature(signature, model, target)) + .collect::>>()?; + put(&mut out, "signatures", positioned(signatures)); + } + } + Ok(out) +} + +/// The union a variant belongs to, which its own `name` would otherwise hide. +/// +/// A Mustache section pushes the variant onto the context stack, so `{{name}}` +/// inside `{{#variants}}` is the *variant's* name and the union's is +/// unreachable. Generating `final class Circle extends Shape` needs both, and +/// asking a template to carry one down by hand is exactly the logic +/// [context.discipline] keeps out of templates. +struct Owner<'a> { + /// The union's declared name. + name: &'a str, + /// Its ``, or the empty string. + generic_declaration: String, +} + +/// One variant of a union, with its payload shape already decided. +fn variant( + variant: &Variant, + owner: &Owner<'_>, + model: &Model, + target: &Target, +) -> Result> { + let mut out = named(&variant.name); + put(&mut out, "owner", owner.name); + put( + &mut out, + "ownerGenericDeclaration", + owner.generic_declaration.clone(), + ); + put(&mut out, "hasFields", !variant.fields.is_empty()); + put(&mut out, "isBare", variant.fields.is_empty()); + put(&mut out, "isTuple", variant.is_tuple()); + put(&mut out, "hasDiscriminant", variant.discriminant.is_some()); + put( + &mut out, + "discriminant", + variant.discriminant.clone().unwrap_or_default(), + ); + members(&mut out, "fields", &variant.fields, model, target)?; + Ok(out) +} + +/// One overload signature. +fn signature(signature: &Signature, model: &Model, target: &Target) -> Result> { + let mut out = Map::new(); + let params = fields(&signature.params, model, target)?; + let returns = type_ref(&signature.returns, model, target)?; + put(&mut out, "isAsync", signature.is_async); + put(&mut out, "hasParams", !signature.params.is_empty()); + put(&mut out, "parameterList", parameter_list(¶ms)); + put(&mut out, "returnType", returns.text); + put(&mut out, "returns", returns.value); + put(&mut out, "params", positioned(params)); + Ok(out) +} + +/// Adds a member list under `name`, together with the constructor fragment it +/// adds up to — the two things a record and a variant both need, in one place. +fn members( + out: &mut Map, + name: &str, + source: &[Field], + model: &Model, + target: &Target, +) -> Result<()> { + let members = fields(source, model, target)?; + put( + out, + "constructorParameters", + constructor_parameters(&members), + ); + put(out, name, positioned(members)); + Ok(()) +} + +/// A field list — a record's, a variant's payload, or a signature's parameters. +fn fields(fields: &[Field], model: &Model, target: &Target) -> Result>> { + fields + .iter() + .map(|field| { + let mut out = named(&field.name); + let typed = type_ref(&field.ty, model, target)?; + typed.place_into(&mut out); + put(&mut out, "typeDiagram", field.ty.canonical()); + put(&mut out, "isOptional", typed.optional); + put(&mut out, "isRequired", !typed.optional); + put( + &mut out, + "parameter", + parameter(&field.name, typed.optional), + ); + put(&mut out, "type", typed.value); + Ok(out) + }) + .collect() +} + +/// One type reference, resolved for the target. +/// +/// The prepared text and the optional flag come back beside the context object +/// rather than being read back out of it: a member that needs them should not +/// have to index into JSON to find what this function already computed. +struct Typed { + /// The target's text for the reference. + text: String, + /// Whether the reference is an `Option`. + optional: bool, + /// The reference as a template sees it. + value: Map, +} + +impl Typed { + /// Adds this type's text to whatever names it, under both the neutral name + /// and the Dart one [typediagram.model]. + fn place_into(&self, out: &mut Map) { + put(out, "targetType", self.text.clone()); + put(out, "dartType", self.text.clone()); + } +} + +/// One type reference, resolved and rendered in the target's terms. +fn type_ref(reference: &TypeRef, model: &Model, target: &Target) -> Result { + let text = (target.type_text)(reference, model)?; + let optional = reference.name == "Option"; + let resolution = model.resolution(reference); + let arguments = reference + .args + .iter() + .map(|arg| type_ref(arg, model, target).map(|typed| typed.value)) + .collect::>>()?; + let mut out = Map::new(); + put(&mut out, "name", reference.name.clone()); + put(&mut out, "typeDiagram", reference.canonical()); + put(&mut out, "targetType", text.clone()); + put(&mut out, "dartType", text.clone()); + put( + &mut out, + "isPrimitive", + matches!(resolution, Resolution::Primitive), + ); + put( + &mut out, + "isDeclared", + matches!(resolution, Resolution::Declared(_)), + ); + put( + &mut out, + "isTypeParam", + matches!(resolution, Resolution::TypeParam), + ); + put(&mut out, "isOptional", optional); + put(&mut out, "isList", reference.name == "List"); + put(&mut out, "isMap", reference.name == "Map"); + put(&mut out, "isAny", reference.name == "Any"); + put(&mut out, "hasArguments", !arguments.is_empty()); + put(&mut out, "arguments", positioned(arguments)); + Ok(Typed { + text, + optional, + value: out, + }) +} + +/// The `kind` a declaration reports. +fn kind_name(decl: &Decl) -> &'static str { + match decl { + Decl::Record(_) => "record", + Decl::Union(_) => "union", + Decl::Alias(_) => "alias", + Decl::Function(_) => "function", + } +} + +/// A name in every casing a template might place it in +/// [context.helpers]. +fn named(name: &str) -> Map { + let mut out = Map::new(); + put(&mut out, "name", name); + put(&mut out, "camelName", casing::camel(name)); + put(&mut out, "pascalName", casing::pascal(name)); + put(&mut out, "snakeName", casing::snake(name)); + put( + &mut out, + "screamingSnakeName", + casing::screaming_snake(name), + ); + put(&mut out, "label", casing::label(name)); + out +} + +/// ``, or the empty string when there are no parameters. +fn generic_list(generics: &[String]) -> String { + if generics.is_empty() { + return String::new(); + } + format!("<{}>", generics.join(", ")) +} + +/// The named-parameter list a constructor takes, braces included, or the empty +/// string when there is nothing to take. +fn constructor_parameters(fields: &[Map]) -> String { + let parts: Vec<&str> = fields + .iter() + .filter_map(|field| field.get("parameter").and_then(Value::as_str)) + .collect(); + if parts.is_empty() { + return String::new(); + } + format!("{{{}}}", parts.join(", ")) +} + +/// One constructor parameter. An optional member has a default of `null` +/// already, so requiring it would only make callers write it. +fn parameter(name: &str, optional: bool) -> String { + if optional { + return format!("this.{name}"); + } + format!("required this.{name}") +} + +/// The positional parameter list a free function takes. +fn parameter_list(params: &[Map]) -> String { + params + .iter() + .filter_map(|param| { + Some(format!( + "{} {}", + param.get("targetType")?.as_str()?, + param.get("name")?.as_str()? + )) + }) + .collect::>() + .join(", ") +} + +/// Stamps `first`, `last`, and `comma` onto every member of a list, so a +/// template lays out separators without counting [context.discipline]. +fn positioned(items: Vec>) -> Vec { + let last = items.len().saturating_sub(1); + items + .into_iter() + .enumerate() + .map(|(index, mut item)| { + let final_item = index == last; + put(&mut item, "first", index == 0); + put(&mut item, "last", final_item); + put(&mut item, "index", index); + put(&mut item, "comma", if final_item { "" } else { "," }); + Value::Object(item) + }) + .collect() +} + +// A separate file only because context.rs is at the 500-line ceiling. +#[cfg(test)] +#[path = "context_tests.rs"] +mod tests; diff --git a/src/dmx/src/typediagram/context_tests.rs b/src/dmx/src/typediagram/context_tests.rs new file mode 100644 index 0000000..29aa72e --- /dev/null +++ b/src/dmx/src/typediagram/context_tests.rs @@ -0,0 +1,188 @@ +//! What one generation group's Mustache context has to contain +//! [typediagram.model]. +//! +//! Every name a template may place is asserted here, because the context *is* +//! the contract: a template author reads these names out of `dmx explain` and +//! writes them into a fence, and renaming one silently breaks every document +//! in every project that used it. + +use serde_json::{Value, json}; + +use super::super::markdown::groups; +use super::super::model::Model; +use super::super::parser::parse; +use super::super::target; +use super::build; + +/// The context a one-template document over `definition` produces. +fn context(definition: &str) -> Value { + let document = format!( + "```typeDiagram\n{definition}\n```\n\n```mustache {{\"dmx\":{{\"output\":\"lib/a.dart\"}}}}\nx\n```\n" + ); + let bound = groups(&document).expect("bind"); + let model = Model::resolve(parse(&bound[0].definition.body).expect("parse")).expect("resolve"); + let target = target::find("dart").expect("dart target"); + model.validate_for_target("dart").expect("resolvable"); + build( + "docs/a.dmx.md", + &bound[0], + &bound[0].templates[0], + &model, + target, + ) + .expect("context") +} + +/// The first declaration of the context for `definition`. +fn first(definition: &str) -> Value { + context(definition)["declarations"][0].clone() +} + +/// [typediagram.model]: the root names the document, both fences, and the +/// context version. +#[test] +fn the_root_locates_the_group_in_its_document() { + let root = context("type A { x: Int }"); + assert_eq!(root["modelVersion"], json!(1)); + assert_eq!(root["target"], json!("dart")); + assert_eq!(root["source"]["path"], json!("docs/a.dmx.md")); + assert_eq!(root["source"]["group"], json!(1)); + assert_eq!(root["source"]["definitionFence"], json!(1)); + assert_eq!(root["source"]["templateFence"], json!(2)); + assert_eq!(root["source"]["definitionLine"], json!(1)); + assert_eq!(root["source"]["output"], json!("lib/a.dart")); +} + +/// [typediagram.model]: kind flags are mutually exclusive and every +/// declaration appears exactly once, in source order. +#[test] +fn kind_flags_replace_per_kind_lists() { + let declarations = + context("type A { x: Int }\nunion B { C }\nalias D = String\nfunction e() -> Unit"); + let declarations = declarations["declarations"].as_array().expect("array"); + assert_eq!( + declarations + .iter() + .map(|d| d["name"].as_str().unwrap_or_default()) + .collect::>(), + ["A", "B", "D", "e"] + ); + for (index, flag) in ["isRecord", "isUnion", "isAlias", "isFunction"] + .into_iter() + .enumerate() + { + for (other, declaration) in declarations.iter().enumerate() { + assert_eq!( + declaration[flag], + json!(index == other), + "{flag} on {}", + declaration["name"] + ); + } + } + assert_eq!(declarations[0]["first"], json!(true)); + assert_eq!(declarations[3]["last"], json!(true)); + assert_eq!(declarations[0]["comma"], json!(",")); + assert_eq!(declarations[3]["comma"], json!("")); +} + +/// [typediagram.model]: a field arrives with its casings, its target type, +/// its typeDiagram spelling, and its constructor fragment. +#[test] +fn a_field_is_ready_to_place() { + let record = first("type Order { order_id: Uuid, lines: List, note: Option }"); + assert_eq!(record["genericDeclaration"], json!("")); + assert_eq!(record["hasGenerics"], json!(true)); + assert_eq!(record["generics"][0]["name"], json!("T")); + assert_eq!( + record["constructorParameters"], + json!("{required this.order_id, required this.lines, this.note}") + ); + let field = &record["fields"][0]; + assert_eq!(field["camelName"], json!("orderId")); + assert_eq!(field["pascalName"], json!("OrderId")); + assert_eq!(field["snakeName"], json!("order_id")); + assert_eq!(field["screamingSnakeName"], json!("ORDER_ID")); + assert_eq!(field["dartType"], json!("String")); + assert_eq!(field["targetType"], json!("String")); + assert_eq!(field["typeDiagram"], json!("Uuid")); + assert_eq!(field["isRequired"], json!(true)); + assert_eq!(record["fields"][1]["type"]["isList"], json!(true)); + assert_eq!( + record["fields"][1]["type"]["arguments"][0]["isTypeParam"], + json!(true) + ); + assert_eq!(record["fields"][2]["isOptional"], json!(true)); + assert_eq!(record["fields"][2]["parameter"], json!("this.note")); + assert_eq!(record["fields"][2]["dartType"], json!("String?")); +} + +/// [typediagram.model]: every variant form arrives distinguishable. +#[test] +fn variants_carry_their_shape() { + let union = + first("union Shape { Circle { radius: Float }\n Pair(Int, Int)\n Point\n Code = -32700 }"); + assert_eq!(union["untagged"], json!(false)); + assert_eq!(union["hasVariants"], json!(true)); + let variants = union["variants"].as_array().expect("array"); + assert_eq!( + variants[0]["constructorParameters"], + json!("{required this.radius}") + ); + assert_eq!(variants[1]["isTuple"], json!(true)); + assert_eq!(variants[1]["fields"][1]["name"], json!("_1")); + assert_eq!(variants[2]["isBare"], json!(true)); + assert_eq!(variants[2]["constructorParameters"], json!("")); + assert_eq!(variants[0]["owner"], json!("Shape")); + assert_eq!(variants[0]["ownerGenericDeclaration"], json!("")); + assert_eq!(variants[3]["hasDiscriminant"], json!(true)); + assert_eq!(variants[3]["discriminant"], json!("-32700")); + assert_eq!(variants[0]["hasDiscriminant"], json!(false)); +} + +/// [typediagram.model]: a variant reaches the union it belongs to, whose +/// own name its own would otherwise hide. +#[test] +fn a_variant_names_the_union_it_belongs_to() { + let union = first("union Result { Ok { value: T }\n Err { error: E } }"); + let variants = union["variants"].as_array().expect("array"); + assert_eq!(variants[0]["name"], json!("Ok")); + assert_eq!(variants[0]["owner"], json!("Result")); + assert_eq!(variants[0]["ownerGenericDeclaration"], json!("")); + assert_eq!(variants[1]["owner"], json!("Result")); +} + +/// [typediagram.model]: an alias exposes its target both ways, and a +/// function exposes a ready parameter list per signature. +#[test] +fn aliases_and_functions_are_ready_to_place() { + let alias = first("alias Ids = List"); + assert_eq!(alias["dartType"], json!("List")); + assert_eq!(alias["target"]["typeDiagram"], json!("List")); + + let function = first( + "function read {\n (path: String) -> Bytes\n async (path: String, timeout: Float) -> Unit\n}", + ); + let signatures = function["signatures"].as_array().expect("array"); + assert_eq!(signatures[0]["parameterList"], json!("String path")); + assert_eq!(signatures[0]["returnType"], json!("List")); + assert_eq!(signatures[0]["isAsync"], json!(false)); + assert_eq!( + signatures[1]["parameterList"], + json!("String path, double timeout") + ); + assert_eq!(signatures[1]["isAsync"], json!(true)); + assert_eq!(signatures[1]["returnType"], json!("void")); +} + +/// [typediagram.model]: a declaration another target owns is not in this +/// target's context at all. +#[test] +fn targeting_removes_a_declaration_from_the_context() { + let declarations = context("@skipTargets(dart)\ntype Hidden { x: Int }\ntype Shown { y: Int }"); + let declarations = declarations["declarations"].as_array().expect("array"); + assert_eq!(declarations.len(), 1); + assert_eq!(declarations[0]["name"], json!("Shown")); + assert_eq!(declarations[0]["first"], json!(true)); + assert_eq!(declarations[0]["last"], json!(true)); +} diff --git a/src/dmx/src/typediagram/diagnostic.rs b/src/dmx/src/typediagram/diagnostic.rs new file mode 100644 index 0000000..1cfb3b9 --- /dev/null +++ b/src/dmx/src/typediagram/diagnostic.rs @@ -0,0 +1,140 @@ +//! Source-anchored diagnostics for the typeDiagram front end +//! [typediagram.diagnostics]. +//! +//! A definition lives inside a Markdown fence, so a bare message is useless: +//! the author needs the line and column *inside the fence* and, at the +//! boundary, the document line the fence starts on. Everything here carries +//! the position; [`Diagnostic::in_document`] is what turns fence-relative +//! positions into document-relative ones once the binder knows where the fence +//! began. + +use std::fmt; + +/// One problem in a typeDiagram definition, anchored in its own source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Diagnostic { + /// What went wrong, in the author's terms. + pub message: String, + /// One-based line, relative to the text the diagnostic was produced from. + pub line: usize, + /// One-based column within that line. + pub col: usize, + /// How many characters the offending token spans; at least one. + pub length: usize, +} + +impl Diagnostic { + /// A diagnostic at `line`/`col` spanning `length` characters. + #[must_use] + pub fn at(message: impl Into, line: usize, col: usize, length: usize) -> Self { + Self { + message: message.into(), + line, + col, + length: length.max(1), + } + } + + /// The same diagnostic with its line rebased onto the enclosing document. + /// + /// `fence_line` is the one-based document line of the fence's *opening* + /// marker, so the definition's own first line is the one after it. + #[must_use] + pub fn in_document(&self, fence_line: usize) -> Self { + Self { + message: self.message.clone(), + line: fence_line.saturating_add(self.line), + col: self.col, + length: self.length, + } + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "line {}, column {}: {}", + self.line, self.col, self.message + ) + } +} + +/// Every diagnostic one parse or validation produced, in source order. +/// +/// This is a newtype rather than a bare `Vec` so that the whole set formats as +/// one block: a definition with three unknown type names is one failure with +/// three lines, not three failures. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Diagnostics(pub Vec); + +impl Diagnostics { + /// A set holding exactly one diagnostic. + #[must_use] + pub fn one(diagnostic: Diagnostic) -> Self { + Self(vec![diagnostic]) + } + + /// Whether anything was reported. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The same set rebased onto the enclosing document [`Diagnostic::in_document`]. + #[must_use] + pub fn in_document(&self, fence_line: usize) -> Self { + Self(self.0.iter().map(|d| d.in_document(fence_line)).collect()) + } +} + +impl fmt::Display for Diagnostics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for diagnostic in &self.0 { + if !first { + writeln!(f)?; + } + first = false; + write!(f, "{diagnostic}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Diagnostic, Diagnostics}; + + /// [typediagram.diagnostics]: a fence-relative position becomes a document + /// position, and the fence's own opening line is not part of the source. + #[test] + fn rebasing_counts_from_the_line_after_the_fence_marker() { + let inner = Diagnostic::at("unknown type 'Timestamp'", 3, 9, 9); + let outer = inner.in_document(12); + assert_eq!((outer.line, outer.col, outer.length), (15, 9, 9)); + assert_eq!(outer.message, inner.message); + } + + /// A zero-length token still underlines one character. + #[test] + fn a_span_is_never_empty() { + assert_eq!(Diagnostic::at("end of input", 1, 1, 0).length, 1); + } + + /// [typediagram.diagnostics]: several problems format as one block. + #[test] + fn a_set_formats_one_line_per_diagnostic() { + let set = Diagnostics(vec![ + Diagnostic::at("first", 1, 2, 3), + Diagnostic::at("second", 4, 5, 6), + ]); + assert_eq!( + set.to_string(), + "line 1, column 2: first\nline 4, column 5: second" + ); + assert!(!set.is_empty()); + assert!(Diagnostics::default().is_empty()); + assert_eq!(Diagnostics::one(Diagnostic::at("only", 1, 1, 1)).0.len(), 1); + } +} diff --git a/src/dmx/src/typediagram/document.rs b/src/dmx/src/typediagram/document.rs new file mode 100644 index 0000000..6d03f56 --- /dev/null +++ b/src/dmx/src/typediagram/document.rs @@ -0,0 +1,329 @@ +//! One Markdown document through the whole pipeline +//! [typediagram.execution]. +//! +//! Bind → resolve → invoke the built-in macro → check the paths → emit. The +//! document itself is never rewritten: it is the source of truth, and dmx only +//! ever reads it [typediagram.output]. +//! +//! `explain` walks the same path and stops before emission, printing what the +//! templates will actually see. It is the template author's only tool, so it +//! prints the exact context rather than a summary of it. + +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use serde_json::json; + +use super::{Invocation, context, emit, markdown, resolve, target}; +use crate::{Options, Outcome, macros}; + +/// Everything one document produced, resolved onto real paths. +struct Rendered { + /// Each output's absolute path and complete text. + outputs: Vec<(PathBuf, String)>, +} + +/// Generates every group in `path`, writing what changed +/// [typediagram.execution]. +/// +/// `roots` is the scope this pass was asked to manage, and therefore the scope +/// stale outputs are collected from: an output that a removed template used to +/// produce is found by its ownership marker among the files dmx already walks. +/// +/// # Errors +/// +/// Fails when the document cannot be read, when binding, resolution, rendering, +/// validation, or path safety refuses it, or on I/O. +pub fn process(path: &Path, roots: &[PathBuf], opts: &Options) -> Result { + let source = fs::read_to_string(path) + .with_context(|| format!("DMX1002: cannot read {}", path.display()))?; + let workspace = std::env::current_dir().context("DMX1002: cannot resolve the workspace")?; + let root = emit::output_root(&workspace, path); + let document = emit::document_name(&root, path); + let rendered = render(&document, &root, &source)?; + let candidates = crate::watch::collect_outputs(roots)?; + let changed = emit::emit(&document, &root, &rendered.outputs, &candidates, opts.check)?; + Ok(if changed { + Outcome::Updated + } else { + Outcome::Unchanged + }) +} + +/// Every output `source` declares, rendered and validated but not written. +fn render(document: &str, root: &Path, source: &str) -> Result { + let groups = markdown::groups(source).with_context(|| format!("in {document}"))?; + let mut outputs = Vec::new(); + for group in &groups { + let model = resolve(document, group)?; + let files = macros::expand_group(&Invocation { + document, + group, + model: &model, + })?; + // The macro renders one file per bound template, in template order, so + // a path fault can name the fence that declared it. + for (template, file) in group.templates.iter().zip(files) { + let located = || { + format!( + "in {document}, the Mustache template on line {}", + template.fence.line + ) + }; + emit::refuse_self_overwrite(document, &file.name).with_context(located)?; + let path = emit::resolve_output(root, &file.name).with_context(located)?; + outputs.push((path, file.text)); + } + } + Ok(Rendered { outputs }) +} + +/// What `dmx explain` prints for a Markdown document +/// [typediagram.execution]. +/// +/// Nothing is rendered and nothing is written: this is the input side of the +/// pipeline, laid out so a template author can see the names they may place +/// before they place them. +/// +/// # Errors +/// +/// Fails when the document cannot be read, or when binding or resolution +/// refuses it — the same failures generation would report. +pub fn explain(path: &Path) -> Result { + let source = fs::read_to_string(path) + .with_context(|| format!("DMX1002: cannot read {}", path.display()))?; + let workspace = std::env::current_dir().context("DMX1002: cannot resolve the workspace")?; + let root = emit::output_root(&workspace, path); + let document = emit::document_name(&root, path); + let groups = markdown::groups(&source).with_context(|| format!("in {document}"))?; + let mut out = format!( + "{document}: {} generation group(s), outputs under {}\n", + groups.len(), + root.display() + ); + for group in &groups { + let model = resolve(&document, group)?; + writeln!( + out, + "\ngroup {} — typeDiagram fence {} on line {}, {} declaration(s), digest {}", + group.ordinal, + group.definition.ordinal, + group.definition.line, + model.decls().len(), + super::digest(&group.definition.body), + ) + .map_err(report_fault)?; + for template in &group.templates { + let target = target::find(&template.target)?; + writeln!( + out, + " -> {} (target {}, fence {} on line {}, digest {})", + template.output, + target.name, + template.fence.ordinal, + template.fence.line, + super::digest(&template.fence.body), + ) + .map_err(report_fault)?; + let ctx = context::build(&document, group, template, &model, target)?; + writeln!( + out, + "{}", + serde_json::to_string_pretty(&json!({ "context": ctx })) + .context("DMX2000: internal error — the context is not serializable")? + ) + .map_err(report_fault)?; + } + } + Ok(out) +} + +/// A `String` that cannot be written to is not a condition this program can +/// act on, and saying so is better than a panic that says less. +fn report_fault(error: std::fmt::Error) -> anyhow::Error { + anyhow::anyhow!("DMX2000: internal error — cannot format the explain report: {error}") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::{explain, process}; + use crate::{Options, Outcome}; + + /// A scratch workspace holding one document, with the process working + /// directory pointed at it. + /// + /// The working directory is process-wide, so these tests run under one + /// mutex rather than in parallel — the alternative is a `workspace` option + /// nothing but the tests would ever set. + fn in_workspace(document: &str, body: impl FnOnce(&std::path::Path) -> T) -> T { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let guard = LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let directory = scratch(); + fs::create_dir_all(directory.join("docs")).expect("docs directory"); + fs::write(directory.join("docs").join("models.dmx.md"), document).expect("document"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&directory).expect("enter workspace"); + let outcome = body(&directory); + std::env::set_current_dir(previous).expect("leave workspace"); + drop(fs::remove_dir_all(&directory)); + drop(guard); + outcome + } + + /// A directory nobody else holds. + fn scratch() -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let path = std::env::temp_dir().join(format!("dmx-td-{}-{unique}", std::process::id())); + fs::create_dir_all(&path).expect("scratch directory"); + path + } + + /// The canonical worked document. + const DOCUMENT: &str = r#"# Store + +```typeDiagram +type Product { + id: Uuid + name: String +} +``` + +```mustache {"dmx":{"output":"lib/models.dart"}} +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +``` +"#; + + /// The document's one path, and the pipeline options for a real build. + fn build_options() -> Options { + Options { + insert_regions: false, + check: false, + } + } + + /// [typediagram.execution]: a build writes the declared file, a second + /// build writes nothing, and the document is never rewritten. + #[test] + fn a_build_is_idempotent_and_never_touches_the_document() { + in_workspace(DOCUMENT, |directory| { + let path = directory.join("docs").join("models.dmx.md"); + let roots = vec![std::path::PathBuf::from("lib")]; + assert_eq!( + process(&path, &roots, &build_options()).expect("first build"), + Outcome::Updated + ); + let generated = fs::read_to_string(directory.join("lib").join("models.dart")) + .expect("generated file"); + assert!(generated.contains("final class Product {"), "{generated}"); + assert!(generated.contains("const Product({required this.id, required this.name});")); + assert!(generated.starts_with("// dmx: generated from docs/models.dmx.md")); + + assert_eq!( + process(&path, &roots, &build_options()).expect("second build"), + Outcome::Unchanged + ); + assert_eq!( + fs::read_to_string(&path).expect("document"), + DOCUMENT, + "the document is the source of truth and is never rewritten" + ); + }); + } + + /// [typediagram.execution]: `--check` reports drift and writes nothing. + #[test] + fn check_reports_drift_without_writing() { + in_workspace(DOCUMENT, |directory| { + let path = directory.join("docs").join("models.dmx.md"); + let roots = vec![std::path::PathBuf::from("lib")]; + let check = Options { + insert_regions: false, + check: true, + }; + assert_eq!( + process(&path, &roots, &check).expect("check"), + Outcome::Updated + ); + assert!(!directory.join("lib").join("models.dart").exists()); + }); + } + + /// [typediagram.output]: an output that exists without dmx's marker is a + /// hand-written file and is never overwritten. + #[test] + fn a_hand_written_output_is_refused() { + in_workspace(DOCUMENT, |directory| { + fs::create_dir_all(directory.join("lib")).expect("lib"); + fs::write(directory.join("lib").join("models.dart"), "// mine\n").expect("existing"); + let path = directory.join("docs").join("models.dmx.md"); + let error = format!( + "{:#}", + process(&path, &[std::path::PathBuf::from("lib")], &build_options()) + .expect_err("hand-written file") + ); + assert!(error.contains("DMX8006"), "{error}"); + assert_eq!( + fs::read_to_string(directory.join("lib").join("models.dart")).expect("untouched"), + "// mine\n" + ); + }); + } + + /// [typediagram.output]: a removed template takes its output with it. + #[test] + fn a_removed_template_collects_its_output() { + in_workspace(DOCUMENT, |directory| { + let path = directory.join("docs").join("models.dmx.md"); + let roots = vec![std::path::PathBuf::from("lib")]; + let _ = process(&path, &roots, &build_options()).expect("first build"); + assert!(directory.join("lib").join("models.dart").exists()); + + fs::write(&path, "# Store\n\nNothing to generate any more.\n").expect("rewrite"); + assert_eq!( + process(&path, &roots, &build_options()).expect("second build"), + Outcome::Updated + ); + assert!( + !directory.join("lib").join("models.dart").exists(), + "a dropped template means a dropped file" + ); + }); + } + + /// [typediagram.execution]: `explain` prints the groups, their paths, + /// their digests, and the exact context — and writes nothing. + #[test] + fn explain_prints_the_context_without_generating() { + in_workspace(DOCUMENT, |directory| { + let path = directory.join("docs").join("models.dmx.md"); + let report = explain(&path).expect("explain"); + assert!( + report.contains("docs/models.dmx.md: 1 generation group(s)"), + "{report}" + ); + assert!( + report.contains("-> lib/models.dart (target dart, fence 2 on line 10"), + "{report}" + ); + assert!(report.contains("\"modelVersion\": 1"), "{report}"); + assert!(report.contains("\"dartType\": \"String\""), "{report}"); + assert!(!directory.join("lib").join("models.dart").exists()); + }); + } +} diff --git a/src/dmx/src/typediagram/emit.rs b/src/dmx/src/typediagram/emit.rs new file mode 100644 index 0000000..9e35613 --- /dev/null +++ b/src/dmx/src/typediagram/emit.rs @@ -0,0 +1,237 @@ +//! Whole-file emission for a Markdown generation group [typediagram.output]. +//! +//! One thing happens here that the Dart-macro backend does not need: an output +//! path arrives from a *document* rather than from dmx, so it is checked before +//! anything is written — inside the workspace, no traversal, and no symbolic +//! link that leaves the tree. Whether the path is the right *kind* of file is +//! the target's question and is answered by the macro that named it. +//! +//! Everything after that is the shared protocol in [`crate::emit`]: never +//! overwrite an unmarked file, write atomically, skip a no-op, report drift +//! without writing under `--check`, and collect what a removed template used to +//! produce. + +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context as _, Result, bail}; + +use crate::emit::{collect_stale, write_owned}; + +/// The absolute path `declared` resolves to under `workspace` +/// [typediagram.output]. +/// +/// # Errors +/// +/// Fails (`DMX8005`) when the path is absolute, escapes the workspace, or +/// reaches through a symbolic link that leaves it. +pub fn resolve_output(workspace: &Path, declared: &str) -> Result { + let relative = Path::new(declared); + let fault = + |detail: &str| anyhow::anyhow!("DMX8005 [typediagram.output]: `{declared}` {detail}"); + for component in relative.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => { + return Err(fault("leaves the workspace; `..` is never an output path")); + } + Component::RootDir | Component::Prefix(_) => { + return Err(fault("is an absolute path; outputs are workspace-relative")); + } + } + } + let resolved = workspace.join(relative); + refuse_symlink_escape(workspace, &resolved).map_err(|detail| fault(&detail))?; + Ok(resolved) +} + +/// Refuses a path whose nearest existing ancestor resolves outside the root. +/// +/// A directory in the middle of an output path may be a symbolic link; the +/// question is only ever whether following it still lands inside the tree dmx +/// was asked to manage. Canonicalizing the deepest ancestor that exists answers +/// exactly that, and a path whose directories do not exist yet cannot have been +/// redirected by one. +fn refuse_symlink_escape(workspace: &Path, resolved: &Path) -> Result<(), String> { + let Ok(root) = workspace.canonicalize() else { + return Ok(()); + }; + let existing = resolved + .ancestors() + .skip(1) + .find(|ancestor| ancestor.exists()) + .unwrap_or(workspace); + match existing.canonicalize() { + Ok(real) if real.starts_with(&root) => Ok(()), + Ok(real) => Err(format!( + "reaches outside the workspace through {} -> {}", + existing.display(), + real.display() + )), + Err(_) => Ok(()), + } +} + +/// Refuses an output path a document may not claim at all. +/// +/// # Errors +/// +/// Fails when the declared path is the document itself, which would replace the +/// source of truth with its own output. +pub fn refuse_self_overwrite(document: &str, declared: &str) -> Result<()> { + if Path::new(document) == Path::new(declared) { + bail!( + "DMX8005 [typediagram.output]: `{declared}` is the document itself; a group never \ + overwrites its own source" + ); + } + Ok(()) +} + +/// Writes every output this document produced, then collects what it no longer +/// produces [typediagram.output]. +/// +/// Returns whether anything changed — or, under `check`, would have. +/// +/// # Errors +/// +/// Fails when an output exists without dmx's marker (`DMX8006`), or on I/O. +pub fn emit( + document: &str, + root: &Path, + outputs: &[(PathBuf, String)], + candidates: &[PathBuf], + check: bool, +) -> Result { + let mut changed = false; + for (path, content) in outputs { + changed |= write_owned(path, content, check, "DMX8006", "[typediagram.output]") + .with_context(|| { + format!( + "DMX8006 [typediagram.output]: generating {} from {document}", + display_relative(root, path) + ) + })?; + } + let kept: Vec = outputs.iter().map(|(path, _)| path.clone()).collect(); + let marker = super::ownership_marker(document); + Ok(collect_stale(candidates, &marker, &kept, check)? || changed) +} + +/// A path as a reader of the document would write it: relative to the +/// workspace when it is inside one, with forward slashes either way. +#[must_use] +pub fn display_relative(workspace: &Path, path: &Path) -> String { + path.strip_prefix(workspace) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// The directory a document's outputs are resolved against +/// [typediagram.output]. +/// +/// It is the nearest ancestor of the document that carries a project marker — +/// `pubspec.yaml` for Dart — bounded by the workspace, and the workspace +/// itself when there is none. That is what makes a document portable: `lib/a.dart` +/// means *this package's* `lib`, whether dmx was run from the package, from the +/// repository root, or from an editor that opened the whole tree. Resolving +/// against the working directory instead would make the same document generate +/// somewhere else depending on where it was run from. +#[must_use] +pub fn output_root(workspace: &Path, document: &Path) -> PathBuf { + let workspace = resolved(workspace); + let document = match document.canonicalize() { + Ok(absolute) => absolute, + Err(_) => workspace.join(document), + }; + document + .ancestors() + .skip(1) + .take_while(|ancestor| ancestor.starts_with(&workspace)) + .find(|ancestor| { + super::target::project_markers().any(|marker| ancestor.join(marker).is_file()) + }) + .map_or(workspace, Path::to_owned) +} + +/// A path in the one form two spellings of it agree on. +fn resolved(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_owned()) +} + +/// The name a document is known by — in its ownership markers, its +/// diagnostics, and its templates' contexts [typediagram.output]. +/// +/// It is the path relative to the root its outputs land in, so the same +/// document generates the same bytes however it was named — relatively, +/// absolutely, or through a symbolic link. Resolving both sides is what makes +/// that true: a directory reached through `/tmp` and one reached through +/// `/private/tmp` are the same directory, and an output that recorded the +/// difference would rewrite itself every time somebody ran dmx the other way. +#[must_use] +pub fn document_name(root: &Path, path: &Path) -> String { + let root = resolved(root); + let absolute = path.canonicalize().unwrap_or_else(|_| root.join(path)); + display_relative(&root, &absolute) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{display_relative, refuse_self_overwrite, resolve_output}; + + /// [typediagram.output]: an output path is workspace-relative and does not + /// traverse out of the tree. + #[test] + fn unsafe_output_paths_are_refused() { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")); + for (declared, detail) in [ + ("/etc/passwd.dart", "absolute path"), + ("../outside/a.dart", "leaves the workspace"), + ("lib/../../a.dart", "leaves the workspace"), + ] { + let error = format!( + "{:#}", + resolve_output(workspace, declared).expect_err(declared) + ); + assert!(error.contains("DMX8005"), "{declared}: {error}"); + assert!(error.contains(detail), "{declared}: {error}"); + } + assert_eq!( + resolve_output(workspace, "lib/models/a.dart").expect("safe path"), + workspace.join("lib").join("models").join("a.dart") + ); + assert_eq!( + resolve_output(workspace, "./lib/a.dart").expect("safe path"), + workspace.join("lib").join("a.dart") + ); + } + + /// [typediagram.output]: a group never writes over the document it was + /// read from. + #[test] + fn a_document_is_never_its_own_output() { + refuse_self_overwrite("docs/a.dmx.md", "lib/a.dart").expect("a different file"); + let error = format!( + "{:#}", + refuse_self_overwrite("docs/a.dmx.md", "docs/a.dmx.md").expect_err("self overwrite") + ); + assert!(error.contains("DMX8005"), "{error}"); + } + + /// [typediagram.output]: a path relative to the workspace is what a reader + /// of the document sees, whatever the platform separator is. + #[test] + fn paths_are_reported_the_way_the_document_writes_them() { + let workspace = PathBuf::from("/work/space"); + assert_eq!( + display_relative(&workspace, &workspace.join("lib").join("a.dart")), + "lib/a.dart" + ); + assert_eq!( + display_relative(&workspace, Path::new("docs/a.dmx.md")), + "docs/a.dmx.md" + ); + } +} diff --git a/src/dmx/src/typediagram/json.rs b/src/dmx/src/typediagram/json.rs new file mode 100644 index 0000000..b97351f --- /dev/null +++ b/src/dmx/src/typediagram/json.rs @@ -0,0 +1,226 @@ +//! The resolved model in typeDiagram's own JSON shape +//! [typediagram.delivery.baseline]. +//! +//! This is the *compatibility surface*, not the template context. It exists so +//! the Rust front end can be held to the upstream parser and model builder by a +//! differential corpus: same definition in, structurally identical model JSON +//! out. Nothing in the generation path reads it — the context builder works +//! from the model directly — so the shape is free to track upstream exactly, +//! `resolution` fields stripped, keys present only where upstream emits them. + +use serde_json::{Map, Value, json}; + +use super::ast::{Decl, Field, Signature, Targeting, TypeRef, Variant}; +use super::model::Model; + +/// The upstream model-JSON schema this build is pinned to. +pub const SCHEMA_VERSION: u64 = 1; + +/// The whole model, in upstream's `ModelJson` shape. +#[must_use] +pub fn to_json(model: &Model) -> Value { + json!({ + "version": SCHEMA_VERSION, + "decls": model.decls().iter().map(decl_json).collect::>(), + }) +} + +/// One declaration, with the keys upstream emits for its kind and no others. +fn decl_json(decl: &Decl) -> Value { + let mut out = Map::new(); + let _ = out.insert("kind".to_owned(), json!(kind_name(decl))); + let _ = out.insert("name".to_owned(), json!(decl.name())); + let _ = out.insert("generics".to_owned(), json!(decl.generics())); + match decl { + Decl::Record(record) => { + let _ = out.insert("fields".to_owned(), fields_json(&record.fields)); + } + Decl::Union(union) => { + if union.untagged { + let _ = out.insert("untagged".to_owned(), json!(true)); + } + let _ = out.insert( + "variants".to_owned(), + json!(union.variants.iter().map(variant_json).collect::>()), + ); + } + Decl::Alias(alias) => { + let _ = out.insert("target".to_owned(), ref_json(&alias.target)); + } + Decl::Function(function) => { + let _ = out.insert( + "signatures".to_owned(), + json!( + function + .signatures + .iter() + .map(signature_json) + .collect::>() + ), + ); + } + } + if let Some(targeting) = decl.targeting() { + let _ = out.insert("targeting".to_owned(), targeting_json(targeting)); + } + Value::Object(out) +} + +/// The `kind` discriminator upstream writes. +fn kind_name(decl: &Decl) -> &'static str { + match decl { + Decl::Record(_) => "record", + Decl::Union(_) => "union", + Decl::Alias(_) => "alias", + Decl::Function(_) => "function", + } +} + +/// A field list, which is also a parameter list. +fn fields_json(fields: &[Field]) -> Value { + json!( + fields + .iter() + .map(|field| json!({ "name": field.name, "type": ref_json(&field.ty) })) + .collect::>() + ) +} + +/// One variant, with `discriminant` present only where the author pinned one. +fn variant_json(variant: &Variant) -> Value { + let mut out = Map::new(); + let _ = out.insert("name".to_owned(), json!(variant.name)); + let _ = out.insert("fields".to_owned(), fields_json(&variant.fields)); + if let Some(discriminant) = &variant.discriminant { + let _ = out.insert("discriminant".to_owned(), json!(discriminant)); + } + Value::Object(out) +} + +/// One signature, with `async` present only where it was written. +fn signature_json(signature: &Signature) -> Value { + let mut out = Map::new(); + let _ = out.insert("params".to_owned(), fields_json(&signature.params)); + let _ = out.insert("returns".to_owned(), ref_json(&signature.returns)); + if signature.is_async { + let _ = out.insert("async".to_owned(), json!(true)); + } + Value::Object(out) +} + +/// A target filter, with each list present only where it was written. +fn targeting_json(targeting: &Targeting) -> Value { + let mut out = Map::new(); + if let Some(targets) = &targeting.targets { + let _ = out.insert("targets".to_owned(), json!(targets)); + } + if let Some(skipped) = &targeting.skip_targets { + let _ = out.insert("skipTargets".to_owned(), json!(skipped)); + } + Value::Object(out) +} + +/// One type reference: the name as written and its arguments, recursively. +fn ref_json(reference: &TypeRef) -> Value { + json!({ + "name": reference.name, + "args": reference.args.iter().map(ref_json).collect::>(), + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::super::model::Model; + use super::super::parser::parse; + use super::to_json; + + /// The model JSON for `source`. + fn model_json(source: &str) -> serde_json::Value { + to_json(&Model::resolve(parse(source).expect("parse")).expect("resolve")) + } + + /// [typediagram.delivery.baseline]: a record carries kind, name, generics, + /// and fields — and no `resolution`. + #[test] + fn a_record_matches_the_upstream_shape() { + assert_eq!( + model_json("type Pair { first: A, second: List }"), + json!({ + "version": 1, + "decls": [{ + "kind": "record", + "name": "Pair", + "generics": ["A", "B"], + "fields": [ + {"name": "first", "type": {"name": "A", "args": []}}, + {"name": "second", "type": {"name": "List", "args": [{"name": "B", "args": []}]}}, + ], + }], + }) + ); + } + + /// [typediagram.delivery.baseline]: optional keys appear only where the + /// author wrote them. + #[test] + fn optional_keys_are_absent_unless_written() { + assert_eq!( + model_json("untagged union U { A = -1\n B(Int)\n C }"), + json!({ + "version": 1, + "decls": [{ + "kind": "union", + "name": "U", + "generics": [], + "untagged": true, + "variants": [ + {"name": "A", "fields": [], "discriminant": "-1"}, + {"name": "B", "fields": [{"name": "_0", "type": {"name": "Int", "args": []}}]}, + {"name": "C", "fields": []}, + ], + }], + }) + ); + } + + /// [typediagram.delivery.baseline]: aliases, functions, overloads, and + /// targeting all match upstream, `async` included only where written. + #[test] + fn aliases_functions_and_targeting_match_upstream() { + assert_eq!( + model_json( + "@skipTargets(go)\nalias Email = String\nfunction read {\n (path: String) -> Bytes\n async (path: String) -> Unit\n}" + ), + json!({ + "version": 1, + "decls": [ + { + "kind": "alias", + "name": "Email", + "generics": [], + "target": {"name": "String", "args": []}, + "targeting": {"skipTargets": ["go"]}, + }, + { + "kind": "function", + "name": "read", + "generics": [], + "signatures": [ + { + "params": [{"name": "path", "type": {"name": "String", "args": []}}], + "returns": {"name": "Bytes", "args": []}, + }, + { + "params": [{"name": "path", "type": {"name": "String", "args": []}}], + "returns": {"name": "Unit", "args": []}, + "async": true, + }, + ], + }, + ], + }) + ); + } +} diff --git a/src/dmx/src/typediagram/lexer.rs b/src/dmx/src/typediagram/lexer.rs new file mode 100644 index 0000000..33369e2 --- /dev/null +++ b/src/dmx/src/typediagram/lexer.rs @@ -0,0 +1,401 @@ +//! The typeDiagram tokenizer [typediagram.model]. +//! +//! A direct port of the upstream lexer's rules, because the compatibility +//! baseline is behavioural: the same bytes must tokenize the same way here as +//! they do in the package that renders the diagram +//! [typediagram.delivery.baseline]. Newlines are tokens rather than +//! whitespace, since the grammar accepts a newline *or* a comma as a separator +//! inside a brace block. + +use super::diagnostic::{Diagnostic, Diagnostics}; + +/// What one token is. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + /// `type`. + Type, + /// `union`. + Union, + /// `untagged`. + Untagged, + /// `alias`. + Alias, + /// `function`. + Function, + /// `async`. + Async, + /// The optional `typeDiagram` file header. + Header, + /// A bare identifier. + Ident, + /// A (possibly negative, possibly `_`-grouped) integer discriminant. + Number, + /// `{`. + LBrace, + /// `}`. + RBrace, + /// `(`. + LParen, + /// `)`. + RParen, + /// `<`. + LAngle, + /// `>`. + RAngle, + /// `@`, which opens a targeting annotation. + At, + /// `,`. + Comma, + /// `:`. + Colon, + /// `=`. + Equals, + /// `->`. + Arrow, + /// A line break, which separates fields and variants. + Newline, + /// The end of the definition. + Eof, +} + +impl Kind { + /// How the token reads in a diagnostic, in the author's terms. + #[must_use] + pub fn describe(self) -> &'static str { + match self { + Self::Type => "'type'", + Self::Union => "'union'", + Self::Untagged => "'untagged'", + Self::Alias => "'alias'", + Self::Function => "'function'", + Self::Async => "'async'", + Self::Header => "'typeDiagram'", + Self::Ident => "a name", + Self::Number => "a number", + Self::LBrace => "'{'", + Self::RBrace => "'}'", + Self::LParen => "'('", + Self::RParen => "')'", + Self::LAngle => "'<'", + Self::RAngle => "'>'", + Self::At => "'@'", + Self::Comma => "','", + Self::Colon => "':'", + Self::Equals => "'='", + Self::Arrow => "'->'", + Self::Newline => "a newline", + Self::Eof => "the end of the definition", + } + } +} + +/// One token with the position it was read from. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Token { + /// What it is. + pub kind: Kind, + /// The exact source text, so a name keeps the author's spelling. + pub text: String, + /// One-based line within the definition. + pub line: usize, + /// One-based column within the line, counted in characters. + pub col: usize, + /// How many characters the token spans. + pub length: usize, +} + +impl Token { + /// How this token reads in a diagnostic: the kind, plus the spelling when + /// the kind alone does not identify it. + #[must_use] + pub fn describe(&self) -> String { + match self.kind { + Kind::Ident | Kind::Number => format!("{} \"{}\"", self.kind.describe(), self.text), + other => other.describe().to_owned(), + } + } +} + +/// The keyword a bare word turns out to be, or [`Kind::Ident`]. +fn keyword(word: &str) -> Kind { + match word { + "type" => Kind::Type, + "union" => Kind::Union, + "untagged" => Kind::Untagged, + "alias" => Kind::Alias, + "function" => Kind::Function, + "async" => Kind::Async, + "typeDiagram" => Kind::Header, + _ => Kind::Ident, + } +} + +/// The single-character token `c` is, if it is one. +fn punctuation(c: char) -> Option { + match c { + '{' => Some(Kind::LBrace), + '}' => Some(Kind::RBrace), + '(' => Some(Kind::LParen), + ')' => Some(Kind::RParen), + '<' => Some(Kind::LAngle), + '>' => Some(Kind::RAngle), + '@' => Some(Kind::At), + ',' => Some(Kind::Comma), + ':' => Some(Kind::Colon), + '=' => Some(Kind::Equals), + _ => None, + } +} + +/// Whether `c` may open an identifier. +fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +/// Whether `c` may continue one. +fn is_ident_continue(c: char) -> bool { + is_ident_start(c) || c.is_ascii_digit() +} + +/// The scanner's position in the definition. +struct Scanner { + /// Every character, so lookahead is a plain index. + chars: Vec, + /// The index of the next character to read. + next: usize, + /// The one-based line that index sits on. + line: usize, + /// The one-based column that index sits at. + col: usize, +} + +impl Scanner { + /// A scanner over `source`, positioned at its first character. + fn new(source: &str) -> Self { + Self { + chars: source.chars().collect(), + next: 0, + line: 1, + col: 1, + } + } + + /// The character `ahead` positions from here, if the source has one. + fn peek(&self, ahead: usize) -> Option { + self.chars.get(self.next.saturating_add(ahead)).copied() + } + + /// Consumes `count` characters on the current line. + fn advance(&mut self, count: usize) { + self.next = self.next.saturating_add(count); + self.col = self.col.saturating_add(count); + } + + /// Consumes a line break, wherever the next line begins. + fn newline(&mut self, count: usize) { + self.next = self.next.saturating_add(count); + self.line = self.line.saturating_add(1); + self.col = 1; + } + + /// The run of characters starting here that all satisfy `accept`. + fn run(&self, skip: usize, accept: fn(char) -> bool) -> String { + self.chars + .iter() + .skip(self.next) + .take(skip) + .chain( + self.chars + .iter() + .skip(self.next.saturating_add(skip)) + .take_while(|c| accept(**c)), + ) + .collect() + } +} + +/// Every token in `source`, or the first character that is not typeDiagram. +/// +/// # Errors +/// +/// Fails on a character the language has no meaning for, naming its position. +pub fn tokenize(source: &str) -> Result, Diagnostics> { + let mut scanner = Scanner::new(source); + let mut tokens = Vec::new(); + while let Some(c) = scanner.peek(0) { + let (line, col) = (scanner.line, scanner.col); + match c { + ' ' | '\t' => scanner.advance(1), + '\r' | '\n' => { + let width = + usize::from(c == '\r' && scanner.peek(1) == Some('\n')).saturating_add(1); + tokens.push(token(Kind::Newline, "\n".to_owned(), line, col)); + scanner.newline(width); + } + // A comment runs to the end of the line, and the line break after + // it is still a separator [typediagram.model]. + '#' => { + let comment = scanner.run(1, |c| c != '\n' && c != '\r'); + scanner.advance(comment.chars().count()); + } + _ if is_ident_start(c) => { + let word = scanner.run(1, is_ident_continue); + scanner.advance(word.chars().count()); + tokens.push(token(keyword(&word), word, line, col)); + } + '-' if scanner.peek(1) == Some('>') => { + scanner.advance(2); + tokens.push(token(Kind::Arrow, "->".to_owned(), line, col)); + } + _ if c.is_ascii_digit() + || (c == '-' && scanner.peek(1).is_some_and(|d| d.is_ascii_digit())) => + { + let skip = usize::from(c == '-').saturating_add(1); + let number = scanner.run(skip, |c| c.is_ascii_digit() || c == '_'); + scanner.advance(number.chars().count()); + tokens.push(token(Kind::Number, number, line, col)); + } + _ => match punctuation(c) { + Some(kind) => { + scanner.advance(1); + tokens.push(token(kind, c.to_string(), line, col)); + } + None => { + return Err(Diagnostics::one(Diagnostic::at( + format!("unexpected character '{c}'"), + line, + col, + 1, + ))); + } + }, + } + } + tokens.push(token(Kind::Eof, String::new(), scanner.line, scanner.col)); + Ok(tokens) +} + +/// One token, with its span taken from the text it was read from. +fn token(kind: Kind, text: String, line: usize, col: usize) -> Token { + let length = text.chars().count().max(1); + Token { + kind, + text, + line, + col, + length, + } +} + +#[cfg(test)] +mod tests { + use super::{Kind, tokenize}; + + /// The kinds `source` tokenizes to, end marker included. + fn kinds(source: &str) -> Vec { + tokenize(source) + .expect("tokenize") + .into_iter() + .map(|t| t.kind) + .collect() + } + + /// [typediagram.model]: keywords, names, and punctuation as upstream reads + /// them. + #[test] + fn reads_a_record_declaration() { + assert_eq!( + kinds("type User { id: Uuid }"), + [ + Kind::Type, + Kind::Ident, + Kind::LBrace, + Kind::Ident, + Kind::Colon, + Kind::Ident, + Kind::RBrace, + Kind::Eof, + ] + ); + } + + /// [typediagram.model]: a comment is not a token, and the newline after it + /// still separates. + #[test] + fn comments_disappear_but_their_line_breaks_do_not() { + assert_eq!( + kinds("# a comment\ntype A { }"), + [ + Kind::Newline, + Kind::Type, + Kind::Ident, + Kind::LBrace, + Kind::RBrace, + Kind::Eof, + ] + ); + assert_eq!( + kinds("type A { x: Int # trailing\n}"), + [ + Kind::Type, + Kind::Ident, + Kind::LBrace, + Kind::Ident, + Kind::Colon, + Kind::Ident, + Kind::Newline, + Kind::RBrace, + Kind::Eof, + ] + ); + } + + /// [typediagram.model]: CRLF is one line break, and the position after it + /// is the start of the next line. + #[test] + fn crlf_is_a_single_newline() { + let tokens = tokenize("type A\r\ntype B").expect("tokenize"); + assert_eq!(tokens[2].kind, Kind::Newline); + assert_eq!(tokens[3].kind, Kind::Type); + assert_eq!((tokens[3].line, tokens[3].col), (2, 1)); + } + + /// [typediagram.model]: discriminants may be negative and `_`-grouped. + #[test] + fn numbers_carry_sign_and_grouping() { + let tokens = tokenize("= -32_700").expect("tokenize"); + assert_eq!(tokens[1].kind, Kind::Number); + assert_eq!(tokens[1].text, "-32_700"); + assert_eq!(tokens[1].length, 7); + } + + /// `->` is one token; a bare `-` that starts nothing is a lexical error. + #[test] + fn the_arrow_is_one_token_and_a_stray_dash_is_not() { + assert_eq!(kinds("-> Bytes"), [Kind::Arrow, Kind::Ident, Kind::Eof]); + let error = tokenize("type A - B").expect_err("a stray dash is not typeDiagram"); + assert_eq!(error.0[0].col, 8); + assert!(error.to_string().contains("unexpected character '-'")); + } + + /// A name that merely starts with a keyword is still a name. + #[test] + fn keywords_are_whole_words() { + assert_eq!( + kinds("typeName aliasing"), + [Kind::Ident, Kind::Ident, Kind::Eof] + ); + assert_eq!( + kinds("untagged union"), + [Kind::Untagged, Kind::Union, Kind::Eof] + ); + } + + /// Columns count characters, so a diagnostic points at the right glyph + /// even after non-ASCII text in a comment. + #[test] + fn columns_count_characters_not_bytes() { + let error = tokenize("# héllo →\ntype A { x: Int }\n%").expect_err("stray percent"); + assert_eq!((error.0[0].line, error.0[0].col), (3, 1)); + } +} diff --git a/src/dmx/src/typediagram/markdown.rs b/src/dmx/src/typediagram/markdown.rs new file mode 100644 index 0000000..12fe8c9 --- /dev/null +++ b/src/dmx/src/typediagram/markdown.rs @@ -0,0 +1,496 @@ +//! Binding typeDiagram definitions to Mustache templates inside a Markdown +//! document [typediagram.binding]. +//! +//! The document is read as a `CommonMark` AST and never as text: a fence is a +//! node, its info string is that node's, and adjacency is a fact about the +//! node list rather than about how many blank lines somebody left. Prose, +//! headings, quotes, lists, and unrelated fences are documentation, and +//! nothing here can rewrite them — this module only reads. +//! +//! A group is one `typeDiagram` fence followed immediately by one or more +//! dmx-enabled `mustache` fences. Everything else in the document is ignored, +//! which is what keeps an ordinary typeDiagram document renderable by the +//! tooling that has always rendered it. + +use anyhow::{Result, bail}; +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +use serde_json::Value; + +/// The default generation target when a template does not name one. +pub const DEFAULT_TARGET: &str = "dart"; + +/// The info-string language that opens a definition, compared case-insensitively. +const DEFINITION_LANGUAGE: &str = "typediagram"; + +/// The info-string language a bound template uses. +const TEMPLATE_LANGUAGE: &str = "mustache"; + +/// One fenced code block dmx looked at [typediagram.documents]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fence { + /// Its one-based position among the document's top-level fenced blocks. + pub ordinal: usize, + /// The one-based document line its opening marker sits on. + pub line: usize, + /// Its content, exactly as `CommonMark` reads it. + pub body: String, +} + +/// A template fence bound to the definition above it [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoundTemplate { + /// The fence itself. + pub fence: Fence, + /// The workspace-relative output path, as the author wrote it. + pub output: String, + /// The generation target, defaulting to [`DEFAULT_TARGET`]. + pub target: String, +} + +/// One definition and every template bound to it [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Group { + /// Its one-based position among the document's generation groups. + pub ordinal: usize, + /// The typeDiagram fence. + pub definition: Fence, + /// The templates it generates through, in document order. + pub templates: Vec, +} + +/// Every generation group in `source`, in document order. +/// +/// # Errors +/// +/// Fails on malformed fence metadata (`DMX8001`), a bound template with no +/// definition above it (`DMX8002`), or two templates claiming one output path +/// (`DMX8003`). +pub fn groups(source: &str) -> Result> { + let nodes = top_level_fences(source)?; + let mut groups: Vec = Vec::new(); + let mut index = 0usize; + while let Some(node) = nodes.get(index) { + index = index.saturating_add(1); + match node { + Node::Definition(definition) => { + let mut templates = Vec::new(); + while let Some(Node::Template(template)) = nodes.get(index) { + templates.push(template.clone()); + index = index.saturating_add(1); + } + if !templates.is_empty() { + groups.push(Group { + ordinal: groups.len().saturating_add(1), + definition: definition.clone(), + templates, + }); + } + } + // A bound template with nothing above it has no model to render, + // and guessing which definition it meant is exactly the implicit + // global state [typediagram.binding] forbids. + Node::Template(template) => bail!( + "DMX8002 [typediagram.binding]: the Mustache template on line {} generating \ + `{}` is not bound to a typeDiagram definition; put a ```typeDiagram fence \ + immediately above it", + template.fence.line, + template.output + ), + Node::Other => {} + } + } + refuse_duplicate_outputs(&groups)?; + Ok(groups) +} + +/// Refuses two templates that would write the same file [typediagram.binding]. +fn refuse_duplicate_outputs(groups: &[Group]) -> Result<()> { + let mut seen: Vec<(&str, usize)> = Vec::new(); + for group in groups { + for template in &group.templates { + match seen.iter().find(|(path, _)| *path == template.output) { + Some((path, line)) => bail!( + "DMX8003 [typediagram.binding]: the templates on lines {line} and {} both \ + generate `{path}`; one output has one template", + template.fence.line + ), + None => seen.push((&template.output, template.fence.line)), + } + } + } + Ok(()) +} + +/// What one top-level fenced block turned out to be. +#[derive(Clone, Debug)] +enum Node { + /// A renderable typeDiagram definition. + Definition(Fence), + /// A Mustache fence carrying dmx metadata. + Template(BoundTemplate), + /// Anything else: another language, an example, ordinary prose. + Other, +} + +/// Every top-level block in `source`, classified, in document order. +/// +/// A block nested inside a list item or a quote is not in this sequence: its +/// *container* is, as [`Node::Other`]. That is the reading [typediagram.binding] +/// asks for — adjacency is a property of the document's own structure — and it +/// keeps a fence quoted inside an explanation from binding to anything. +/// +/// # Errors +/// +/// Fails when a Mustache fence carries metadata that was meant to be dmx's and +/// is not usable (`DMX8001`). +fn top_level_fences(source: &str) -> Result> { + let starts = line_starts(source); + let mut nodes = Vec::new(); + let mut depth = 0usize; + let mut fences = 0usize; + let mut open: Option<(String, usize, usize)> = None; + let mut body = String::new(); + for (event, range) in Parser::new_ext(source, Options::empty()).into_offset_iter() { + match event { + Event::Start(tag) => { + if depth == 0 { + match info_string(&tag) { + Some(info) => { + fences = fences.saturating_add(1); + open = Some((info, fences, line_of(&starts, range.start))); + body.clear(); + } + None => nodes.push(Node::Other), + } + } + depth = depth.saturating_add(1); + } + Event::End(end) => { + depth = depth.saturating_sub(1); + if depth == 0 + && end == TagEnd::CodeBlock + && let Some((info, ordinal, line)) = open.take() + { + let fence = Fence { + ordinal, + line, + body: std::mem::take(&mut body), + }; + nodes.push(classify(&info, fence)?); + } + } + Event::Text(text) if open.is_some() && depth == 1 => body.push_str(&text), + Event::Rule if depth == 0 => nodes.push(Node::Other), + _ => {} + } + } + Ok(nodes) +} + +/// The info string of a fenced code block, or `None` for anything else — an +/// indented code block included, since it has no info string to bind with. +fn info_string(tag: &Tag<'_>) -> Option { + match tag { + Tag::CodeBlock(CodeBlockKind::Fenced(info)) => Some(info.to_string()), + _ => None, + } +} + +/// What a fence with this info string is. +/// +/// # Errors +/// +/// Fails when the fence's metadata was meant to be dmx's and cannot be used +/// (`DMX8001`). +fn classify(info: &str, fence: Fence) -> Result { + let (language, meta) = split_info(info); + match () { + // The definition fence stays exactly what typeDiagram's own Markdown + // tooling renders: the bare language, nothing after it + // [typediagram.documents]. + () if language.eq_ignore_ascii_case(DEFINITION_LANGUAGE) && meta.is_empty() => { + Ok(Node::Definition(fence)) + } + () if language.eq_ignore_ascii_case(TEMPLATE_LANGUAGE) => { + Ok(match binding(meta, &fence)? { + Some(template) => Node::Template(template), + None => Node::Other, + }) + } + () => Ok(Node::Other), + } +} + +/// The dmx binding a Mustache fence declares, or `None` when it declares none. +/// +/// Metadata that does not open with `{` belongs to somebody else's convention +/// and is left alone. Metadata that does is dmx's to read: a JSON object +/// without a `dmx` key is an ordinary example, and anything else is a mistake +/// worth reporting rather than silently generating nothing +/// [typediagram.binding]. +/// +/// # Errors +/// +/// Fails when the metadata is not a JSON object, when `dmx` is not an object, +/// when `output` is missing or empty, or when an unrecognised key appears — +/// all `DMX8001`. +fn binding(meta: &str, fence: &Fence) -> Result> { + if !meta.starts_with('{') { + return Ok(None); + } + let fault = |detail: &str| { + anyhow::anyhow!( + "DMX8001 [typediagram.binding]: the Mustache fence on line {} has unusable dmx \ + metadata: {detail}\n\n ```mustache {{\"dmx\": {{\"output\": \"lib/models.dart\"}}}}", + fence.line + ) + }; + let Ok(Value::Object(metadata)) = serde_json::from_str::(meta) else { + return Err(fault("it is not a JSON object")); + }; + let Some(dmx) = metadata.get("dmx") else { + return Ok(None); + }; + let Value::Object(dmx) = dmx else { + return Err(fault("`dmx` is not an object")); + }; + if let Some(unknown) = dmx.keys().find(|key| !DMX_KEYS.contains(&key.as_str())) { + return Err(fault(&format!( + "`dmx.{unknown}` is not a setting dmx knows" + ))); + } + let output = match dmx.get("output") { + Some(Value::String(output)) if !output.trim().is_empty() => output.trim().to_owned(), + _ => return Err(fault("`dmx.output` must be a non-empty output path")), + }; + let target = match dmx.get("target") { + None => DEFAULT_TARGET.to_owned(), + Some(Value::String(target)) if !target.trim().is_empty() => target.trim().to_owned(), + Some(_) => return Err(fault("`dmx.target` must be a target name")), + }; + Ok(Some(BoundTemplate { + fence: fence.clone(), + output, + target, + })) +} + +/// Every key a `dmx` metadata object may carry. +const DMX_KEYS: &[&str] = &["output", "target"]; + +/// The language and the metadata halves of an info string. +fn split_info(info: &str) -> (&str, &str) { + match info.trim().split_once(char::is_whitespace) { + Some((language, meta)) => (language, meta.trim()), + None => (info.trim(), ""), + } +} + +/// The byte offset every line in `source` begins at. +fn line_starts(source: &str) -> Vec { + std::iter::once(0) + .chain( + source + .bytes() + .enumerate() + .filter(|(_, byte)| *byte == b'\n') + .map(|(index, _)| index.saturating_add(1)), + ) + .collect() +} + +/// The one-based line `offset` sits on. +fn line_of(starts: &[usize], offset: usize) -> usize { + starts.partition_point(|start| *start <= offset).max(1) +} + +#[cfg(test)] +mod tests { + use super::{DEFAULT_TARGET, groups}; + + /// A document with `body` between two ordinary paragraphs, so every test + /// also proves prose neither binds nor breaks. + fn document(body: &str) -> String { + format!("# Models\n\nSome prose.\n\n{body}\n\nMore prose.\n") + } + + /// The canonical one-definition, one-template document. + const ONE: &str = "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nclass {{name}} {}\n```"; + + /// [typediagram.binding]: one definition binds to the template below it, + /// and the fence bodies arrive exactly as written. + #[test] + fn a_definition_binds_to_the_template_below_it() { + let found = groups(&document(ONE)).expect("bind"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].ordinal, 1); + assert_eq!(found[0].definition.body, "type A { x: Int }\n"); + assert_eq!(found[0].definition.ordinal, 1); + assert_eq!(found[0].templates.len(), 1); + assert_eq!(found[0].templates[0].output, "lib/a.dart"); + assert_eq!(found[0].templates[0].target, DEFAULT_TARGET); + assert_eq!(found[0].templates[0].fence.body, "class {{name}} {}\n"); + assert_eq!(found[0].templates[0].fence.ordinal, 2); + } + + /// [typediagram.binding]: one definition may feed several templates, and a + /// blank line between fences is not a node. + #[test] + fn one_definition_feeds_several_templates() { + let found = groups(&document( + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/b.dart\",\"target\":\"dart\"}}\nb\n```", + )) + .expect("bind"); + assert_eq!(found.len(), 1); + assert_eq!( + found[0] + .templates + .iter() + .map(|t| t.output.as_str()) + .collect::>(), + ["lib/a.dart", "lib/b.dart"] + ); + } + + /// [typediagram.binding]: prose between the fences ends the group, so the + /// template below it is an orphan rather than a silent rebinding. + #[test] + fn any_other_node_ends_the_group() { + let error = groups(&document( + "```typeDiagram\ntype A { x: Int }\n```\n\nA note.\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```", + )) + .expect_err("prose ends the group"); + assert!(format!("{error:#}").contains("DMX8002"), "{error:#}"); + } + + /// [typediagram.binding]: a definition nobody templates is documentation, + /// and a Mustache fence with no dmx metadata is an example. + #[test] + fn documentation_only_fences_generate_nothing() { + assert!( + groups(&document("```typeDiagram\ntype A { x: Int }\n```")) + .expect("bind") + .is_empty() + ); + assert!( + groups(&document( + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache\n{{name}}\n```" + )) + .expect("bind") + .is_empty() + ); + assert!( + groups(&document( + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"other\":true}\n{{name}}\n```" + )) + .expect("bind") + .is_empty() + ); + assert!( + groups(&document("```dart\nclass A {}\n```")) + .expect("bind") + .is_empty() + ); + } + + /// [typediagram.documents]: dmx metadata on the definition fence would + /// stop typeDiagram's own tooling rendering it, so such a fence is not a + /// definition at all. + #[test] + fn a_definition_fence_carries_no_metadata() { + assert!( + groups(&document( + "```typeDiagram {\"dmx\":{}}\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```" + )) + .expect_err("an annotated definition fence binds nothing") + .to_string() + .contains("DMX8002") + ); + } + + /// [typediagram.binding]: metadata meant for dmx is held to its shape, + /// rather than silently generating nothing. + #[test] + fn unusable_dmx_metadata_is_refused() { + for (meta, detail) in [ + ("{\"dmx\": }", "it is not a JSON object"), + ("{\"dmx\": \"lib/a.dart\"}", "`dmx` is not an object"), + ( + "{\"dmx\": {}}", + "`dmx.output` must be a non-empty output path", + ), + ( + "{\"dmx\": {\"output\": \" \"}}", + "`dmx.output` must be a non-empty output path", + ), + ( + "{\"dmx\": {\"output\": 7}}", + "`dmx.output` must be a non-empty output path", + ), + ( + "{\"dmx\": {\"output\": \"a.dart\", \"target\": 7}}", + "`dmx.target` must be a target name", + ), + ( + "{\"dmx\": {\"output\": \"a.dart\", \"ouput\": \"typo\"}}", + "`dmx.ouput` is not a setting dmx knows", + ), + ] { + let source = document(&format!( + "```typeDiagram\ntype A {{ x: Int }}\n```\n\n```mustache {meta}\na\n```" + )); + let error = format!("{:#}", groups(&source).expect_err(meta)); + assert!(error.contains("DMX8001"), "{meta}: {error}"); + assert!(error.contains(detail), "{meta}: {error}"); + } + } + + /// [typediagram.binding]: two templates may not claim one path. + #[test] + fn one_output_has_one_template() { + let error = groups(&document( + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nb\n```", + )) + .expect_err("duplicate output"); + assert!(format!("{error:#}").contains("DMX8003"), "{error:#}"); + } + + /// [typediagram.binding]: longer fences, CRLF, Unicode prose, and several + /// independent groups all read the same. + #[test] + fn longer_fences_crlf_and_several_groups_all_bind() { + let source = "# Título\r\n\r\n````typeDiagram\r\ntype A { x: Int }\r\n````\r\n\r\n````mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\r\na — é\r\n````\r\n\r\n> quoted\r\n\r\n```typeDiagram\r\ntype B { y: Int }\r\n```\r\n\r\n```mustache {\"dmx\":{\"output\":\"lib/b.dart\"}}\r\nb\r\n```\r\n"; + let found = groups(source).expect("bind"); + assert_eq!(found.len(), 2); + assert_eq!(found[1].ordinal, 2); + assert_eq!(found[0].templates[0].fence.body, "a — é\n"); + assert_eq!(found[1].definition.body, "type B { y: Int }\n"); + assert_eq!(found[1].definition.ordinal, 3); + } + + /// [typediagram.binding]: a fence quoted inside a container is part of the + /// explanation, not of any group. + #[test] + fn a_nested_fence_binds_to_nothing() { + let source = "- an example:\n\n ```typeDiagram\n type A { x: Int }\n ```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n"; + assert!( + format!( + "{:#}", + groups(source).expect_err("the nested fence binds nothing") + ) + .contains("DMX8002") + ); + } + + /// [typediagram.diagnostics]: the reported line is the document line the + /// offending fence opens on. + #[test] + fn diagnostics_name_the_document_line() { + let error = format!( + "{:#}", + groups("intro\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n") + .expect_err("orphan") + ); + assert!(error.contains("line 3"), "{error}"); + } +} diff --git a/src/dmx/src/typediagram/mod.rs b/src/dmx/src/typediagram/mod.rs new file mode 100644 index 0000000..2e591f6 --- /dev/null +++ b/src/dmx/src/typediagram/mod.rs @@ -0,0 +1,201 @@ +//! The built-in `typeDiagram` macro [typediagram]. +//! +//! typeDiagram definitions plus Mustache templates equal generated code. The +//! definitions live in an ordinary Markdown document that typeDiagram's own +//! tooling still renders; the templates live beside them; dmx owns everything +//! in between — parsing, resolution, context, rendering, validation, and safe +//! emission — and never runs typeDiagram's CLI, library, or language emitters +//! [typediagram.delivery.baseline]. +//! +//! The pipeline is the ordinary one. The Markdown front end synthesizes one +//! [`Invocation`] per generation group and dispatches it through the same +//! macro registry an `@dmx('model')` annotation goes through +//! [typediagram.macro]; what comes back is whole files, emitted by the same +//! ownership protocol a Dart-authored macro's siblings use [dartmacros.files]. + +pub mod ast; +pub mod context; +pub mod diagnostic; +#[cfg(not(target_arch = "wasm32"))] +pub mod document; +#[cfg(not(target_arch = "wasm32"))] +pub mod emit; +pub mod json; +pub mod lexer; +pub mod markdown; +pub mod model; +pub mod parser; +pub mod target; + +use anyhow::Result; + +use diagnostic::Diagnostics; +use markdown::{BoundTemplate, Group}; +use model::Model; + +/// The file-name suffix that makes a Markdown document one dmx generates from +/// [typediagram.documents]. +pub const DOCUMENT_SUFFIX: &str = ".dmx.md"; + +/// One synthesized `typeDiagram` macro invocation [typediagram.macro]. +/// +/// This is what the Markdown front end hands the registry, and it is +/// deliberately the *whole* input: the document it came from, the group's +/// fences and their bound outputs, and the resolved model. A macro that +/// received less would have to go back to the document, which is how a second +/// rendering path starts. +#[derive(Clone, Copy, Debug)] +pub struct Invocation<'a> { + /// The document's path as a reader of it would write it. + pub document: &'a str, + /// The definition fence and every template bound to it. + pub group: &'a Group, + /// The definition, parsed and resolved. + pub model: &'a Model, +} + +/// Whether `path` is a Markdown document dmx generates from +/// [typediagram.documents]. +/// +/// Recursive discovery takes `*.dmx.md` and nothing else; a Markdown file named +/// explicitly on the command line is accepted whatever it is called, which is +/// [`is_markdown`]'s job. +#[must_use] +pub fn is_document(path: &std::path::Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(DOCUMENT_SUFFIX)) +} + +/// Whether `path` is a Markdown file at all. +#[must_use] +pub fn is_markdown(path: &std::path::Path) -> bool { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) +} + +/// The first line of every output a document owns — the shared ownership +/// marker, so the same predicate that protects a hand-written sibling from a +/// Dart macro protects one from a document [dartmacros.files]. +#[must_use] +pub fn ownership_marker(document: &str) -> String { + crate::emit::file_marker(document) +} + +/// The second line: which group, which fences, and the content that produced +/// the file [typediagram.output]. +/// +/// The digests are what make drift visible without reading the whole document. +/// A definition or template edit changes them; prose outside the group does +/// not, which is exactly the dependency rule [typediagram.execution] states. +#[must_use] +pub fn identity_line(group: &Group, template: &BoundTemplate) -> String { + format!( + "// dmx: group {}, fences {}/{}, definition {}, template {}, context v{}, dmx {}.", + group.ordinal, + group.definition.ordinal, + template.fence.ordinal, + digest(&group.definition.body), + digest(&template.fence.body), + context::CONTEXT_VERSION, + crate::VERSION, + ) +} + +/// A short, stable content digest. +#[must_use] +pub fn digest(content: &str) -> String { + blake3::hash(content.as_bytes()) + .to_hex() + .chars() + .take(16) + .collect() +} + +/// The complete text of one output: the two marker lines, then the render. +#[must_use] +pub fn file_text(document: &str, group: &Group, template: &BoundTemplate, body: &str) -> String { + format!( + "{}\n{}\n\n{body}\n", + ownership_marker(document), + identity_line(group, template) + ) +} + +/// The resolved model for one group's definition fence. +/// +/// # Errors +/// +/// Fails (`DMX8004`) when the definition does not tokenize, parse, or resolve, +/// with every position rebased onto the document so the reported line is the +/// one the author's editor shows. +pub fn resolve(document: &str, group: &Group) -> Result { + let fault = |found: Diagnostics| { + anyhow::anyhow!( + "DMX8004 [typediagram.model]: the typeDiagram definition in {document} (fence {}, \ + line {}) is not valid:\n{}", + group.definition.ordinal, + group.definition.line, + found.in_document(group.definition.line) + ) + }; + let diagram = parser::parse(&group.definition.body).map_err(fault)?; + Model::resolve(diagram).map_err(fault) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::markdown::groups; + use super::{DOCUMENT_SUFFIX, file_text, is_document, is_markdown, resolve}; + + /// [typediagram.documents]: recursive discovery takes `*.dmx.md`; every + /// other Markdown file is documentation until somebody names it. + #[test] + fn only_dmx_markdown_is_discovered() { + assert!(is_document(Path::new("docs/models.dmx.md"))); + assert!(!is_document(Path::new("docs/README.md"))); + assert!(!is_document(Path::new("docs/models.dmx.markdown"))); + assert!(is_markdown(Path::new("docs/README.md"))); + assert!(is_markdown(Path::new("docs/README.MD"))); + assert!(!is_markdown(Path::new("lib/a.dart"))); + assert!(is_markdown(Path::new(&format!("a{DOCUMENT_SUFFIX}")))); + } + + /// [typediagram.model]: a definition fault is reported at the line the + /// author's editor shows, not at a fence-relative one. + #[test] + fn definition_faults_are_reported_in_document_lines() { + let document = "# Models\n\nprose\n\n```typeDiagram\ntype A { x: Int }\ntype B { y }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n"; + let bound = groups(document).expect("bind"); + let error = format!( + "{:#}", + resolve("docs/a.dmx.md", &bound[0]).expect_err("bad definition") + ); + assert!(error.contains("DMX8004"), "{error}"); + assert!(error.contains("docs/a.dmx.md"), "{error}"); + // The fence opens on line 5, so its second definition line is line 7. + assert!(error.contains("line 7, column 12"), "{error}"); + } + + /// [typediagram.output]: the marker lines identify the document, the + /// fences, and the content — and the body follows them exactly once. + #[test] + fn the_file_text_carries_both_marker_lines() { + let bound = groups("```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n") + .expect("bind"); + let text = file_text( + "docs/a.dmx.md", + &bound[0], + &bound[0].templates[0], + "final class A {}", + ); + let lines: Vec<&str> = text.lines().collect(); + assert!(lines[0].contains("docs/a.dmx.md")); + assert!(lines[1].contains("group 1, fences 1/2")); + assert!(lines[1].contains("context v1")); + assert_eq!(lines[2], ""); + assert_eq!(lines[3], "final class A {}"); + assert!(text.ends_with('\n')); + } +} diff --git a/src/dmx/src/typediagram/model.rs b/src/dmx/src/typediagram/model.rs new file mode 100644 index 0000000..c73c72c --- /dev/null +++ b/src/dmx/src/typediagram/model.rs @@ -0,0 +1,343 @@ +//! Name resolution and validation for a parsed definition [typediagram.model]. +//! +//! Resolution answers one question per type reference — generic parameter, +//! declared type, primitive, or external — in the order upstream answers it, +//! because a declared name deliberately shadows a built-in so that +//! `alias Uuid = String` keeps meaning what it meant before the semantic +//! scalars existed [typediagram.delivery.baseline]. +//! +//! Validation is deliberately in two halves. Structural validation is what +//! *any* consumer of the model needs — duplicate declarations, generic arity — +//! and matches upstream exactly. Generation validation is stricter: a name +//! that resolves to nothing renders as a diagram but cannot become code, so +//! [`Model::validate_for_target`] refuses it before a template ever runs. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::ast::{Decl, Diagram, Field, Span, TypeRef}; +use super::diagnostic::{Diagnostic, Diagnostics}; + +/// The scalar names typeDiagram always understands, semantic scalars included. +pub const PRIMITIVES: &[&str] = &[ + "Bool", "Int", "Float", "String", "Bytes", "Unit", "DateTime", "Uuid", "Decimal", +]; + +/// The generic names every converter understands without a declaration. +pub const BUILTIN_GENERICS: &[&str] = &["List", "Map", "Option", "Any"]; + +/// What one type reference turned out to name. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Resolution { + /// A generic parameter of the declaration it was written in; the + /// reference's own name is the parameter. + TypeParam, + /// Another declaration in the same definition. + Declared(String), + /// One of [`PRIMITIVES`]. + Primitive, + /// A name this definition does not declare. + External, +} + +/// A definition with every reference resolved [typediagram.model]. +/// +/// The declarations are the parsed ones, unchanged and in source order: the +/// resolution table is keyed by position rather than folded into the tree, so +/// nothing here can reorder or drop what the author wrote. +#[derive(Clone, Debug)] +pub struct Model { + /// The declarations, in source order. + decls: Vec, + /// What each reference resolves to, keyed by its position in the source. + resolutions: BTreeMap<(usize, usize), Resolution>, +} + +impl Model { + /// Resolves and structurally validates `diagram`. + /// + /// # Errors + /// + /// Fails on a duplicate declaration or a generic-arity mismatch, reporting + /// every one it found. + pub fn resolve(diagram: Diagram) -> Result { + let mut found = Diagnostics::default(); + let mut arity: BTreeMap<&str, usize> = BTreeMap::new(); + for decl in &diagram.decls { + if arity.insert(decl.name(), decl.generics().len()).is_some() { + found.0.push(diagnostic( + format!("duplicate declaration '{}'", decl.name()), + decl.span(), + )); + } + } + + let mut resolutions = BTreeMap::new(); + for decl in &diagram.decls { + let generics: BTreeSet<&str> = decl.generics().iter().map(String::as_str).collect(); + for reference in references(decl) { + resolve_reference(reference, &generics, &arity, &mut resolutions, &mut found); + } + } + + if found.is_empty() { + return Ok(Self { + decls: diagram.decls, + resolutions, + }); + } + Err(found) + } + + /// The declarations, exactly once each and in source order. + #[must_use] + pub fn decls(&self) -> &[Decl] { + &self.decls + } + + /// The declarations `target` generates from — everything, minus what a + /// `@targets` / `@skipTargets` annotation excludes. + pub fn visible(&self, target: &str) -> impl Iterator { + self.decls + .iter() + .filter(move |decl| decl.targeting().is_none_or(|t| t.admits(target))) + } + + /// What `reference` names. An unrecorded position cannot occur for a + /// reference taken from this model's own declarations, and reading it as + /// external is the answer that changes nothing. + #[must_use] + pub fn resolution(&self, reference: &TypeRef) -> &Resolution { + self.resolutions + .get(&(reference.span.line, reference.span.col)) + .unwrap_or(&Resolution::External) + } + + /// Refuses references `target` cannot turn into code [typediagram.model]. + /// + /// A diagram renders an unknown name as inline text. Generation cannot: + /// the name would reach the output verbatim and produce source that does + /// not compile, which is the worst thing this pipeline can emit. + /// + /// # Errors + /// + /// Fails naming every unresolvable reference, once each. + pub fn validate_for_target(&self, target: &str) -> Result<(), Diagnostics> { + let mut found = Diagnostics::default(); + let mut reported: BTreeSet<&str> = BTreeSet::new(); + for decl in self.visible(target) { + for reference in references(decl) { + let unknown = matches!(self.resolution(reference), Resolution::External) + && !BUILTIN_GENERICS.contains(&reference.name.as_str()) + && reported.insert(reference.name.as_str()); + if unknown { + found.0.push(diagnostic( + format!( + "unknown type '{}': not a primitive, a built-in, or a declared type", + reference.name + ), + reference.span, + )); + } + } + } + if found.is_empty() { + return Ok(()); + } + Err(found) + } +} + +/// Records what one reference names, and complains about a wrong arity on a +/// declared one exactly where upstream does. +fn resolve_reference( + reference: &TypeRef, + generics: &BTreeSet<&str>, + arity: &BTreeMap<&str, usize>, + resolutions: &mut BTreeMap<(usize, usize), Resolution>, + found: &mut Diagnostics, +) { + let name = reference.name.as_str(); + let resolution = match (generics.contains(name), arity.get(name)) { + (true, _) => Resolution::TypeParam, + (false, Some(&expected)) => { + if reference.args.len() != expected { + found.0.push(diagnostic( + format!( + "type '{name}' takes {expected} type argument(s), got {}", + reference.args.len() + ), + reference.span, + )); + } + Resolution::Declared(name.to_owned()) + } + (false, None) if PRIMITIVES.contains(&name) => Resolution::Primitive, + (false, None) => Resolution::External, + }; + let _ = resolutions.insert((reference.span.line, reference.span.col), resolution); +} + +/// Every type reference a declaration contains, nested arguments included, in +/// source order. +/// +/// One walk serves resolution, generation validation, and the context builder, +/// so no consumer can accidentally look at a different set of references than +/// another one did. +#[must_use] +pub fn references(decl: &Decl) -> Vec<&TypeRef> { + let mut out = Vec::new(); + match decl { + Decl::Record(record) => push_fields(&record.fields, &mut out), + Decl::Union(union) => { + for variant in &union.variants { + push_fields(&variant.fields, &mut out); + } + } + Decl::Alias(alias) => push_ref(&alias.target, &mut out), + Decl::Function(function) => { + for signature in &function.signatures { + push_fields(&signature.params, &mut out); + push_ref(&signature.returns, &mut out); + } + } + } + out +} + +/// Adds every reference in `fields`, in order. +fn push_fields<'a>(fields: &'a [Field], out: &mut Vec<&'a TypeRef>) { + for field in fields { + push_ref(&field.ty, out); + } +} + +/// Adds `reference` and everything nested inside it, outermost first. +fn push_ref<'a>(reference: &'a TypeRef, out: &mut Vec<&'a TypeRef>) { + out.push(reference); + for arg in &reference.args { + push_ref(arg, out); + } +} + +/// One diagnostic anchored at `span`. +fn diagnostic(message: String, span: Span) -> Diagnostic { + Diagnostic::at(message, span.line, span.col, span.length) +} + +#[cfg(test)] +mod tests { + use super::super::ast::Decl; + use super::super::parser::parse; + use super::{Model, Resolution, references}; + + /// The model `source` resolves to. + fn resolved(source: &str) -> Model { + Model::resolve(parse(source).expect("parse")).expect("resolve") + } + + /// The resolutions of every reference in the first declaration. + fn first_resolutions(source: &str) -> Vec { + let model = resolved(source); + references(&model.decls()[0]) + .into_iter() + .map(|reference| model.resolution(reference).clone()) + .collect() + } + + /// [typediagram.model]: generic parameter, then declared, then primitive, + /// then external — the order that lets a declaration shadow a built-in. + #[test] + fn resolution_follows_the_upstream_precedence() { + assert_eq!( + first_resolutions("type A { a: T\n b: B\n c: Int\n d: Mystery }\ntype B { x: Int }"), + [ + Resolution::TypeParam, + Resolution::Declared("B".to_owned()), + Resolution::Primitive, + Resolution::External, + ] + ); + } + + /// [typediagram.model]: a declared name wins over a built-in scalar, so a + /// pre-scalar diagram keeps its meaning. + #[test] + fn a_declaration_shadows_a_primitive() { + assert_eq!( + first_resolutions("type A { id: Uuid }\nalias Uuid = String"), + [Resolution::Declared("Uuid".to_owned())] + ); + assert_eq!( + first_resolutions("type A { id: Uuid }"), + [Resolution::Primitive] + ); + } + + /// [typediagram.model]: nested arguments resolve individually. + #[test] + fn nested_arguments_each_resolve() { + assert_eq!( + first_resolutions("type A { m: Map> }\ntype B { x: Int }"), + [ + Resolution::External, + Resolution::Primitive, + Resolution::External, + Resolution::Declared("B".to_owned()), + ] + ); + } + + /// [typediagram.model]: duplicates and arity mismatches are refused, all + /// of them at once. + #[test] + fn structural_faults_are_reported_together() { + let error = Model::resolve( + parse("type A { }\ntype A { }\ntype C { x: T }\ntype D { c: C }").expect("parse"), + ) + .expect_err("two structural faults"); + let text = error.to_string(); + assert!(text.contains("duplicate declaration 'A'"), "{text}"); + assert!( + text.contains("type 'C' takes 1 type argument(s), got 0"), + "{text}" + ); + } + + /// [typediagram.model]: an unknown name fails before rendering, once, with + /// its position — and the container built-ins are not unknown. + #[test] + fn generation_refuses_unresolvable_names() { + let model = resolved("type A { a: Timestamp\n b: Timestamp\n c: List\n d: Any }"); + let error = model + .validate_for_target("dart") + .expect_err("two unknown names"); + assert_eq!(error.0.len(), 2, "{error}"); + assert!(error.0[0].message.contains("unknown type 'Timestamp'")); + assert_eq!((error.0[0].line, error.0[0].col), (1, 13)); + assert!(error.0[1].message.contains("unknown type 'Instant'")); + + resolved("type A { a: List\n b: Map\n c: Option }") + .validate_for_target("dart") + .expect("container built-ins are known"); + } + + /// [typediagram.model]: a declaration another target owns is neither + /// generated nor validated for this one. + #[test] + fn targeting_hides_a_declaration_from_generation() { + let model = resolved("@skipTargets(dart)\ntype A { a: Timestamp }\ntype B { b: Int }"); + model + .validate_for_target("dart") + .expect("the skipped declaration is not this target's problem"); + assert_eq!( + model.visible("dart").map(Decl::name).collect::>(), + ["B"] + ); + assert_eq!( + model.decls().len(), + 2, + "nothing is discarded from the model" + ); + assert!(model.visible("go").any(|decl| decl.name() == "A")); + } +} diff --git a/src/dmx/src/typediagram/parser.rs b/src/dmx/src/typediagram/parser.rs new file mode 100644 index 0000000..bdc5ce3 --- /dev/null +++ b/src/dmx/src/typediagram/parser.rs @@ -0,0 +1,409 @@ +//! The typeDiagram parser [typediagram.model]. +//! +//! The published grammar is LL(1) with six productions, so this is a cursor +//! over the token stream and one function per production — no table, no +//! backtracking, no regular expression anywhere near the source. +//! +//! Unlike the upstream parser this one stops at the first error rather than +//! recovering to the next declaration. A definition that does not parse +//! generates nothing either way, and one precise position beats a cascade of +//! consequences [typediagram.diagnostics]. + +use super::ast::{ + Alias, Decl, Diagram, Field, Function, Record, Signature, Span, Targeting, TypeRef, Union, + Variant, +}; +use super::diagnostic::{Diagnostic, Diagnostics}; +use super::lexer::{Kind, Token, tokenize}; + +/// Parses one typeDiagram definition. +/// +/// # Errors +/// +/// Fails on the first token the grammar has no production for, naming what was +/// expected and what was found. +pub fn parse(source: &str) -> Result { + Cursor::new(tokenize(source)?).diagram() +} + +/// The token stream and the position the parser has reached in it. +struct Cursor { + /// Every token, ending with [`Kind::Eof`]. + tokens: Vec, + /// The index of the next token to read. + next: usize, +} + +impl Cursor { + /// A cursor at the start of `tokens`, which always end with an EOF token. + fn new(tokens: Vec) -> Self { + Self { tokens, next: 0 } + } + + /// The token at the cursor. The stream always ends with EOF, so the + /// fallback is unreachable in practice and still says the honest thing. + fn peek(&self) -> &Token { + self.at(self.next) + } + + /// The token at `index`, clamped to the end marker. + fn at(&self, index: usize) -> &Token { + match self.tokens.get(index).or_else(|| self.tokens.last()) { + Some(token) => token, + None => &END, + } + } + + /// Consumes and returns the token at the cursor. + fn take(&mut self) -> Token { + let token = self.peek().clone(); + if token.kind != Kind::Eof { + self.next = self.next.saturating_add(1); + } + token + } + + /// Consumes the token at the cursor when it is `kind`. + fn eat(&mut self, kind: Kind) -> Option { + (self.peek().kind == kind).then(|| self.take()) + } + + /// Consumes every line break at the cursor. + fn eat_newlines(&mut self) { + while self.eat(Kind::Newline).is_some() {} + } + + /// Consumes the token at the cursor, or says what was expected instead. + fn expect(&mut self, kind: Kind) -> Result { + if self.peek().kind == kind { + return Ok(self.take()); + } + Err(self.unexpected(kind.describe())) + } + + /// The diagnostic for "expected `what`, found this". + fn unexpected(&self, what: &str) -> Diagnostics { + let token = self.peek(); + Diagnostics::one(Diagnostic::at( + format!("expected {what}, found {}", token.describe()), + token.line, + token.col, + token.length, + )) + } + + /// Where the token at the cursor begins. + fn span(&self) -> Span { + let token = self.peek(); + Span { + line: token.line, + col: token.col, + length: token.length, + } + } + + /// `Diagram = ("typeDiagram")? Declaration*`. + fn diagram(&mut self) -> Result { + self.eat_newlines(); + if self.eat(Kind::Header).is_some() { + self.eat_newlines(); + } + let mut decls = Vec::new(); + loop { + self.eat_newlines(); + if self.peek().kind == Kind::Eof { + return Ok(Diagram { decls }); + } + decls.push(self.declaration()?); + } + } + + /// `Declaration = Record | Union | Alias | Function`, with any targeting + /// annotations that precede it. + fn declaration(&mut self) -> Result { + let targeting = self.targeting()?; + let span = self.span(); + match self.peek().kind { + Kind::Type => self.record(targeting, span).map(Decl::Record), + Kind::Union | Kind::Untagged => self.union(targeting, span).map(Decl::Union), + Kind::Alias => self.alias(targeting, span).map(Decl::Alias), + Kind::Function | Kind::Async => self.function(targeting, span).map(Decl::Function), + _ => Err(self.unexpected("'type', 'union', 'untagged union', 'alias', or 'function'")), + } + } + + /// `("@targets" | "@skipTargets") "(" Name ("," Name)* ")"`, repeated. + fn targeting(&mut self) -> Result, Diagnostics> { + let mut targeting: Option = None; + while self.eat(Kind::At).is_some() { + let name = self.expect(Kind::Ident)?; + let _ = self.expect(Kind::LParen)?; + let mut values = Vec::new(); + while self.peek().kind != Kind::RParen && self.peek().kind != Kind::Eof { + values.push(self.expect(Kind::Ident)?.text); + if self.eat(Kind::Comma).is_none() { + break; + } + self.eat_newlines(); + } + let _ = self.expect(Kind::RParen)?; + let entry = targeting.get_or_insert_with(Targeting::default); + match name.text.as_str() { + "targets" => entry.targets = Some(values), + "skipTargets" => entry.skip_targets = Some(values), + other => { + return Err(Diagnostics::one(Diagnostic::at( + format!("unknown annotation '@{other}'"), + name.line, + name.col, + name.length, + ))); + } + } + self.eat_newlines(); + } + Ok(targeting) + } + + /// `Record = "type" Name Generics? "{" Field* "}"`. + fn record(&mut self, targeting: Option, span: Span) -> Result { + let _ = self.take(); + let name = self.expect(Kind::Ident)?.text; + let generics = self.generic_params()?; + let _ = self.expect(Kind::LBrace)?; + let fields = self.brace_list(Self::field)?; + let _ = self.expect(Kind::RBrace)?; + Ok(Record { + name, + generics, + fields, + targeting, + span, + }) + } + + /// `Union = "untagged"? "union" Name Generics? "{" Variant* "}"`. + fn union(&mut self, targeting: Option, span: Span) -> Result { + let untagged = self.eat(Kind::Untagged).is_some(); + let _ = self.expect(Kind::Union)?; + let name = self.expect(Kind::Ident)?.text; + let generics = self.generic_params()?; + let _ = self.expect(Kind::LBrace)?; + let variants = self.brace_list(Self::variant)?; + let _ = self.expect(Kind::RBrace)?; + Ok(Union { + name, + generics, + untagged, + variants, + targeting, + span, + }) + } + + /// `Alias = "alias" Name Generics? "=" TypeRef`. + fn alias(&mut self, targeting: Option, span: Span) -> Result { + let _ = self.take(); + let name = self.expect(Kind::Ident)?.text; + let generics = self.generic_params()?; + let _ = self.expect(Kind::Equals)?; + let target = self.type_ref()?; + Ok(Alias { + name, + generics, + target, + targeting, + span, + }) + } + + /// `Function = "async"? "function" Name Generics? (Signature | "{" Signature* "}")`. + /// + /// A head `async` describes the *bare* form's one signature. An overload + /// block spells `async` per signature, and upstream discards the head's + /// flag there — matched exactly, because the model JSON must agree + /// [typediagram.delivery.baseline]. + fn function( + &mut self, + targeting: Option, + span: Span, + ) -> Result { + let is_async = self.eat(Kind::Async).is_some(); + let _ = self.expect(Kind::Function)?; + let name = self.expect(Kind::Ident)?.text; + let generics = self.generic_params()?; + let signatures = match self.eat(Kind::LBrace) { + Some(_) => { + let signatures = self.brace_list(Self::overload)?; + let _ = self.expect(Kind::RBrace)?; + signatures + } + None => vec![self.signature(is_async)?], + }; + Ok(Function { + name, + generics, + signatures, + targeting, + span, + }) + } + + /// One signature inside an overload block, with its own `async` flag. + fn overload(&mut self) -> Result { + let is_async = self.eat(Kind::Async).is_some(); + self.signature(is_async) + } + + /// `Signature = "(" Parameter* ")" "->" TypeRef`. + fn signature(&mut self, is_async: bool) -> Result { + let span = self.span(); + let _ = self.expect(Kind::LParen)?; + let mut params = Vec::new(); + while self.peek().kind != Kind::RParen && self.peek().kind != Kind::Eof { + params.push(self.field()?); + if self.eat(Kind::Comma).is_none() { + break; + } + self.eat_newlines(); + } + let _ = self.expect(Kind::RParen)?; + let _ = self.expect(Kind::Arrow)?; + let returns = self.type_ref()?; + Ok(Signature { + params, + returns, + is_async, + span, + }) + } + + /// `Field = Name ":" TypeRef`, which is also a parameter. + fn field(&mut self) -> Result { + let span = self.span(); + let name = self.expect(Kind::Ident)?.text; + let _ = self.expect(Kind::Colon)?; + let ty = self.type_ref()?; + Ok(Field { name, ty, span }) + } + + /// `Variant = Name ("=" Number)? ("{" Field* "}" | "(" TypeRef* ")")?`. + fn variant(&mut self) -> Result { + let span = self.span(); + let name = self.expect(Kind::Ident)?.text; + let discriminant = match self.eat(Kind::Equals) { + Some(_) => Some(self.expect(Kind::Number)?.text), + None => None, + }; + let fields = match self.peek().kind { + Kind::LBrace => { + let _ = self.take(); + let fields = self.brace_list(Self::field)?; + let _ = self.expect(Kind::RBrace)?; + fields + } + Kind::LParen => { + let _ = self.take(); + let fields = self.tuple_fields()?; + let _ = self.expect(Kind::RParen)?; + fields + } + _ => Vec::new(), + }; + Ok(Variant { + name, + discriminant, + fields, + span, + }) + } + + /// The positional payload of a tuple variant, named `_0`, `_1`, … exactly + /// as upstream names it. + fn tuple_fields(&mut self) -> Result, Diagnostics> { + let mut fields: Vec = Vec::new(); + loop { + self.eat_newlines(); + if matches!(self.peek().kind, Kind::RParen | Kind::Eof) { + return Ok(fields); + } + let ty = self.type_ref()?; + fields.push(Field { + name: format!("_{}", fields.len()), + span: ty.span, + ty, + }); + self.eat_newlines(); + if self.eat(Kind::Comma).is_none() { + return Ok(fields); + } + } + } + + /// Items inside `{ … }`, separated by a comma, a line break, or both. + fn brace_list( + &mut self, + item: fn(&mut Self) -> Result, + ) -> Result, Diagnostics> { + let mut items = Vec::new(); + loop { + self.eat_newlines(); + if matches!(self.peek().kind, Kind::RBrace | Kind::Eof) { + return Ok(items); + } + items.push(item(self)?); + let _ = self.eat(Kind::Comma); + self.eat_newlines(); + } + } + + /// `Generics = "<" Name ("," Name)* ">"`, absent when there is no `<`. + fn generic_params(&mut self) -> Result, Diagnostics> { + if self.eat(Kind::LAngle).is_none() { + return Ok(Vec::new()); + } + let mut names = Vec::new(); + while self.peek().kind != Kind::RAngle && self.peek().kind != Kind::Eof { + names.push(self.expect(Kind::Ident)?.text); + if self.eat(Kind::Comma).is_none() { + break; + } + self.eat_newlines(); + } + let _ = self.expect(Kind::RAngle)?; + Ok(names) + } + + /// `TypeRef = Name ("<" TypeRef ("," TypeRef)* ">")?`. + fn type_ref(&mut self) -> Result { + let span = self.span(); + let name = self.expect(Kind::Ident)?.text; + let mut args = Vec::new(); + if self.eat(Kind::LAngle).is_some() { + while self.peek().kind != Kind::RAngle && self.peek().kind != Kind::Eof { + args.push(self.type_ref()?); + if self.eat(Kind::Comma).is_none() { + break; + } + self.eat_newlines(); + } + let _ = self.expect(Kind::RAngle)?; + } + Ok(TypeRef { name, args, span }) + } +} + +/// The token a cursor reports when its stream is empty, which construction +/// prevents — [`tokenize`] always appends an end marker. +static END: Token = Token { + kind: Kind::Eof, + text: String::new(), + line: 1, + col: 1, + length: 1, +}; + +// A separate file only because parser.rs is at the 500-line ceiling. +#[cfg(test)] +#[path = "parser_tests.rs"] +mod tests; diff --git a/src/dmx/src/typediagram/parser_tests.rs b/src/dmx/src/typediagram/parser_tests.rs new file mode 100644 index 0000000..ad82c84 --- /dev/null +++ b/src/dmx/src/typediagram/parser_tests.rs @@ -0,0 +1,148 @@ +//! The typeDiagram grammar, production by production [typediagram.model]. +//! +//! Read against the published grammar: one test per form the language admits, +//! plus the positions a syntax error reports. The compatibility corpus in +//! `tests/typediagram_model.rs` proves the same inputs agree with upstream; +//! this proves the tree they parse to is the one the grammar describes. + +use super::super::ast::Decl; +use super::parse; + +/// The one declaration `source` parses to. +fn only(source: &str) -> Decl { + let diagram = parse(source).expect("parse"); + assert_eq!(diagram.decls.len(), 1, "expected exactly one declaration"); + diagram.decls.into_iter().next().expect("one declaration") +} + +/// [typediagram.model]: fields keep source order, and commas and line +/// breaks separate interchangeably. +#[test] +fn a_record_keeps_its_fields_in_order() { + let Decl::Record(record) = only("type User { id: Uuid, name: String\n extra: T, }") else { + panic!("expected a record"); + }; + assert_eq!(record.name, "User"); + assert_eq!(record.generics, ["T"]); + assert_eq!( + record + .fields + .iter() + .map(|f| (f.name.as_str(), f.ty.canonical())) + .collect::>(), + [ + ("id", "Uuid".to_owned()), + ("name", "String".to_owned()), + ("extra", "T".to_owned()), + ] + ); +} + +/// [typediagram.model]: bare, record, tuple, and pinned variants in one +/// union, in source order. +#[test] +fn a_union_reads_every_variant_form() { + let Decl::Union(union) = only( + "untagged union Shape {\n Circle { radius: Float }\n Pair(Int, Int)\n Point\n Code = -32700\n}", + ) else { + panic!("expected a union"); + }; + assert!(union.untagged); + assert_eq!( + union + .variants + .iter() + .map(|v| (v.name.as_str(), v.fields.len(), v.discriminant.clone())) + .collect::>(), + [ + ("Circle", 1, None), + ("Pair", 2, None), + ("Point", 0, None), + ("Code", 0, Some("-32700".to_owned())), + ] + ); + assert!(union.variants[1].is_tuple()); + assert!(!union.variants[0].is_tuple()); +} + +/// [typediagram.model]: nested generic arguments survive intact. +#[test] +fn nested_type_arguments_parse_to_the_written_shape() { + let Decl::Alias(alias) = only("alias Index = Map>>") else { + panic!("expected an alias"); + }; + assert_eq!( + alias.target.canonical(), + "Map>>" + ); +} + +/// [typediagram.model]: the bare form takes the head's `async`; an +/// overload block spells it per signature and drops the head's, exactly as +/// upstream does. +#[test] +fn function_forms_carry_async_where_upstream_does() { + let Decl::Function(bare) = only("async function fetch(id: T) -> Bytes") else { + panic!("expected a function"); + }; + assert!(bare.signatures[0].is_async); + assert_eq!(bare.generics, ["T"]); + + let Decl::Function(block) = only( + "async function read {\n (path: String) -> Bytes\n async (path: String, timeout: Float) -> Unit\n}", + ) else { + panic!("expected a function"); + }; + assert_eq!(block.signatures.len(), 2); + assert!(!block.signatures[0].is_async); + assert!(block.signatures[1].is_async); +} + +/// [typediagram.model]: the optional header and `#` comments are not +/// declarations. +#[test] +fn the_header_and_comments_are_not_declarations() { + let diagram = parse("typeDiagram\n\n# only a note\ntype A { x: Int }\n").expect("parse"); + assert_eq!(diagram.decls.len(), 1); + assert!(parse("# nothing here\n").expect("parse").decls.is_empty()); +} + +/// [typediagram.model]: targeting annotations filter a declaration. +#[test] +fn targeting_annotations_attach_to_the_declaration_below_them() { + let Decl::Record(record) = only("@targets(dart)\n@skipTargets(go)\ntype A { x: Int }") else { + panic!("expected a record"); + }; + let targeting = record.targeting.expect("targeting"); + assert_eq!( + targeting.targets.as_deref(), + Some(["dart".to_owned()].as_slice()) + ); + assert_eq!( + targeting.skip_targets.as_deref(), + Some(["go".to_owned()].as_slice()) + ); +} + +/// [typediagram.diagnostics]: a syntax error names the position and what +/// was expected there. +#[test] +fn syntax_errors_carry_a_position_and_an_expectation() { + for (source, expected, line, col) in [ + ("type { }", "expected a name", 1, 6), + ("type A { id }", "expected ':'", 1, 13), + ("type A { id: }", "expected a name", 1, 14), + ("alias E String", "expected '='", 1, 9), + ("union U { A = }", "expected a number", 1, 15), + ("record A { }", "expected 'type'", 1, 1), + ("untagged type A { }", "expected 'union'", 1, 10), + ("@nope(x)\ntype A { }", "unknown annotation '@nope'", 1, 2), + ] { + let error = parse(source).expect_err(source); + assert!( + error.to_string().contains(expected), + "{source}: {error} did not contain {expected}" + ); + assert_eq!((error.0[0].line, error.0[0].col), (line, col), "{source}"); + } +} diff --git a/src/dmx/src/typediagram/target.rs b/src/dmx/src/typediagram/target.rs new file mode 100644 index 0000000..29265a5 --- /dev/null +++ b/src/dmx/src/typediagram/target.rs @@ -0,0 +1,329 @@ +//! Generation targets [typediagram.model]. +//! +//! A target is the *only* place a language leaks into this feature: it maps a +//! resolved typeDiagram reference onto that language's type text, says what +//! extension its files carry, and validates a finished file. Everything else +//! in `typediagram` — the lexer, the parser, the model, the binder, the +//! context builder, the emitter — is language-neutral. +//! +//! One target ships today, because one language does. The registry is a table +//! rather than a trait object for the same reason the macro catalogue is +//! [catalogue]: adding a language is adding a row, not a plugin lifecycle. + +use anyhow::{Result, bail}; + +use super::ast::TypeRef; +use super::model::{Model, Resolution}; + +/// Everything the pipeline needs to know about one output language. +pub struct Target { + /// The name `dmx.target` selects it by. + pub name: &'static str, + /// The extension every output it generates must carry, without the dot. + pub extension: &'static str, + /// The file that marks a project root in this language, which is what an + /// output path is resolved against [typediagram.output]. + pub project_marker: &'static str, + /// This language's text for one resolved reference. + pub type_text: fn(&TypeRef, &Model) -> Result, + /// Refuses a finished file that does not parse, or that generated code is + /// not allowed to contain [hygiene]. + pub validate: fn(&str, &str) -> Result<()>, +} + +/// A target is mostly function pointers, which carry nothing a diagnostic +/// could act on. Its name and extension are the whole of what identifies it. +impl std::fmt::Debug for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Target") + .field("name", &self.name) + .field("extension", &self.extension) + .finish_non_exhaustive() + } +} + +/// Every target this build can generate [typediagram.model]. +const TARGETS: &[Target] = &[Target { + name: "dart", + extension: "dart", + project_marker: "pubspec.yaml", + type_text: dart_type, + validate: validate_dart, +}]; + +/// Every file that marks a project root, for any target this build carries +/// [typediagram.output]. +/// +/// One document resolves its outputs against one root, so the search is over +/// all of them rather than over the target a particular template named: a +/// document that generated Dart into one package and something else into +/// another would have two identities and no single ownership marker. +pub fn project_markers() -> impl Iterator { + TARGETS.iter().map(|target| target.project_marker) +} + +/// Every extension a generated output can carry, for any target this build +/// carries [typediagram.output]. +/// +/// This is what a pass sweeps for when it collects the outputs a removed +/// template used to produce: a stale file is found by its ownership marker, +/// and this is the set of files worth opening to look for one. +pub fn extensions() -> impl Iterator { + TARGETS.iter().map(|target| target.extension) +} + +/// The target `name` selects. +/// +/// # Errors +/// +/// Fails when no target carries that name, listing the ones that do. +pub fn find(name: &str) -> Result<&'static Target> { + TARGETS + .iter() + .find(|target| target.name == name) + .map_or_else( + || { + bail!( + "DMX8007 [typediagram.model]: `{name}` is not a generation target dmx knows; \ + available: {}", + TARGETS + .iter() + .map(|target| target.name) + .collect::>() + .join(", ") + ) + }, + Ok, + ) +} + +/// The Dart text for one resolved reference [typediagram.model]. +/// +/// This is the whole of dmx's typeDiagram-to-Dart mapping, and it matches the +/// published table: `Option` is Dart's own `T?`, the semantic scalars land +/// on Dart's native types where Dart has one, and a declared name keeps its +/// arguments. +/// +/// # Errors +/// +/// Fails when a container built-in was given the wrong number of arguments, +/// which the model deliberately does not check — a diagram renders `List` with +/// no argument, and Dart cannot. +fn dart_type(reference: &TypeRef, model: &Model) -> Result { + let args = reference + .args + .iter() + .map(|arg| dart_type(arg, model)) + .collect::>>()?; + match model.resolution(reference) { + // A generic parameter is its own name, and a declared type is its name + // plus whatever it was applied to. + Resolution::TypeParam => Ok(reference.name.clone()), + Resolution::Declared(name) => Ok(applied(name, &args)), + Resolution::Primitive => Ok(primitive(&reference.name).to_owned()), + Resolution::External => container(reference, &args), + } +} + +/// Dart's name for one typeDiagram scalar. +/// +/// `Uuid` and `Decimal` have no native Dart type, so they carry the string +/// their wire form already is; `Unit` is Dart's `void`. +fn primitive(name: &str) -> &'static str { + match name { + "Bool" => "bool", + "Int" => "int", + "Float" => "double", + "Bytes" => "List", + "Unit" => "void", + "DateTime" => "DateTime", + // `String`, `Uuid`, and `Decimal` are all Dart strings. The list is + // exhaustive over PRIMITIVES, and a name that somehow reached here + // without being one keeps its spelling rather than becoming a lie. + _ => "String", + } +} + +/// Dart's form for one of the container built-ins. +fn container(reference: &TypeRef, args: &[String]) -> Result { + let arity = |wanted: usize| { + if args.len() == wanted { + return Ok(()); + } + bail!( + "DMX8004 [typediagram.model]: `{}` takes {wanted} type argument(s), got {} \ + (line {}, column {})", + reference.name, + args.len(), + reference.span.line, + reference.span.col + ) + }; + match reference.name.as_str() { + "Option" => { + arity(1)?; + Ok(nullable(args.first().map_or("Object", String::as_str))) + } + "List" => { + arity(1)?; + Ok(applied("List", args)) + } + "Map" => { + arity(2)?; + Ok(applied("Map", args)) + } + // `Any` is Dart's `Object`, matching the published mapping table. + "Any" => { + arity(0)?; + Ok("Object".to_owned()) + } + // Generation validation has already refused every other external name + // [typediagram.model], so nothing else can reach here. + other => bail!("DMX8004 [typediagram.model]: `{other}` has no Dart type"), + } +} + +/// `Name`, or just `Name` when there are no arguments. +fn applied(name: &str, args: &[String]) -> String { + if args.is_empty() { + return name.to_owned(); + } + format!("{name}<{}>", args.join(", ")) +} + +/// The nullable form of a Dart type. +/// +/// Dart has no `T??` and no `void?`, so an already-optional type and `void` +/// are their own nullable forms — which is exactly what `Option>` +/// means anyway. +fn nullable(text: &str) -> String { + if text.ends_with('?') || text == "void" { + return text.to_owned(); + } + format!("{text}?") +} + +/// Refuses generated Dart that does not parse, or that breaks [hygiene]. +fn validate_dart(source: &str, origin: &str) -> Result<()> { + crate::frontend::Frontend::new()?.validate(source, origin)?; + crate::hygiene::check(source, origin) +} + +#[cfg(test)] +mod tests { + use super::super::model::Model; + use super::super::parser::parse; + use super::{find, nullable}; + + /// The Dart types of the first declaration's fields, in order. + fn dart_fields(source: &str) -> Vec { + let model = Model::resolve(parse(source).expect("parse")).expect("resolve"); + let target = find("dart").expect("dart target"); + let super::super::ast::Decl::Record(record) = &model.decls()[0] else { + panic!("expected a record"); + }; + record + .fields + .iter() + .map(|field| (target.type_text)(&field.ty, &model).expect("dart type")) + .collect() + } + + /// [typediagram.model]: the published mapping table, scalar by scalar. + #[test] + fn scalars_map_to_dart() { + assert_eq!( + dart_fields( + "type A { a: Bool, b: Int, c: Float, d: String, e: Bytes, f: DateTime, g: Uuid, h: Decimal }" + ), + [ + "bool", + "int", + "double", + "String", + "List", + "DateTime", + "String", + "String" + ] + ); + } + + /// [typediagram.model]: containers, nesting, declared names, and generic + /// parameters all come out as Dart writes them. + #[test] + fn containers_and_declared_names_map_to_dart() { + assert_eq!( + dart_fields( + "type A { a: List, b: Map>, c: Option, d: Any, e: T, f: B, g: C }\ntype B { x: Int }\ntype C { y: T }" + ), + [ + "List", + "Map>", + "int?", + "Object", + "T", + "B", + "C", + ] + ); + } + + /// [typediagram.model]: Dart has no `T??`, so a doubled option is one + /// option — and `Option` is still `void`. + #[test] + fn nullability_never_doubles() { + assert_eq!( + dart_fields("type A { a: Option>, b: Option, c: Option }"), + ["String?", "void", "Object?"] + ); + assert_eq!(nullable("int"), "int?"); + } + + /// [typediagram.model]: a container given the wrong arity renders as a + /// diagram and cannot become Dart, so generation refuses it. + #[test] + fn a_container_with_the_wrong_arity_is_refused() { + for source in [ + "type A { a: List }", + "type A { a: Map }", + "type A { a: Option }", + "type A { a: Any }", + ] { + let model = Model::resolve(parse(source).expect("parse")).expect("resolve"); + let target = find("dart").expect("dart target"); + let super::super::ast::Decl::Record(record) = &model.decls()[0] else { + panic!("expected a record"); + }; + let error = (target.type_text)(&record.fields[0].ty, &model).expect_err(source); + assert!( + format!("{error:#}").contains("DMX8004"), + "{source}: {error:#}" + ); + } + } + + /// [typediagram.model]: an unknown target is named, with the ones that + /// exist. + #[test] + fn an_unknown_target_is_refused_with_the_alternatives() { + let error = format!("{:#}", find("kotlin").expect_err("no kotlin target")); + assert!(error.contains("DMX8007"), "{error}"); + assert!(error.contains("available: dart"), "{error}"); + let dart = find("dart").expect("dart target"); + assert_eq!(dart.extension, "dart"); + assert_eq!(dart.project_marker, "pubspec.yaml"); + assert!(super::project_markers().any(|marker| marker == "pubspec.yaml")); + assert!(super::extensions().any(|extension| extension == "dart")); + } + + /// [hygiene]: the Dart target refuses a file that does not parse and one + /// that breaks the generated-code rules. + #[test] + fn the_dart_target_validates_what_it_emits() { + let target = find("dart").expect("dart target"); + (target.validate)("final class A {}\n", "test").expect("valid Dart"); + assert!((target.validate)("final class A {", "test").is_err()); + assert!((target.validate)("int f(Object o) => throw 'no';\n", "test").is_err()); + } +} diff --git a/src/dmx/src/watch.rs b/src/dmx/src/watch.rs index c759a86..7e0790c 100644 --- a/src/dmx/src/watch.rs +++ b/src/dmx/src/watch.rs @@ -35,9 +35,9 @@ impl Scope { .with_context(|| format!("DMX1002 [cli]: cannot watch {}", path.display()))?; match (absolute.is_dir(), absolute.is_file()) { (true, false) => Ok(Self::Directory(absolute)), - (false, true) if is_dart_source(&absolute) => Ok(Self::File(absolute)), + (false, true) if Sweep::Sources.wants_named(&absolute) => Ok(Self::File(absolute)), (false, true) => bail!( - "DMX1002 [cli]: watch target is not a Dart source: {}", + "DMX1002 [cli]: watch target is not a Dart source or a Markdown document: {}", path.display() ), _ => bail!( @@ -62,7 +62,15 @@ impl Scope { /// Whether an event about this path is one this scope wants. fn accepts(&self, path: &Path) -> bool { - !path.is_symlink() && path.is_file() && is_dart_source(path) && self.contains(path) + let named = matches!(self, Self::File(file) if file == path); + // Recursive discovery takes `*.dmx.md`; a Markdown file named directly + // is watched whatever it is called [typediagram.documents]. + let wanted = if named { + Sweep::Sources.wants_named(path) + } else { + Sweep::Sources.wants(path) + }; + !path.is_symlink() && path.is_file() && wanted && self.contains(path) } /// Whether `path` is a directory inside this scope's tree. @@ -103,15 +111,62 @@ impl Scope { } } -/// Finds source files using the zero-config exclusions [surface.zero-config]. +/// What one sweep of the tree is looking for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Sweep { + /// Everything dmx generates from: Dart files and Markdown documents. + Sources, + /// Anything carrying an extension some generation target writes — the + /// candidates a generated output could be hiding among when a pass + /// collects what it no longer produces [typediagram.output]. + Outputs, +} + +impl Sweep { + /// Whether a file *recursive discovery* found is one this sweep wants. + fn wants(self, path: &Path) -> bool { + match self { + Self::Sources => is_dart_source(path) || crate::typediagram::is_document(path), + Self::Outputs => crate::typediagram::target::extensions() + .any(|extension| has_extension(path, extension)), + } + } + + /// Whether a file *named directly* is one this sweep wants. + /// + /// The two differ in exactly one place: recursive discovery takes + /// `*.dmx.md` and nothing else, and naming a Markdown file is how any + /// other one is generated from [typediagram.documents]. + fn wants_named(self, path: &Path) -> bool { + self.wants(path) || (self == Self::Sources && crate::typediagram::is_markdown(path)) + } +} + +/// Every source dmx generates from at or under `paths` — Dart files and +/// Markdown documents alike [surface.zero-config], [typediagram.documents]. /// /// # Errors /// /// Fails when a directory cannot be read. -pub fn collect_dart_files(paths: &[PathBuf]) -> Result> { +pub fn collect_sources(paths: &[PathBuf]) -> Result> { + collect(paths, Sweep::Sources) +} + +/// Every file at or under `paths` that some generation target could have +/// written [typediagram.output]. +/// +/// # Errors +/// +/// Fails when a directory cannot be read. +pub fn collect_outputs(paths: &[PathBuf]) -> Result> { + collect(paths, Sweep::Outputs) +} + +/// Every file `sweep` accepts at or under `paths`, deduplicated and ordered. +fn collect(paths: &[PathBuf], sweep: Sweep) -> Result> { paths .iter() - .map(|path| collect_path(path)) + .map(|path| collect_path(path, sweep, Sweep::wants_named)) .collect::>>() .map(|groups| { groups @@ -123,23 +178,25 @@ pub fn collect_dart_files(paths: &[PathBuf]) -> Result> { }) } -/// Every source at or under one path. -fn collect_path(path: &Path) -> Result> { - match ( - path.is_symlink(), - path.is_dir(), - path.is_file() && is_dart_source(path), - ) { - (false, true, _) => collect_directory(path), - (false, false, true) => Ok(vec![path.to_owned()]), +/// Every source at or under one path, with `accept` deciding what a *file* +/// there has to be — which differs between a path somebody named and one +/// discovery walked into. +fn collect_path( + path: &Path, + sweep: Sweep, + accept: fn(Sweep, &Path) -> bool, +) -> Result> { + match (path.is_symlink(), path.is_dir(), path.is_file()) { + (false, true, _) => collect_directory(path, sweep), + (false, false, true) if accept(sweep, path) => Ok(vec![path.to_owned()]), // A symlink is never followed [surface.zero-config], and anything that - // is not a Dart source is not dmx's to read. + // is not a source is not dmx's to read. _ => Ok(Vec::new()), } } /// Every source under one directory, hidden entries excluded. -fn collect_directory(directory: &Path) -> Result> { +fn collect_directory(directory: &Path, sweep: Sweep) -> Result> { std::fs::read_dir(directory) .with_context(|| { format!( @@ -148,7 +205,9 @@ fn collect_directory(directory: &Path) -> Result> { ) })? .filter_map(|entry| match entry { - Ok(entry) if visible_name(&entry.file_name()) => Some(collect_path(&entry.path())), + Ok(entry) if visible_name(&entry.file_name()) => { + Some(collect_path(&entry.path(), sweep, Sweep::wants)) + } Ok(_) => None, Err(error) => Some(Err(anyhow::Error::from(error).context(format!( "DMX1002 [surface.zero-config]: cannot inspect {}", @@ -161,13 +220,18 @@ fn collect_directory(directory: &Path) -> Result> { /// A Dart source dmx owns — not a `.g.dart` somebody else generates. fn is_dart_source(path: &Path) -> bool { - path.extension() - .is_some_and(|extension| extension == "dart") + has_extension(path, "dart") && path .file_name() .is_some_and(|name| !name.to_string_lossy().ends_with(".g.dart")) } +/// Whether `path` carries `extension`, however it is cased. +fn has_extension(path: &Path, extension: &str) -> bool { + path.extension() + .is_some_and(|found| found.eq_ignore_ascii_case(extension)) +} + /// Whether a directory entry is one the zero-config rules look at. fn visible_name(name: &OsStr) -> bool { !name.to_string_lossy().starts_with('.') @@ -307,7 +371,7 @@ impl Batch { self.trees .iter() .filter(|path| path.exists()) - .map(|path| collect_path(path)) + .map(|path| collect_path(path, Sweep::Sources, Sweep::wants)) .collect::>>() .map(|groups| { groups @@ -447,9 +511,12 @@ fn claim(path: &Path, scopes: &[Scope]) -> Batch { } } -/// A missing Dart source against the watched directory it was in. +/// A missing source against the watched directory it was in. fn vanished_in(path: &Path, scopes: &[Scope]) -> Option<(PathBuf, PathBuf)> { - let parent = is_dart_source(path).then(|| path.parent()).flatten()?; + let parent = Sweep::Sources + .wants(path) + .then(|| path.parent()) + .flatten()?; scopes .iter() .any(|scope| scope.covers_directory(parent)) diff --git a/src/dmx/tests/cli.rs b/src/dmx/tests/cli.rs index 0f381b8..444adcc 100644 --- a/src/dmx/tests/cli.rs +++ b/src/dmx/tests/cli.rs @@ -58,7 +58,13 @@ fn help_describes_every_subcommand_and_succeeds() { let output = dmx(&[flag]); assert!(output.status.success(), "`dmx {flag}` failed"); let text = stdout(&output); - for expected in ["dmx build", "dmx watch", "--insert-regions", "--check"] { + for expected in [ + "dmx build", + "dmx watch", + "dmx explain", + "--insert-regions", + "--check", + ] { assert!( text.contains(expected), "`dmx {flag}` omitted `{expected}`:\n{text}" diff --git a/src/dmx/tests/support/mod.rs b/src/dmx/tests/support/mod.rs index ab69d0d..8a99dcd 100644 --- a/src/dmx/tests/support/mod.rs +++ b/src/dmx/tests/support/mod.rs @@ -48,8 +48,21 @@ impl TempDirectory { )) } - pub fn write(&self, name: &str, contents: &str) -> io::Result { - let path = self.path.join(name); + /// One path inside the directory. `relative` is written with `/` on every + /// platform, because a test that spells its own separators is a test that + /// only runs on one. + pub fn at(&self, relative: &str) -> PathBuf { + relative + .split('/') + .fold(self.path.clone(), |path, part| path.join(part)) + } + + /// Writes `contents` to `relative`, creating the directories it names. + pub fn write(&self, relative: &str, contents: &str) -> io::Result { + let path = self.at(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&path, contents)?; Ok(path) } diff --git a/src/dmx/tests/typediagram/corpus/aliases-and-functions.model.json b/src/dmx/tests/typediagram/corpus/aliases-and-functions.model.json new file mode 100644 index 0000000..1d71b0d --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/aliases-and-functions.model.json @@ -0,0 +1,238 @@ +{ + "version": 1, + "decls": [ + { + "kind": "alias", + "name": "Email", + "generics": [], + "target": { + "name": "String", + "args": [] + } + }, + { + "kind": "alias", + "name": "UserId", + "generics": [], + "target": { + "name": "Uuid", + "args": [] + } + }, + { + "kind": "alias", + "name": "Callback", + "generics": [], + "target": { + "name": "Option", + "args": [ + { + "name": "String", + "args": [] + } + ] + } + }, + { + "kind": "alias", + "name": "Index", + "generics": [ + "K" + ], + "target": { + "name": "Map", + "args": [ + { + "name": "K", + "args": [] + }, + { + "name": "List", + "args": [ + { + "name": "Email", + "args": [] + } + ] + } + ] + } + }, + { + "kind": "function", + "name": "fetch", + "generics": [ + "T" + ], + "signatures": [ + { + "params": [ + { + "name": "request", + "type": { + "name": "Request", + "args": [] + } + }, + { + "name": "fallback", + "type": { + "name": "Option", + "args": [ + { + "name": "T", + "args": [] + } + ] + } + } + ], + "returns": { + "name": "Response", + "args": [] + } + } + ] + }, + { + "kind": "function", + "name": "store", + "generics": [], + "signatures": [ + { + "params": [ + { + "name": "item", + "type": { + "name": "Request", + "args": [] + } + } + ], + "returns": { + "name": "Unit", + "args": [] + }, + "async": true + } + ] + }, + { + "kind": "function", + "name": "read", + "generics": [], + "signatures": [ + { + "params": [ + { + "name": "path", + "type": { + "name": "String", + "args": [] + } + } + ], + "returns": { + "name": "Bytes", + "args": [] + } + }, + { + "params": [ + { + "name": "path", + "type": { + "name": "String", + "args": [] + } + }, + { + "name": "timeout", + "type": { + "name": "Float", + "args": [] + } + } + ], + "returns": { + "name": "Bytes", + "args": [] + }, + "async": true + } + ] + }, + { + "kind": "function", + "name": "drain", + "generics": [], + "signatures": [ + { + "params": [], + "returns": { + "name": "Unit", + "args": [] + } + }, + { + "params": [ + { + "name": "limit", + "type": { + "name": "Int", + "args": [] + } + } + ], + "returns": { + "name": "Int", + "args": [] + }, + "async": true + } + ] + }, + { + "kind": "function", + "name": "nothing", + "generics": [], + "signatures": [ + { + "params": [], + "returns": { + "name": "Unit", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Request", + "generics": [], + "fields": [ + { + "name": "url", + "type": { + "name": "String", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Response", + "generics": [], + "fields": [ + { + "name": "status", + "type": { + "name": "Int", + "args": [] + } + } + ] + } + ] +} diff --git a/src/dmx/tests/typediagram/corpus/aliases-and-functions.td b/src/dmx/tests/typediagram/corpus/aliases-and-functions.td new file mode 100644 index 0000000..54496d6 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/aliases-and-functions.td @@ -0,0 +1,23 @@ +alias Email = String +alias UserId = Uuid +alias Callback = Option +alias Index = Map> + +function fetch(request: Request, fallback: Option) -> Response + +async function store(item: Request) -> Unit + +function read { + (path: String) -> Bytes + async (path: String, timeout: Float) -> Bytes +} + +async function drain { + () -> Unit + async (limit: Int) -> Int +} + +function nothing() -> Unit + +type Request { url: String } +type Response { status: Int } diff --git a/src/dmx/tests/typediagram/corpus/records.model.json b/src/dmx/tests/typediagram/corpus/records.model.json new file mode 100644 index 0000000..2456bc8 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/records.model.json @@ -0,0 +1,173 @@ +{ + "version": 1, + "decls": [ + { + "kind": "record", + "name": "User", + "generics": [], + "fields": [ + { + "name": "id", + "type": { + "name": "Uuid", + "args": [] + } + }, + { + "name": "name", + "type": { + "name": "String", + "args": [] + } + }, + { + "name": "email", + "type": { + "name": "Option", + "args": [ + { + "name": "Email", + "args": [] + } + ] + } + }, + { + "name": "roles", + "type": { + "name": "List", + "args": [ + { + "name": "Role", + "args": [] + } + ] + } + }, + { + "name": "address", + "type": { + "name": "Address", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Pair", + "generics": [ + "A", + "B" + ], + "fields": [ + { + "name": "first", + "type": { + "name": "A", + "args": [] + } + }, + { + "name": "second", + "type": { + "name": "B", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Box", + "generics": [ + "T" + ], + "fields": [ + { + "name": "value", + "type": { + "name": "T", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Empty", + "generics": [], + "fields": [] + }, + { + "kind": "record", + "name": "Separators", + "generics": [], + "fields": [ + { + "name": "a", + "type": { + "name": "Int", + "args": [] + } + }, + { + "name": "b", + "type": { + "name": "Int", + "args": [] + } + }, + { + "name": "c", + "type": { + "name": "Int", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Email", + "generics": [], + "fields": [ + { + "name": "text", + "type": { + "name": "String", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Role", + "generics": [], + "fields": [ + { + "name": "name", + "type": { + "name": "String", + "args": [] + } + } + ] + }, + { + "kind": "record", + "name": "Address", + "generics": [], + "fields": [ + { + "name": "line", + "type": { + "name": "String", + "args": [] + } + } + ] + } + ] +} diff --git a/src/dmx/tests/typediagram/corpus/records.td b/src/dmx/tests/typediagram/corpus/records.td new file mode 100644 index 0000000..5cf1fb3 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/records.td @@ -0,0 +1,30 @@ +typeDiagram + +# Every record shape the language reference names. +type User { + id: Uuid + name: String + email: Option + roles: List + address: Address +} + +type Pair { + first: A + second: B +} + +type Box { + value: T +} + +type Empty { +} + +type Separators { a: Int, b: Int + c: Int, +} + +type Email { text: String } +type Role { name: String } +type Address { line: String } diff --git a/src/dmx/tests/typediagram/corpus/scalars.model.json b/src/dmx/tests/typediagram/corpus/scalars.model.json new file mode 100644 index 0000000..2e3e017 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/scalars.model.json @@ -0,0 +1,172 @@ +{ + "version": 1, + "decls": [ + { + "kind": "record", + "name": "Scalars", + "generics": [], + "fields": [ + { + "name": "flag", + "type": { + "name": "Bool", + "args": [] + } + }, + { + "name": "count", + "type": { + "name": "Int", + "args": [] + } + }, + { + "name": "ratio", + "type": { + "name": "Float", + "args": [] + } + }, + { + "name": "text", + "type": { + "name": "String", + "args": [] + } + }, + { + "name": "blob", + "type": { + "name": "Bytes", + "args": [] + } + }, + { + "name": "nothing", + "type": { + "name": "Unit", + "args": [] + } + }, + { + "name": "at", + "type": { + "name": "DateTime", + "args": [] + } + }, + { + "name": "id", + "type": { + "name": "Uuid", + "args": [] + } + }, + { + "name": "amount", + "type": { + "name": "Decimal", + "args": [] + } + }, + { + "name": "tags", + "type": { + "name": "List", + "args": [ + { + "name": "String", + "args": [] + } + ] + } + }, + { + "name": "index", + "type": { + "name": "Map", + "args": [ + { + "name": "String", + "args": [] + }, + { + "name": "List", + "args": [ + { + "name": "Option", + "args": [ + { + "name": "Decimal", + "args": [] + } + ] + } + ] + } + ] + } + }, + { + "name": "maybe", + "type": { + "name": "Option", + "args": [ + { + "name": "Int", + "args": [] + } + ] + } + }, + { + "name": "anything", + "type": { + "name": "Any", + "args": [] + } + }, + { + "name": "deep", + "type": { + "name": "Option", + "args": [ + { + "name": "Option", + "args": [ + { + "name": "Map", + "args": [ + { + "name": "Uuid", + "args": [] + }, + { + "name": "List", + "args": [ + { + "name": "Any", + "args": [] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }, + { + "kind": "alias", + "name": "Uuid", + "generics": [], + "target": { + "name": "String", + "args": [] + } + } + ] +} diff --git a/src/dmx/tests/typediagram/corpus/scalars.td b/src/dmx/tests/typediagram/corpus/scalars.td new file mode 100644 index 0000000..6374592 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/scalars.td @@ -0,0 +1,20 @@ +# Every primitive and semantic scalar, plus every container built-in. +type Scalars { + flag: Bool + count: Int + ratio: Float + text: String + blob: Bytes + nothing: Unit + at: DateTime + id: Uuid + amount: Decimal + tags: List + index: Map>> + maybe: Option + anything: Any + deep: Option>>> +} + +# A declaration shadows a built-in scalar, which must keep working. +alias Uuid = String diff --git a/src/dmx/tests/typediagram/corpus/targeting.model.json b/src/dmx/tests/typediagram/corpus/targeting.model.json new file mode 100644 index 0000000..3e55f89 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/targeting.model.json @@ -0,0 +1,84 @@ +{ + "version": 1, + "decls": [ + { + "kind": "record", + "name": "OnlyDartAndRust", + "generics": [], + "fields": [ + { + "name": "a", + "type": { + "name": "Int", + "args": [] + } + } + ], + "targeting": { + "targets": [ + "dart", + "rust" + ] + } + }, + { + "kind": "record", + "name": "NotGo", + "generics": [], + "fields": [ + { + "name": "b", + "type": { + "name": "String", + "args": [] + } + } + ], + "targeting": { + "skipTargets": [ + "go" + ] + } + }, + { + "kind": "union", + "name": "Both", + "generics": [], + "targeting": { + "targets": [ + "dart" + ], + "skipTargets": [ + "python" + ] + }, + "variants": [ + { + "name": "One", + "fields": [] + }, + { + "name": "Two", + "fields": [ + { + "name": "x", + "type": { + "name": "Int", + "args": [] + } + } + ] + } + ] + }, + { + "kind": "alias", + "name": "Plain", + "generics": [], + "target": { + "name": "Int", + "args": [] + } + } + ] +} diff --git a/src/dmx/tests/typediagram/corpus/targeting.td b/src/dmx/tests/typediagram/corpus/targeting.td new file mode 100644 index 0000000..4f8e0ca --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/targeting.td @@ -0,0 +1,14 @@ +@targets(dart, rust) +type OnlyDartAndRust { a: Int } + +@skipTargets(go) +type NotGo { b: String } + +@targets(dart) +@skipTargets(python) +union Both { + One + Two { x: Int } +} + +alias Plain = Int diff --git a/src/dmx/tests/typediagram/corpus/unions.model.json b/src/dmx/tests/typediagram/corpus/unions.model.json new file mode 100644 index 0000000..491eea2 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/unions.model.json @@ -0,0 +1,258 @@ +{ + "version": 1, + "decls": [ + { + "kind": "union", + "name": "Shape", + "generics": [], + "variants": [ + { + "name": "Circle", + "fields": [ + { + "name": "radius", + "type": { + "name": "Float", + "args": [] + } + } + ] + }, + { + "name": "Rectangle", + "fields": [ + { + "name": "width", + "type": { + "name": "Float", + "args": [] + } + }, + { + "name": "height", + "type": { + "name": "Float", + "args": [] + } + } + ] + }, + { + "name": "Triangle", + "fields": [ + { + "name": "a", + "type": { + "name": "Float", + "args": [] + } + }, + { + "name": "b", + "type": { + "name": "Float", + "args": [] + } + }, + { + "name": "c", + "type": { + "name": "Float", + "args": [] + } + } + ] + }, + { + "name": "Point", + "fields": [] + } + ] + }, + { + "kind": "union", + "name": "ErrorCode", + "generics": [], + "variants": [ + { + "name": "ParseError", + "fields": [], + "discriminant": "-32700" + }, + { + "name": "InvalidRequest", + "fields": [], + "discriminant": "-32600" + }, + { + "name": "MethodNotFound", + "fields": [], + "discriminant": "-32601" + }, + { + "name": "Ok", + "fields": [], + "discriminant": "0" + }, + { + "name": "Grouped", + "fields": [], + "discriminant": "1_000" + } + ] + }, + { + "kind": "union", + "name": "Option", + "generics": [ + "T" + ], + "variants": [ + { + "name": "Some", + "fields": [ + { + "name": "value", + "type": { + "name": "T", + "args": [] + } + } + ] + }, + { + "name": "None", + "fields": [] + } + ] + }, + { + "kind": "union", + "name": "Result", + "generics": [ + "T", + "E" + ], + "variants": [ + { + "name": "Ok", + "fields": [ + { + "name": "value", + "type": { + "name": "T", + "args": [] + } + } + ] + }, + { + "name": "Err", + "fields": [ + { + "name": "error", + "type": { + "name": "E", + "args": [] + } + } + ] + } + ] + }, + { + "kind": "union", + "name": "RequestId", + "generics": [], + "variants": [ + { + "name": "Number", + "fields": [ + { + "name": "_0", + "type": { + "name": "Int", + "args": [] + } + } + ] + }, + { + "name": "String", + "fields": [ + { + "name": "_0", + "type": { + "name": "String", + "args": [] + } + } + ] + }, + { + "name": "Triple", + "fields": [ + { + "name": "_0", + "type": { + "name": "Int", + "args": [] + } + }, + { + "name": "_1", + "type": { + "name": "String", + "args": [] + } + }, + { + "name": "_2", + "type": { + "name": "List", + "args": [ + { + "name": "Bool", + "args": [] + } + ] + } + } + ] + } + ] + }, + { + "kind": "union", + "name": "Loose", + "generics": [], + "untagged": true, + "variants": [ + { + "name": "Left", + "fields": [ + { + "name": "value", + "type": { + "name": "Int", + "args": [] + } + } + ] + }, + { + "name": "Right", + "fields": [ + { + "name": "value", + "type": { + "name": "String", + "args": [] + } + } + ] + } + ] + } + ] +} diff --git a/src/dmx/tests/typediagram/corpus/unions.td b/src/dmx/tests/typediagram/corpus/unions.td new file mode 100644 index 0000000..dd773a1 --- /dev/null +++ b/src/dmx/tests/typediagram/corpus/unions.td @@ -0,0 +1,35 @@ +union Shape { + Circle { radius: Float } + Rectangle { width: Float, height: Float } + Triangle { a: Float, b: Float, c: Float } + Point +} + +union ErrorCode { + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + Ok = 0 + Grouped = 1_000 +} + +union Option { + Some { value: T } + None +} + +union Result { + Ok { value: T } + Err { error: E } +} + +union RequestId { + Number(Int) + String(String) + Triple(Int, String, List) +} + +untagged union Loose { + Left { value: Int } + Right { value: String } +} diff --git a/src/dmx/tests/typediagram_cli.rs b/src/dmx/tests/typediagram_cli.rs new file mode 100644 index 0000000..e6d1853 --- /dev/null +++ b/src/dmx/tests/typediagram_cli.rs @@ -0,0 +1,593 @@ +//! typeDiagram documents through the real `dmx` binary [typediagram]. +//! +//! Black box throughout: a scratch workspace on a real filesystem, real +//! Markdown, real Mustache, and the shipped binary run the way a person or a +//! Makefile runs it. Nothing here reaches into the crate — what is asserted is +//! the bytes on disk and the output contract on stdout and stderr. + +// [TEST-RULES] admits `expect` in a test: a fixture that cannot be built is a +// broken test, and unwinding at the point of failure names it better than any +// `Result` plumbing would. Production code is still held to `unwrap_used` and +// `expect_used` at deny — this relaxation is `cfg(test)`-scoped on purpose. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects + ) +)] + +mod support; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use support::TempDirectory; + +/// A record definition and one template over it — the canonical document. +const STORE: &str = r#"# Store models + +The definitions below are the source of truth. Everything under them is +ordinary prose and must survive untouched. + +```typeDiagram +type Product { + id: Uuid + name: String + price: Decimal + note: Option +} + +union Availability { + InStock { count: Int } + Backordered { until: DateTime } + Discontinued +} +``` + +```mustache {"dmx":{"output":"lib/models.dart"}} +{{#declarations}} +{{#isRecord}} +final class {{name}}{{genericDeclaration}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/isRecord}} +{{#isUnion}} +sealed class {{name}}{{genericDeclaration}} { + const {{name}}(); +} +{{#variants}} + +final class {{name}} extends {{#last}}{{/last}}Availability { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/variants}} +{{/isUnion}} +{{/declarations}} +``` + +```mustache {"dmx":{"output":"lib/names.dart"}} +/// The declared names, in source order. +const declaredNames = [ +{{#declarations}} + '{{name}}', +{{/declarations}} +]; +``` + +That is the whole document. +"#; + +/// A workspace with `docs/store.dmx.md` in it, plus whatever else a test adds. +struct Workspace { + /// The scratch directory, removed when the test ends. + directory: TempDirectory, +} + +impl Workspace { + /// A workspace holding one document at `docs/store.dmx.md`. + fn with(document: &str) -> Self { + let directory = TempDirectory::create("dmx-typediagram").expect("scratch directory"); + fs::create_dir_all(directory.at("lib")).expect("lib directory"); + let _ = directory + .write("docs/store.dmx.md", document) + .expect("write the document"); + Self { directory } + } + + /// The workspace root. + fn root(&self) -> &Path { + &self.directory.path + } + + /// One path inside it. + fn path(&self, relative: &str) -> PathBuf { + self.directory.at(relative) + } + + /// The contents of one file inside it. + fn read(&self, relative: &str) -> String { + fs::read_to_string(self.path(relative)) + .unwrap_or_else(|e| panic!("cannot read {relative}: {e}")) + } + + /// Whether one path inside it exists. + fn exists(&self, relative: &str) -> bool { + self.path(relative).exists() + } + + /// Writes one file inside it, creating the directories it needs. + fn write(&self, relative: &str, contents: &str) { + let _ = self.directory.write(relative, contents).expect("write"); + } + + /// Runs `dmx` from the workspace root, as a shell in it would. + fn dmx(&self, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_dmx")) + .args(args) + .current_dir(self.root()) + .output() + .expect("run dmx") + } + + /// Runs `dmx build docs lib` and requires it to succeed. + fn build(&self) -> String { + let output = self.dmx(&["build", "docs", "lib"]); + assert!( + output.status.success(), + "build failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + + /// Runs `dmx build docs lib` and requires it to fail, returning stderr. + fn build_failure(&self) -> String { + let output = self.dmx(&["build", "docs", "lib"]); + assert!( + !output.status.success(), + "build should have failed; stdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + String::from_utf8_lossy(&output.stderr).into_owned() + } +} + +/// [typediagram.execution]: one document, two templates, two owned files — +/// and a second build that writes nothing. +#[test] +fn a_document_generates_every_bound_template_once() { + let workspace = Workspace::with(STORE); + + let first = workspace.build(); + assert!(first.contains("wrote: docs/store.dmx.md"), "{first}"); + + let models = workspace.read("lib/models.dart"); + assert!(models.starts_with("// dmx: generated from docs/store.dmx.md — do not edit.")); + assert!(models.contains("// dmx: group 1, fences 1/2,"), "{models}"); + assert!(models.contains("final class Product {"), "{models}"); + assert!( + models.contains( + "const Product({required this.id, required this.name, required this.price, this.note});" + ), + "{models}" + ); + assert!(models.contains(" final String? note;"), "{models}"); + assert!(models.contains("sealed class Availability {"), "{models}"); + assert!( + models.contains("final class InStock extends Availability {"), + "{models}" + ); + assert!(models.contains(" final DateTime until;"), "{models}"); + + let names = workspace.read("lib/names.dart"); + assert!(names.contains("// dmx: group 1, fences 1/3,"), "{names}"); + assert!(names.contains("'Product',"), "{names}"); + assert!(names.contains("'Availability',"), "{names}"); + + let second = workspace.build(); + assert!( + second.contains("dmx: 0 of") && !second.contains("wrote:"), + "a second build must write nothing:\n{second}" + ); + assert_eq!(workspace.read("lib/models.dart"), models); + assert_eq!( + workspace.read("docs/store.dmx.md"), + STORE, + "the document is never rewritten" + ); +} + +/// [typediagram.execution]: generation is deterministic — the same document +/// produces the same bytes from a clean workspace every time. +#[test] +fn generation_is_byte_identical_across_workspaces() { + let first = Workspace::with(STORE); + let _ = first.build(); + let second = Workspace::with(STORE); + let _ = second.build(); + assert_eq!( + first.read("lib/models.dart"), + second.read("lib/models.dart") + ); + assert_eq!(first.read("lib/names.dart"), second.read("lib/names.dart")); +} + +/// [typediagram.documents]: CRLF input generates the same model, and the +/// document still is not rewritten. +#[test] +fn a_crlf_document_generates_the_same_model() { + let workspace = Workspace::with(&STORE.replace('\n', "\r\n")); + let _ = workspace.build(); + assert!( + workspace + .read("lib/models.dart") + .contains("final class Product {") + ); + assert!(workspace.read("docs/store.dmx.md").contains("\r\n")); +} + +/// [typediagram.execution]: `--check` reports drift, exits 2, and writes +/// nothing; once the outputs are current it exits 0. +#[test] +fn check_reports_drift_and_writes_nothing() { + let workspace = Workspace::with(STORE); + + let drift = workspace.dmx(&["build", "docs", "lib", "--check"]); + assert_eq!(drift.status.code(), Some(2), "drift must exit 2"); + assert!( + String::from_utf8_lossy(&drift.stdout).contains("drift: docs/store.dmx.md"), + "{}", + String::from_utf8_lossy(&drift.stdout) + ); + assert!(!workspace.exists("lib/models.dart"), "--check never writes"); + + let _ = workspace.build(); + let current = workspace.dmx(&["build", "docs", "lib", "--check"]); + assert!(current.status.success(), "a current workspace has no drift"); +} + +/// [typediagram.documents]: recursive discovery takes `*.dmx.md`; another +/// Markdown file is documentation until somebody names it. +#[test] +fn other_markdown_is_documentation_until_it_is_named() { + let workspace = Workspace::with(STORE); + workspace.write( + "docs/notes.md", + "```typeDiagram\ntype Note { body: String }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/notes.dart\"}}\n// {{#declarations}}{{name}}{{/declarations}}\n```\n", + ); + + let _ = workspace.build(); + assert!( + !workspace.exists("lib/notes.dart"), + "an ordinary .md is not discovered" + ); + + let named = workspace.dmx(&["build", "docs/notes.md"]); + assert!( + named.status.success(), + "{}", + String::from_utf8_lossy(&named.stderr) + ); + assert!(workspace.read("lib/notes.dart").contains("// Note")); +} + +/// [typediagram.binding]: a definition nobody templates, a Mustache fence with +/// no dmx metadata, and an unrelated fence all generate nothing. +#[test] +fn documentation_only_content_generates_nothing() { + let workspace = Workspace::with( + "# Notes\n\n```typeDiagram\ntype A { x: Int }\n```\n\n```mustache\n{{name}}\n```\n\n```dart\nclass A {}\n```\n", + ); + let output = workspace.build(); + assert!(output.contains("dmx: 0 of"), "{output}"); + assert!(!workspace.exists("lib/a.dart")); +} + +/// [typediagram.output]: a hand-written file is never overwritten, and the +/// build fails rather than proceeding. +#[test] +fn a_hand_written_output_is_never_overwritten() { + let workspace = Workspace::with(STORE); + workspace.write("lib/models.dart", "// mine, by hand\n"); + + let error = workspace.build_failure(); + assert!(error.contains("DMX8006"), "{error}"); + assert_eq!(workspace.read("lib/models.dart"), "// mine, by hand\n"); +} + +/// [typediagram.output]: a dropped template drops its file. +#[test] +fn a_removed_template_collects_its_output() { + let workspace = Workspace::with(STORE); + let _ = workspace.build(); + assert!(workspace.exists("lib/names.dart")); + + let trimmed = STORE + .split("```mustache {\"dmx\":{\"output\":\"lib/names.dart\"}}") + .next() + .expect("the document up to the second template") + .to_owned(); + workspace.write("docs/store.dmx.md", &trimmed); + + let _ = workspace.build(); + assert!( + workspace.exists("lib/models.dart"), + "the surviving output stays" + ); + assert!( + !workspace.exists("lib/names.dart"), + "the dropped output goes" + ); +} + +/// [typediagram.diagnostics]: every refusal names its code, the document, and +/// the line — and none of them writes anything. +#[test] +fn every_refusal_is_coded_and_located() { + for (code, needle, document) in [ + ( + "DMX8001", + "line 5", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"ouput\":\"lib/a.dart\"}}\na\n```\n", + ), + ( + "DMX8002", + "line 1", + "```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n", + ), + ( + "DMX8003", + "lines 5 and 9", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nb\n```\n", + ), + ( + "DMX8004", + "line 2, column 13", + "```typeDiagram\ntype A { x: Timestamp }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\n// a\n```\n", + ), + ( + "DMX8005", + "leaves the workspace", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"../escape.dart\"}}\n// a\n```\n", + ), + ( + "DMX8007", + "available: dart", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\",\"target\":\"kotlin\"}}\n// a\n```\n", + ), + ( + "DMX8008", + "template fence on line 5", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nfinal class {{#declarations}}{{name}}{{/declarations}} {\n```\n", + ), + ( + "DMX4003", + "never throws", + "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nint probe() => throw StateError('{{#declarations}}{{name}}{{/declarations}}');\n```\n", + ), + ] { + let workspace = Workspace::with(document); + let error = workspace.build_failure(); + assert!(error.contains(code), "expected {code}:\n{error}"); + assert!( + error.contains(needle), + "expected {needle:?} in {code}:\n{error}" + ); + assert!( + error.contains("store.dmx.md"), + "{code} must name the document:\n{error}" + ); + assert!( + !workspace.exists("lib/a.dart"), + "{code} wrote an output anyway" + ); + assert!( + !workspace.exists("escape.dart"), + "{code} wrote outside the workspace" + ); + } +} + +/// [typediagram.execution]: `explain` prints the groups, their dependencies, +/// their outputs, and the exact context — and generates nothing. +#[test] +fn explain_prints_the_context_and_writes_nothing() { + let workspace = Workspace::with(STORE); + let output = workspace.dmx(&["explain", "docs/store.dmx.md"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report = String::from_utf8_lossy(&output.stdout); + + assert!( + report.contains("docs/store.dmx.md: 1 generation group(s)"), + "{report}" + ); + assert!(report.contains("2 declaration(s)"), "{report}"); + assert!( + report.contains("-> lib/models.dart (target dart, fence 2"), + "{report}" + ); + assert!( + report.contains("-> lib/names.dart (target dart, fence 3"), + "{report}" + ); + assert!(report.contains("\"modelVersion\": 1"), "{report}"); + assert!(report.contains("\"isRecord\": true"), "{report}"); + assert!(report.contains("\"dartType\": \"String?\""), "{report}"); + assert!( + report.contains("\"typeDiagram\": \"Option\""), + "{report}" + ); + assert!( + !workspace.exists("lib/models.dart"), + "explain never generates" + ); + + // `explain` names one file. There is no useful default, and a Dart source + // is not what it explains yet. + for (args, needle) in [ + (vec!["explain"], "takes exactly one file"), + (vec!["explain", "docs", "lib"], "takes exactly one file"), + (vec!["explain", "lib/models.dart"], "Markdown documents"), + ] { + let refused = workspace.dmx(&args); + assert!( + !refused.status.success(), + "`dmx {}` should have failed", + args.join(" ") + ); + assert!( + String::from_utf8_lossy(&refused.stderr).contains(needle), + "`dmx {}`:\n{}", + args.join(" "), + String::from_utf8_lossy(&refused.stderr) + ); + } +} + +/// [typediagram.execution]: prose outside a group is not a dependency of its +/// output, and a definition change is. +#[test] +fn only_the_group_is_a_dependency_of_its_output() { + let workspace = Workspace::with(STORE); + let _ = workspace.build(); + let before = workspace.read("lib/models.dart"); + + workspace.write( + "docs/store.dmx.md", + &format!("{STORE}\nAn added paragraph.\n"), + ); + let after_prose = workspace.build(); + assert!( + after_prose.contains("dmx: 0 of"), + "prose is not a dependency:\n{after_prose}" + ); + assert_eq!(workspace.read("lib/models.dart"), before); + + workspace.write( + "docs/store.dmx.md", + &STORE.replace("price: Decimal", "price: Float"), + ); + let _ = workspace.build(); + let after_definition = workspace.read("lib/models.dart"); + assert_ne!( + after_definition, before, + "a definition change is a dependency" + ); + assert!( + after_definition.contains("final double price;"), + "{after_definition}" + ); +} + +/// [typediagram.macro]: `typeDiagram` is a built-in macro name, so an +/// annotation may not claim it and a Dart file may not be generated by it. +#[test] +fn the_builtin_name_is_not_an_annotation() { + let workspace = Workspace::with(STORE); + workspace.write( + "lib/hand.dart", + "@dmx('typeDiagram')\nclass Hand {\n final int a = 0;\n}\n", + ); + let error = workspace.build_failure(); + assert!(error.contains("DMX2006"), "{error}"); + assert!(error.contains("Markdown generation group"), "{error}"); +} + +/// [typediagram.output]: an output path is resolved against the package the +/// document belongs to, so the same document generates the same bytes in the +/// same place however dmx was launched. +#[test] +fn outputs_land_in_the_package_the_document_belongs_to() { + let workspace = Workspace::with("# empty\n"); + workspace.write("packages/store/pubspec.yaml", "name: store\n"); + workspace.write("packages/store/docs/models.dmx.md", STORE); + + // From the repository root, naming the package's document directory. + let root_run = workspace.dmx(&["build", "packages"]); + assert!( + root_run.status.success(), + "{}", + String::from_utf8_lossy(&root_run.stderr) + ); + assert!( + workspace.exists("packages/store/lib/models.dart"), + "the output belongs to the package, not to the directory dmx ran in" + ); + assert!(!workspace.exists("lib/models.dart")); + let from_root = workspace.read("packages/store/lib/models.dart"); + assert!( + from_root.starts_with("// dmx: generated from docs/models.dmx.md"), + "the document is recorded relative to its own package:\n{from_root}" + ); + + // From inside the package, naming the same document relatively. + let inside = Command::new(env!("CARGO_BIN_EXE_dmx")) + .args(["build", "docs", "lib"]) + .current_dir(workspace.path("packages/store")) + .output() + .expect("run dmx"); + assert!( + inside.status.success(), + "{}", + String::from_utf8_lossy(&inside.stderr) + ); + assert!( + String::from_utf8_lossy(&inside.stdout).contains("dmx: 0 of"), + "the same document from another directory must write nothing:\n{}", + String::from_utf8_lossy(&inside.stdout) + ); + assert_eq!(workspace.read("packages/store/lib/models.dart"), from_root); +} + +/// [typediagram.output]: two live documents may not both generate one file, +/// but a renamed document takes its own outputs with it. +#[test] +fn one_output_has_one_live_source() { + let workspace = Workspace::with(STORE); + let _ = workspace.build(); + assert!(workspace.exists("lib/models.dart")); + + // A second document claiming the same output: each pass would undo the + // other's, so the build fails instead of flip-flopping. + workspace.write("docs/rival.dmx.md", STORE); + let error = workspace.build_failure(); + assert!(error.contains("DMX8006"), "{error}"); + assert!(error.contains("already generated from"), "{error}"); + assert!(error.contains("store.dmx.md"), "{error}"); + + // Renaming the document is not a collision: the marker names a source that + // is gone, so the new one takes its own outputs over. + fs::remove_file(workspace.path("docs/rival.dmx.md")).expect("remove the rival"); + fs::rename( + workspace.path("docs/store.dmx.md"), + workspace.path("docs/renamed.dmx.md"), + ) + .expect("rename the document"); + + let after = workspace.build(); + assert!(after.contains("wrote: docs/renamed.dmx.md"), "{after}"); + assert!( + workspace + .read("lib/models.dart") + .starts_with("// dmx: generated from docs/renamed.dmx.md"), + "{}", + workspace.read("lib/models.dart") + ); +} diff --git a/src/dmx/tests/typediagram_model.rs b/src/dmx/tests/typediagram_model.rs new file mode 100644 index 0000000..6a3aa3d --- /dev/null +++ b/src/dmx/tests/typediagram_model.rs @@ -0,0 +1,180 @@ +//! The typeDiagram compatibility corpus [typediagram.delivery.baseline]. +//! +//! Every fixture in `tests/typediagram/corpus` carries the model JSON the +//! upstream parser and model builder produce for it, generated by +//! `scripts/typediagram-oracle.mjs` from a real typeDiagram checkout. This +//! suite parses and resolves the same fixture with the *Rust* front end and +//! requires the two models to be structurally identical. +//! +//! That is the whole point of the corpus. dmx never runs typeDiagram in +//! production — no Node, no npm package, no `typediagram` executable, no +//! network — so the only thing that can keep the two in step is a gate that +//! fails when they diverge. Upstream language drift shows up here, in CI, +//! rather than as Dart somebody's build generated wrongly. + +// [TEST-RULES] admits `expect` in a test: a fixture that cannot be built is a +// broken test, and unwinding at the point of failure names it better than any +// `Result` plumbing would. Production code is still held to `unwrap_used` and +// `expect_used` at deny — this relaxation is `cfg(test)`-scoped on purpose. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects + ) +)] + +use std::fs; +use std::path::PathBuf; + +use dmx::typediagram::json::{SCHEMA_VERSION, to_json}; +use dmx::typediagram::model::Model; +use dmx::typediagram::parser::parse; + +/// Where the fixtures live. +fn corpus() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("typediagram") + .join("corpus") +} + +/// Every `*.td` fixture, in a stable order, with its oracle JSON beside it. +fn fixtures() -> Vec<(String, String, serde_json::Value)> { + let mut found: Vec<(String, String, serde_json::Value)> = fs::read_dir(corpus()) + .expect("the corpus directory") + .filter_map(|entry| { + let path = entry.expect("a corpus entry").path(); + if path.extension()? != "td" { + return None; + } + let name = path.file_name()?.to_string_lossy().into_owned(); + let oracle = path.with_extension("model.json"); + let expected = serde_json::from_str(&fs::read_to_string(&oracle).unwrap_or_else(|e| { + panic!( + "{}: no oracle model beside it ({e}); run \ + `node scripts/typediagram-oracle.mjs --typediagram `", + oracle.display() + ) + })) + .unwrap_or_else(|e| panic!("{}: oracle is not JSON: {e}", oracle.display())); + Some(( + name, + fs::read_to_string(&path).expect("a fixture"), + expected, + )) + }) + .collect(); + found.sort_by(|a, b| a.0.cmp(&b.0)); + assert!( + found.len() >= 5, + "the compatibility corpus must cover the language reference, found {} fixture(s)", + found.len() + ); + found +} + +/// The model JSON dmx's own front end produces for `source`. +fn dmx_model(name: &str, source: &str) -> serde_json::Value { + let diagram = parse(source).unwrap_or_else(|e| panic!("{name} did not parse:\n{e}")); + let model = Model::resolve(diagram).unwrap_or_else(|e| panic!("{name} did not resolve:\n{e}")); + to_json(&model) +} + +/// [typediagram.delivery.baseline]: the Rust model and the upstream model +/// agree, declaration for declaration, over the whole corpus. +#[test] +fn the_rust_model_matches_the_upstream_oracle() { + for (name, source, expected) in fixtures() { + let actual = dmx_model(&name, &source); + assert_eq!( + actual, + expected, + "{name}: the Rust model diverged from the typeDiagram oracle\n\ + dmx: {}\n\ + upstream: {}", + serde_json::to_string_pretty(&actual).unwrap_or_default(), + serde_json::to_string_pretty(&expected).unwrap_or_default(), + ); + } +} + +/// [typediagram.delivery.baseline]: the corpus is pinned to one schema +/// version, and a bump has to be a deliberate change to this repository. +#[test] +fn the_corpus_pins_one_model_schema_version() { + for (name, _, expected) in fixtures() { + assert_eq!( + expected["version"], + serde_json::json!(SCHEMA_VERSION), + "{name}: the oracle was generated at a different model schema version; \ + DMX8007 exists for exactly this" + ); + } +} + +/// [typediagram.delivery.baseline]: the corpus actually covers the language +/// reference, rather than five fixtures that happen to agree about records. +#[test] +fn the_corpus_covers_every_declaration_form() { + let mut kinds: Vec = Vec::new(); + let mut features = (false, false, false, false, false, false); + for (_, _, expected) in fixtures() { + for decl in expected["decls"].as_array().expect("decls") { + if let Some(kind) = decl["kind"].as_str() + && !kinds.iter().any(|seen| seen == kind) + { + kinds.push(kind.to_owned()); + } + features.0 |= !decl["generics"].as_array().is_none_or(Vec::is_empty); + features.1 |= decl["untagged"] == serde_json::json!(true); + features.2 |= decl["targeting"].is_object(); + for variant in decl["variants"].as_array().into_iter().flatten() { + features.3 |= variant["discriminant"].is_string(); + features.4 |= variant["fields"][0]["name"] == serde_json::json!("_0"); + } + for signature in decl["signatures"].as_array().into_iter().flatten() { + features.5 |= signature["async"] == serde_json::json!(true); + } + } + } + kinds.sort_unstable(); + assert_eq!(kinds, ["alias", "function", "record", "union"]); + assert_eq!( + features, + (true, true, true, true, true, true), + "generics, untagged, targeting, discriminants, tuple variants, and async \ + signatures must all appear in the corpus" + ); +} + +/// [typediagram.model]: nested type arguments survive the whole round trip, so +/// a `Map>>` in the corpus is not silently +/// flattened by either side. +#[test] +fn deeply_nested_arguments_survive() { + let (name, source, _) = fixtures() + .into_iter() + .find(|(name, ..)| name == "scalars.td") + .expect("the scalars fixture"); + let model = dmx_model(&name, &source); + let index = model["decls"][0]["fields"] + .as_array() + .expect("fields") + .iter() + .find(|field| field["name"] == serde_json::json!("index")) + .expect("the index field"); + assert_eq!(index["type"]["name"], serde_json::json!("Map")); + assert_eq!(index["type"]["args"][1]["name"], serde_json::json!("List")); + assert_eq!( + index["type"]["args"][1]["args"][0]["name"], + serde_json::json!("Option") + ); + assert_eq!( + index["type"]["args"][1]["args"][0]["args"][0]["name"], + serde_json::json!("Decimal") + ); +} diff --git a/src/dmx/tests/watch_cli.rs b/src/dmx/tests/watch_cli.rs index 415e3a0..86fc13b 100644 --- a/src/dmx/tests/watch_cli.rs +++ b/src/dmx/tests/watch_cli.rs @@ -94,9 +94,29 @@ impl WatchProcess { } fn spawn(path: &Path) -> io::Result { - let mut child = Command::new(env!("CARGO_BIN_EXE_dmx")) - .arg("watch") - .arg(path) + Self::spawn_args(None, &[path.as_os_str()]) + } + + /// A watcher started *inside* `directory`, watching the relative paths + /// `args` names. + /// + /// A Markdown document's outputs are workspace-relative + /// [typediagram.output], so where the watcher runs is part of what it does + /// — which is the one thing `spawn` cannot express. + fn spawn_ready_in(directory: &Path, args: &[&str]) -> io::Result { + let owned: Vec<&std::ffi::OsStr> = args.iter().map(std::ffi::OsStr::new).collect(); + let mut watcher = Self::spawn_args(Some(directory), &owned)?; + watcher.wait_until_ready(args.len())?; + Ok(watcher) + } + + fn spawn_args(directory: Option<&Path>, args: &[&std::ffi::OsStr]) -> io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_dmx")); + let _ = command.arg("watch").args(args); + if let Some(directory) = directory { + let _ = command.current_dir(directory); + } + let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; @@ -123,6 +143,22 @@ impl WatchProcess { self.wait_for_log(READY_TIMEOUT, |line| line == expected, &expected) } + /// Waits for a line on `stream` carrying `needle`. + /// + /// The exact-match waiters below spell out a whole line because a Dart + /// source's write line is one path and nothing else. A document is named + /// by both its write line and its diagnostics, so what identifies which + /// one arrived is the stream it arrived on. + fn wait_for_line_on(&mut self, stream: &str, needle: &str) -> io::Result<()> { + let prefix = stream.to_owned(); + let expected = format!("{stream}…{needle}"); + self.wait_for_log( + REGENERATION_TIMEOUT, + move |line| line.starts_with(&prefix) && line.contains(needle), + &expected, + ) + } + fn wait_for_write(&mut self, path: &Path) -> io::Result<()> { let expected = write_log(path)?; self.wait_for_log(REGENERATION_TIMEOUT, |line| line == expected, &expected) @@ -884,19 +920,22 @@ fn watch_rejects_a_missing_root() -> io::Result<()> { Ok(()) } -/// Verifies explicit watch targets obey source inclusion [surface.zero-config] and [cli]. +/// Verifies explicit watch targets obey source inclusion [surface.zero-config], +/// [typediagram.documents] and [cli]. #[test] -fn watch_rejects_an_explicit_non_dart_file_without_reporting_readiness() -> io::Result<()> { +fn watch_rejects_an_explicit_unsupported_file_without_reporting_readiness() -> io::Result<()> { let directory = TempDirectory::create("dmx-watch-cli")?; - let non_dart = directory.path.join("notes.txt"); - fs::write(&non_dart, "not Dart\n")?; - let output = run_watch_target_to_exit(&non_dart)?; + let unsupported = directory.path.join("notes.txt"); + fs::write(&unsupported, "not a source\n")?; + let output = run_watch_target_to_exit(&unsupported)?; let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = failed_stderr(&output, &non_dart, "non-Dart target was accepted"); + let stderr = failed_stderr(&output, &unsupported, "unsupported target was accepted"); assert!(stdout.is_empty(), "unexpected stdout:\n{stdout}"); assert!( - stderr.starts_with("error: DMX1002 [cli]: watch target is not a Dart source:"), + stderr.starts_with( + "error: DMX1002 [cli]: watch target is not a Dart source or a Markdown document:" + ), "unexpected stderr:\n{stderr}" ); assert!( @@ -974,3 +1013,142 @@ fn watch_regenerates_a_region_gutted_twice_in_a_row() -> io::Result<()> { ); Ok(()) } + +/// The document every typeDiagram watch test starts from. +const DOCUMENT: &str = r#"# Shipping + +```typeDiagram +type Parcel { + id: Uuid + weightG: Int +} +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +``` +"#; + +/// A workspace holding one `*.dmx.md` document, watched from inside it. +struct WatchedDocument { + directory: TempDirectory, + watcher: WatchProcess, +} + +impl WatchedDocument { + fn create() -> io::Result { + let directory = TempDirectory::create("dmx-watch-typediagram")?; + let _ = directory.write("docs/shipping.dmx.md", DOCUMENT)?; + fs::create_dir_all(directory.at("lib"))?; + let watcher = WatchProcess::spawn_ready_in(&directory.path, &["docs", "lib"])?; + Ok(Self { directory, watcher }) + } + + /// The generated output, which the first pass has already written. + fn output(&self) -> io::Result { + fs::read_to_string(self.directory.at("lib/parcel.dart")) + } + + /// Replaces the document, which is what a save is. + fn save(&self, document: &str) -> io::Result<()> { + let _ = self.directory.write("docs/shipping.dmx.md", document)?; + Ok(()) + } +} + +/// [typediagram.execution]: the first pass generates the document's outputs +/// before the watcher reports readiness, and a saved definition regenerates +/// them. +#[test] +fn watch_generates_a_document_and_regenerates_it_on_save() -> io::Result<()> { + let mut fixture = WatchedDocument::create()?; + let first = fixture.output()?; + assert!(first.contains("final class Parcel {"), "{first}"); + assert!(first.contains("final int weightG;"), "{first}"); + + fixture.save(&DOCUMENT.replace("weightG: Int", "weightG: Float"))?; + fixture + .watcher + .wait_for_line_on("stdout: wrote: ", "shipping.dmx.md")?; + + let second = fixture.output()?; + assert!(second.contains("final double weightG;"), "{second}"); + assert!( + fixture.watcher.is_running()?, + "watcher stopped after a document save:\n{}", + fixture.watcher.output() + ); + Ok(()) +} + +/// [typediagram.execution]: an invalid save keeps the last valid output, and +/// the next valid save recovers — without the watcher exiting. +#[test] +fn watch_retains_the_last_valid_output_and_recovers() -> io::Result<()> { + let mut fixture = WatchedDocument::create()?; + let valid = fixture.output()?; + + fixture.save(&DOCUMENT.replace("weightG: Int", "weightG:"))?; + fixture.watcher.wait_for_line_on("stderr: ", "DMX8004")?; + assert_eq!( + fixture.output()?, + valid, + "an invalid definition must leave the last valid output alone" + ); + + fixture.save(&DOCUMENT.replace("weightG: Int", "weightG: Bool"))?; + fixture + .watcher + .wait_for_line_on("stdout: wrote: ", "shipping.dmx.md")?; + let recovered = fixture.output()?; + assert!(recovered.contains("final bool weightG;"), "{recovered}"); + assert!( + fixture.watcher.is_running()?, + "watcher stopped after recovery" + ); + Ok(()) +} + +/// [typediagram.execution]: prose outside a generation group is not a +/// dependency, so saving it regenerates nothing. +#[test] +fn watch_ignores_a_change_to_prose_outside_a_group() -> io::Result<()> { + let mut fixture = WatchedDocument::create()?; + let before = fixture.output()?; + // The first pass has already written once, so what a prose-only save must + // not do is write AGAIN. + let writes = fixture.watcher.writes().len(); + + fixture.save(&format!("{DOCUMENT}\nA paragraph somebody added.\n"))?; + fixture.watcher.observe_for(QUIET_PERIOD); + + assert_eq!(fixture.output()?, before, "prose is not a dependency"); + assert_eq!( + fixture.watcher.writes().len(), + writes, + "a prose-only save regenerated:\n{}", + fixture.watcher.output() + ); + assert!( + !fixture + .watcher + .observed + .iter() + .any(|line| line.starts_with("stderr: ")), + "a prose-only save produced an error:\n{}", + fixture.watcher.output() + ); + assert!( + fixture.watcher.is_running()?, + "watcher stopped after a prose-only save" + ); + Ok(()) +} diff --git a/src/editors/vscode/e2e/fixture.js b/src/editors/vscode/e2e/fixture.js index 3a320a6..bb0b87b 100644 --- a/src/editors/vscode/e2e/fixture.js +++ b/src/editors/vscode/e2e/fixture.js @@ -16,4 +16,36 @@ class ${name} { `; } -module.exports = { annotatedClass }; +// A `*.dmx.md` document [typediagram.documents]: one definition, one bound +// template, and prose around both. There is no Dart source behind it — the +// point of the fixture is that the extension generates from a Markdown file +// with nothing annotated anywhere. + +function document(typeName, fieldName) { + return `# ${typeName} + +The definition below is the source of truth. + +\`\`\`typeDiagram +type ${typeName} { + ${fieldName}: String +} +\`\`\` + +\`\`\`mustache {"dmx":{"output":"lib/${typeName.toLowerCase()}.dart"}} +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +\`\`\` + +That is the whole document. +`; +} + +module.exports = { annotatedClass, document }; diff --git a/src/editors/vscode/e2e/run.js b/src/editors/vscode/e2e/run.js index 052b3e5..8cdc55e 100644 --- a/src/editors/vscode/e2e/run.js +++ b/src/editors/vscode/e2e/run.js @@ -14,7 +14,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { runTests } = require('@vscode/test-electron'); -const { annotatedClass } = require('./fixture.js'); +const { annotatedClass, document } = require('./fixture.js'); const BINARY = process.platform === 'win32' ? 'dmx.exe' : 'dmx'; @@ -35,6 +35,10 @@ function writeWorkspace(workspace) { ); fs.writeFileSync(path.join(workspace, 'lib', 'profile.dart'), annotatedClass('Profile', 'handle')); fs.writeFileSync(path.join(workspace, 'lib', 'settings.dart'), annotatedClass('Settings', 'theme')); + // A document with no Dart behind it [typediagram.documents]: the extension + // has to find it, watch it, and generate the file it names. + fs.mkdirSync(path.join(workspace, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'docs', 'shipping.dmx.md'), document('Parcel', 'tracking')); } async function main() { diff --git a/src/editors/vscode/e2e/suite/watch.e2e.js b/src/editors/vscode/e2e/suite/watch.e2e.js index 746e5a1..ab68e10 100644 --- a/src/editors/vscode/e2e/suite/watch.e2e.js +++ b/src/editors/vscode/e2e/suite/watch.e2e.js @@ -251,6 +251,43 @@ describe('the packaged VSIX, running the engine it carries', () => { assertUserCode('lib/settings.dart', ['final int retries;', 'required this.retries']); }); + it('generates from a *.dmx.md document, and regenerates when it is saved', async () => { + // No annotation anywhere: the only source of truth is the diagram in + // docs/shipping.dmx.md [typediagram.documents]. + await until('the document to generate lib/parcel.dart', () => { + try { + return read('lib/parcel.dart').includes('final class Parcel {'); + } catch { + return false; + } + }); + const generated = read('lib/parcel.dart'); + assert.ok( + generated.startsWith('// dmx: generated from docs/shipping.dmx.md'), + `lib/parcel.dart carries no ownership marker:\n${generated}`, + ); + assert.match(generated, /const Parcel\(\{required this\.tracking\}\);/); + assert.match(generated, /final String tracking;/); + + // Saving the document regenerates it, with no command. + const opened = await openInEditor('docs/shipping.dmx.md'); + await editOnce(opened.editor, 'tracking: String', 'tracking: String\n weightG: Int'); + assert.ok(await opened.document.save(), 'docs/shipping.dmx.md did not save'); + await until('the saved document to regenerate lib/parcel.dart', () => + read('lib/parcel.dart').includes('final int weightG;'), + ); + assert.match( + read('lib/parcel.dart'), + /const Parcel\(\{required this\.tracking, required this\.weightG\}\);/, + ); + + // The document itself is never rewritten. + assert.ok( + read('docs/shipping.dmx.md').includes('That is the whole document.'), + 'the document was rewritten', + ); + }); + it('stop, build, and restart from the palette all drive the real engine', async () => { await vscode.commands.executeCommand('dmx.stopWatcher'); diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 410f0df..87d5a11 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -48,6 +48,7 @@ ], "activationEvents": [ "workspaceContains:**/pubspec.yaml", + "workspaceContains:**/*.dmx.md", "onLanguage:dart" ], "contributes": { diff --git a/src/editors/vscode/paths.js b/src/editors/vscode/paths.js index 39b5eb5..fa411be 100644 --- a/src/editors/vscode/paths.js +++ b/src/editors/vscode/paths.js @@ -9,7 +9,9 @@ // the editor, from a generator that has stopped working. // // So: what the setting names, if it exists, and otherwise every Dart package -// this folder actually holds. +// this folder actually holds — plus every `*.dmx.md` document in it, because a +// document generates Dart with no annotated Dart source to find it by +// [typediagram.documents]. const fs = require('node:fs'); const path = require('node:path'); @@ -72,6 +74,37 @@ function packageLibraries(root, depth = MAX_DEPTH) { return found; } +/// The suffix that makes a Markdown file one dmx generates from. +const DOCUMENT_SUFFIX = '.dmx.md'; + +/// Every `*.dmx.md` document under `root`, as workspace-relative file paths. +/// +/// Files rather than their directories: `dmx watch` takes either, and naming +/// the document watches exactly it, where naming `docs/` would watch a whole +/// tree of prose for changes that can never matter. A package's own +/// subdirectories ARE searched, unlike `packageLibraries` — a document +/// normally lives in the package whose `lib` it generates into. +function documents(root, depth = MAX_DEPTH + 1) { + const found = []; + let entries = []; + try { + entries = fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return found; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || SKIPPED.has(entry.name)) { + continue; + } + if (entry.isFile() && entry.name.endsWith(DOCUMENT_SUFFIX)) { + found.push(entry.name); + } else if (entry.isDirectory() && depth > 1) { + found.push(...documents(path.join(root, entry.name), depth - 1).map((relative) => path.join(entry.name, relative))); + } + } + return found; +} + /// The paths to hand `dmx`, in the order they were worked out. /// /// `configured` is what somebody set explicitly, and it is honoured exactly: @@ -83,7 +116,7 @@ function watchTargets(root, configured, explicit) { if (explicit) { return present; } - return [...new Set([...present, ...packageLibraries(root)])]; + return [...new Set([...present, ...packageLibraries(root), ...documents(root)])]; } -module.exports = { packageLibraries, watchTargets }; +module.exports = { documents, packageLibraries, watchTargets }; diff --git a/src/editors/vscode/test/paths.test.js b/src/editors/vscode/test/paths.test.js index 3a56f1c..1a2c934 100644 --- a/src/editors/vscode/test/paths.test.js +++ b/src/editors/vscode/test/paths.test.js @@ -11,7 +11,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); -const { packageLibraries, watchTargets } = require('../paths.js'); +const { documents, packageLibraries, watchTargets } = require('../paths.js'); /// A throwaway workspace holding `directories`, each made a package when its /// entry says so. @@ -92,3 +92,53 @@ test('a pubspec without lib is not a package to generate into', () => { assert.deepEqual(packageLibraries(root), []); }); + +/// A workspace holding `files`, each written with placeholder content. +function withFiles(layout, files) { + const root = workspace(layout); + for (const relative of files) { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, '# a document\n'); + } + return root; +} + +test('every *.dmx.md document is watched, wherever it lives', () => { + const root = withFiles({ 'packages/store': true, docs: false }, [ + 'models.dmx.md', + 'docs/shipping.dmx.md', + 'packages/store/docs/store.dmx.md', + 'docs/README.md', + 'packages/store/lib/notes.md', + ]); + + assert.deepEqual(documents(root), [ + path.join('docs', 'shipping.dmx.md'), + 'models.dmx.md', + path.join('packages', 'store', 'docs', 'store.dmx.md'), + ]); + + const targets = watchTargets(root, ['lib'], false); + assert.ok(targets.includes(path.join('packages', 'store', 'lib')), targets.join(', ')); + assert.ok(targets.includes(path.join('docs', 'shipping.dmx.md')), targets.join(', ')); + assert.ok(!targets.includes(path.join('docs', 'README.md')), targets.join(', ')); +}); + +test('build output and hidden directories hold no documents worth watching', () => { + const root = withFiles({ build: false, node_modules: false, '.git': false }, [ + 'build/generated.dmx.md', + 'node_modules/pkg/thing.dmx.md', + '.git/stash.dmx.md', + 'kept.dmx.md', + ]); + assert.deepEqual(documents(root), ['kept.dmx.md']); +}); + +test('explicit paths are honoured exactly, documents included or not', () => { + const root = withFiles({ docs: false }, ['docs/shipping.dmx.md']); + assert.deepEqual(watchTargets(root, [path.join('docs', 'shipping.dmx.md')], true), [ + path.join('docs', 'shipping.dmx.md'), + ]); + assert.deepEqual(watchTargets(root, ['lib'], true), []); +}); diff --git a/website/e2e/navigation.spec.ts b/website/e2e/navigation.spec.ts index bf845b1..60eb0ec 100644 --- a/website/e2e/navigation.spec.ts +++ b/website/e2e/navigation.spec.ts @@ -168,6 +168,12 @@ test("shows the blog post image on the article and blog listing", async ({ page ? image.naturalWidth : 0)).toBeGreaterThan(0); + await page.goto("/docs/models-in-markdown/"); + await expect(page.getByRole("heading", { level: 1, name: "Models in Markdown" })).toBeVisible(); + await expect( + page.getByText("A template belongs to the typeDiagram fence", { exact: false }), + ).toBeVisible(); + await page.goto("/blog/"); const cardImage = page.locator("main .post-list article").getByRole("img", { name: alt, @@ -215,6 +221,7 @@ test("serves the TechDoc documentation and blog structure", async ({ page }) => "Getting started", "Dart (Custom) Macros", "Macro catalogue", + "Models in Markdown", ]); await page.goto("/docs/dart-custom-macros/"); diff --git a/website/src/docs/index.md b/website/src/docs/index.md index b12b415..054c4a6 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -27,6 +27,11 @@ are ready to use. A **[custom macro](/docs/dart-custom-macros/)** is a Dart program in your own project, for generating something the built-ins do not cover — reading a database schema, say, or an API document. +Not every model starts as Dart. When the types live in a design document rather +than in a class, you can write them once in a `*.dmx.md` file and let Mustache +templates under the diagram write the Dart — +see **[Models in Markdown](/docs/models-in-markdown/)**. + You opt in with the package's single annotation type: ```dart diff --git a/website/src/docs/models-in-markdown.md b/website/src/docs/models-in-markdown.md new file mode 100644 index 0000000..04598fb --- /dev/null +++ b/website/src/docs/models-in-markdown.md @@ -0,0 +1,173 @@ +--- +layout: layouts/docs.njk +title: Models in Markdown +description: Define your types once in a *.dmx.md document and let Mustache templates write the Dart files. +eleventyNavigation: + key: Models in Markdown + order: 4 +--- + +# Models in Markdown + +Sometimes there is no Dart file to annotate yet. The types exist in a design +document, an API contract, or somebody's head, and writing them out in Dart +first — then annotating that Dart — is work you only do so that a generator has +something to read. + +A `*.dmx.md` document skips it. You write the types once, in a +[typeDiagram](https://typediagram.dev/docs/) fence, and put the Mustache +templates that generate from them immediately below. Save the document and dmx +writes the `.dart` files those templates name. + +The fence is an ordinary typeDiagram fence, so the same page still renders as a +diagram anywhere typeDiagram is supported. One page is the model, the +documentation, and the build input. + +## A whole document + +{% raw %} +````markdown +# Shipping + +```typeDiagram +type Parcel { + id: Uuid + weightG: Int + insured: Option + labels: List +} +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +``` +```` +{% endraw %} + +Save it and `lib/parcel.dart` appears: + +```dart +// dmx: generated from docs/shipping.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition bd16c86d…, template abae1bb2…, context v1, dmx 0.3.0. + +final class Parcel { + const Parcel({required this.id, required this.weightG, this.insured, required this.labels}); + + final String id; + final int weightG; + final String? insured; + final List labels; +} +``` + +Note what the template did not have to do. `Option` became `String?` +and `List` became `List` before the template ran, and +`constructorParameters` arrived already written — `required` on the fields that +need it, plain on the optional one. Templates place prepared values; they never +work out Dart types. + +## How a template binds to a definition + +A template belongs to the typeDiagram fence **immediately above it**. Blank +lines are fine; anything else — a heading, a paragraph, another fence — ends +the group. Nothing depends on a heading's text or on where the fence sits in +the document, so a template can never quietly attach to the wrong definition. + +One definition can feed several templates, as long as their fences follow it +one after another: + +````markdown +```typeDiagram +type Parcel { id: Uuid, weightG: Int } +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +…the model classes… +``` + +```mustache {"dmx":{"output":"lib/parcel_wire.dart"}} +…the wire-name table… +``` +```` + +Both files are functions of the same definition, so they cannot disagree. Add a +field and both change. Delete a template fence and its file is removed. + +A typeDiagram fence with no template under it is documentation and generates +nothing. A `mustache` fence with no `dmx` metadata is an example and generates +nothing. Everything else in the document — prose, headings, links, code in +other languages — is left exactly as you wrote it. dmx never rewrites the +document. + +## The fence metadata + +The JSON object after `mustache` is the whole configuration: + +| Key | Meaning | +| --- | --- | +| `dmx.output` | Required. The file to write, relative to the package the document belongs to — the nearest `pubspec.yaml`. | +| `dmx.target` | Optional, `dart` by default. The language the output is written in. | + +`dmx.output` cannot be an absolute path, cannot climb out of the package with +`..`, cannot be the document itself, and must end in the extension its target +generates. A misspelled key is reported rather than silently ignored. + +## Seeing what a template will get + +`dmx explain` prints each generation group, the files it writes, the digests +its outputs depend on, and the exact context the templates will render against: + +```bash +dmx explain docs/shipping.dmx.md +``` + +It writes nothing. It is the fastest way to find out what a name is called +before you use it. + +## What the templates can read + +The root of the context carries `modelVersion`, `target`, `source`, and +`declarations`. Every declaration appears once, in the order you wrote it, with +mutually exclusive `isRecord`, `isUnion`, `isAlias` and `isFunction` flags, so a +template selects a shape rather than filtering a list. + +| Name | On | What it is | +| --- | --- | --- | +| `name`, `camelName`, `pascalName`, `snakeName`, `screamingSnakeName`, `label` | declarations, fields, variants | The identifier, in every casing | +| `genericDeclaration` | declarations | ``, or empty | +| `constructorParameters` | records, variants | `{required this.a, this.b}`, ready to place | +| `dartType`, `targetType` | fields, aliases, returns | The Dart type text, already resolved | +| `typeDiagram` | fields | The type as the diagram spells it | +| `isOptional`, `isRequired`, `parameter` | fields | Whether it is an `Option`, and its constructor fragment | +| `owner`, `ownerGenericDeclaration` | variants | The union the variant belongs to, which its own `name` would otherwise hide | +| `discriminant`, `hasDiscriminant`, `isTuple`, `isBare` | variants | The variant's shape | +| `first`, `last`, `comma`, `index` | every list member | Separators without arithmetic | + +## Where the definitions come from + +dmx reads the typeDiagram language itself, in Rust. Installing dmx installs +nothing else: no Node, no npm package, no `typediagram` executable, and no +network access at build time. A compatibility corpus in the dmx repository holds +the parser to typeDiagram's own, fixture by fixture, so the two cannot drift +apart quietly. + +The definition supplies the model. Mustache decides every generated byte. + +## Safety + +Generated files carry an ownership marker on their first line. dmx will not +overwrite a file that does not have one, so a hand-written file is never lost to +a typo in an output path. Rendered source is parsed as a complete file before +anything is written, and checked for the constructs generated code may not +contain — `throw`, `as` casts, `!` null assertions — so a template mistake fails +the build instead of shipping. + +`dmx build --check` writes nothing and exits non-zero when an output is out of +date, which is what CI should run. From 1484b36e497bf6886118844238921f81bb793ff0 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:59:33 +1000 Subject: [PATCH 2/4] fixes --- Makefile | 13 +- docs/plans/typediagram-integration.md | 34 ++- examples/storefront/docs/shipping.dmx.md | 8 +- examples/storefront/lib/shipping.dart | 2 +- src/dmx/src/typediagram/context.rs | 46 ++- src/dmx/src/typediagram/context_tests.rs | 64 +++- .../golden/lib/aliases-and-functions.dart | 55 ++++ .../tests/typediagram/golden/lib/records.dart | 94 ++++++ .../tests/typediagram/golden/lib/scalars.dart | 55 ++++ .../typediagram/golden/lib/targeting.dart | 54 ++++ .../tests/typediagram/golden/lib/unions.dart | 284 +++++++++++++++++ src/dmx/tests/typediagram/golden/pubspec.yaml | 4 + .../typediagram/golden/template.mustache | 59 ++++ src/dmx/tests/typediagram_golden.rs | 287 ++++++++++++++++++ website/src/docs/models-in-markdown.md | 25 ++ 15 files changed, 1064 insertions(+), 20 deletions(-) create mode 100644 src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart create mode 100644 src/dmx/tests/typediagram/golden/lib/records.dart create mode 100644 src/dmx/tests/typediagram/golden/lib/scalars.dart create mode 100644 src/dmx/tests/typediagram/golden/lib/targeting.dart create mode 100644 src/dmx/tests/typediagram/golden/lib/unions.dart create mode 100644 src/dmx/tests/typediagram/golden/pubspec.yaml create mode 100644 src/dmx/tests/typediagram/golden/template.mustache create mode 100644 src/dmx/tests/typediagram_golden.rs diff --git a/Makefile b/Makefile index 60c1865..d3b00b4 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ EXAMPLE_DIR := examples/storefront CORPUS_DIR := $(TARGET_DIR)/corpus DMX_PACKAGE_DIR := src/dart_packages/dmx GOLDEN_DIR := $(CRATE_DIR)/tests/golden +TD_GOLDEN_DIR := $(CRATE_DIR)/tests/typediagram/golden # Every Dart directory a human writes. Deliberately enumerated rather than # globbed: what is NOT here is dmx output, and formatting output rewrites the @@ -126,7 +127,8 @@ lint: ## Clippy with warnings denied (read-only — never formats) fmt: ## Format code in-place. Pass CHECK=1 for read-only check (CI use) cargo fmt $(CRATE) --all$(if $(CHECK), --check,) @# Only HAND-WRITTEN Dart is formatted. `examples/storefront/lib`, - @# `examples/dmx_sqlite_example/lib` and $(GOLDEN_DIR) hold dmx OUTPUT — + @# `examples/dmx_sqlite_example/lib`, $(GOLDEN_DIR) and + @# $(TD_GOLDEN_DIR)/lib hold dmx OUTPUT — @# formatting them would rewrite the very bytes the golden tests assert on. @# `--output none` matters: plain `dart format --set-exit-if-changed` still @# REWRITES the files it checks, which is not a check. @@ -134,7 +136,7 @@ fmt: ## Format code in-place. Pass CHECK=1 for read-only check (CI use) clean: ## Remove Rust and Dart build artifacts cargo clean $(CRATE) - $(RM) $(EXAMPLE_DIR)/.dart_tool $(CORPUS_DIR) $(WEBSITE_DIR)/dist $(WEBSITE_DIR)/pkg lcov.info + $(RM) $(EXAMPLE_DIR)/.dart_tool $(TD_GOLDEN_DIR)/.dart_tool $(CORPUS_DIR) $(WEBSITE_DIR)/dist $(WEBSITE_DIR)/pkg lcov.info @# One per component, plus the raw hit data the two of them are formatted from. $(RM) $(DMX_PACKAGE_DIR)/lcov.info $(DMX_PACKAGE_DIR)/.coverage \ $(EXTENSION_DIR)/lcov.info $(WEBSITE_DIR)/lcov.info $(WEBSITE_DIR)/.coverage @@ -463,3 +465,10 @@ corpus: ## Generate every golden sample and prove it is valid Dart @printf 'name: dmx_corpus\nenvironment:\n sdk: ^3.0.0\ndependencies:\n dmx: ^0.3.0\n' > $(CORPUS_DIR)/pubspec.yaml cargo run $(CRATE) --quiet -- build $(CORPUS_DIR)/lib --insert-regions cd $(CORPUS_DIR) && dart pub get && dart analyze --fatal-infos + @# The typeDiagram corpus is generated the other way round: no annotated + @# Dart at all, just `tests/typediagram/corpus/*.td` rendered through + @# `tests/typediagram/golden/template.mustache`. `cargo test --test + @# typediagram_golden` proves the committed files are what the binary + @# writes; this proves they are Dart the analyzer accepts, which is the + @# half a byte comparison cannot do. + cd $(TD_GOLDEN_DIR) && dart pub get && dart analyze --fatal-infos diff --git a/docs/plans/typediagram-integration.md b/docs/plans/typediagram-integration.md index 8fb2a04..4fadcfb 100644 --- a/docs/plans/typediagram-integration.md +++ b/docs/plans/typediagram-integration.md @@ -107,15 +107,41 @@ Every phase above is implemented, tested, and gated by `make ci`. | `src/dmx/src/macros/typediagram.rs` | The built-in macro, in the same registry `@dmx('model')` is in | | `src/dmx/src/hygiene.rs` | [hygiene] as a CST check, because a user template is nobody's reviewed code | | `src/dmx/tests/typediagram/corpus` | The `.td` fixtures and the oracle's model JSON | +| `src/dmx/tests/typediagram/golden` | The same fixtures rendered to Dart through one shared template, committed and analyzer-gated | | `scripts/typediagram-oracle.mjs` | Development-only regeneration of that oracle from a typeDiagram checkout | | `examples/storefront/docs/shipping.dmx.md` | One definition, two generated Dart files, 9 tests over them | +### [typediagram.delivery.corpus] Corpus → Dart + +The `.td` fixtures proved the *model* and nothing else: they were parsed, +serialised, and compared against the oracle's JSON, and no Dart was ever +produced from them. Against [emission] — emitting Dart that does not compile is +the worst failure this repo has — model parity alone was not enough. + +Each fixture is now wrapped in a real `*.dmx.md` document over one shared +template, run through the shipped binary, and committed as +`tests/typediagram/golden/lib/.dart`. `cargo test --test +typediagram_golden` holds the bytes; `make corpus` runs `dart analyze +--fatal-infos` over them. The definitions are never copied — the document is +assembled from the `.td` file at test time, so the parity corpus stays the one +place a definition is written. + +- [x] Every corpus fixture renders to Dart and the output is committed and byte-gated. +- [x] `make corpus` analyzes it with `dart analyze --fatal-infos`. +- [x] Tuple variants emit a name the target can compile. typeDiagram spells positional members `_0`, `_1`, … and the model keeps that spelling; Dart cannot, because a leading underscore makes the member private — illegal as a named constructor parameter and dead as a field. The context now maps them to `value1`, `value2`, … [context.discipline]. **Every tuple variant in the language previously emitted Dart that did not compile, and nothing in the repo could see it.** +- [x] A signature carries `isOverload` so a target without overloading can name each one. `hasOverloads` on the declaration cannot be read from inside `{{#signatures}}`: a section entered on a name the *declaration* carries pushes that value with the declaration beneath it, so the ordinal read back is the declaration's. +- [x] The shipped storefront template stopped using `{{genericDeclaration}}`, which HTML-escapes `` into `<T>`. It only ever worked there because nothing in that document is generic. + ### [typediagram.delivery.next] Not Yet Done -- A second generation target. The abstraction is in place and carries one row; the value of the split is unproven until a second language uses it. -- `dmx explain --stages` for documents: `explain` prints groups, dependencies, paths, and the exact context, but not the render → hygiene → validation stages [execution]. -- A persistent build cache. Outputs are compared whole, which is correct and re-renders more than a cache would. -- Templates in a document cannot use partials; every bound fence is self-contained. +- [ ] **Decide what `{{ }}` means for a code generator.** Mustache escapes it as HTML, which is never right for Dart: any value holding `<`, `>`, `&` or `"` — every generic type, every function type — silently becomes uncompilable. `{{{ }}}` is the documented way out and the built-in templates use it, but the default is a trap that fails at the analyzer rather than at the template. Either drop escaping for code targets (`jsoncontent.rs` `render_escaped`, plus the two tests that pin the current behaviour) or make an unescaped-by-default tag the documented norm. +- [ ] **A rule for reading a parent's name inside a child section.** `isOverload` solves one instance of a general trap: any `{{#parentFlag}}…{{childName}}…{{/parentFlag}}` reads the parent's value. Either document the rule where template authors will meet it or push the flags every loop body needs onto the loop's own members. +- [ ] **A second generation target.** The seam is in place and carries one row; the value of the split is unproven until a second language uses it. It is also what would force the questions the Dart-only path never asks: identifier casing per target, reserved words, and how positional members are named somewhere other than Dart. +- [ ] **Reserved-word and identifier diagnostics.** A definition whose field is called `class` or `void` fails today at DMX4001 — "not valid Dart" with a line and column into generated source the author never wrote. It fails safe, which is the important half; it does not yet fail *legibly*, pointing at the definition. +- [ ] **Prove `@targets` exclusion end to end.** `targeting.td` selects nothing away for `dart`, so the corpus shows the filter keeping declarations and never shows it dropping one. A fixture that excludes the target under test would. +- [ ] **`dmx explain --stages` for documents.** `explain` prints groups, dependencies, paths, and the exact context, but not the render → hygiene → validation stages [execution]. +- [ ] **A persistent build cache.** Outputs are compared whole, which is correct and re-renders more than a cache would. +- [ ] **Partials in document templates.** Every bound fence is self-contained, so two templates over one definition cannot share a fragment. ## [typediagram.delivery.acceptance] Acceptance Criteria diff --git a/examples/storefront/docs/shipping.dmx.md b/examples/storefront/docs/shipping.dmx.md index 85d85f2..8ab4cb7 100644 --- a/examples/storefront/docs/shipping.dmx.md +++ b/examples/storefront/docs/shipping.dmx.md @@ -56,12 +56,12 @@ type Shipment { {{#isAlias}} /// `{{name}}` as the diagram declares it. -typedef {{name}}{{genericDeclaration}} = {{{dartType}}}; +typedef {{name}}{{{genericDeclaration}}} = {{{dartType}}}; {{/isAlias}} {{#isRecord}} /// {{label}}, generated from the shipping diagram. -final class {{name}}{{genericDeclaration}} { +final class {{name}}{{{genericDeclaration}}} { /// Every field of {{label}}, in the order the diagram declares them. const {{name}}({{{constructorParameters}}}); {{#fields}} @@ -74,14 +74,14 @@ final class {{name}}{{genericDeclaration}} { {{#isUnion}} /// {{label}} — exactly one of the variants below. -sealed class {{name}}{{genericDeclaration}} { +sealed class {{name}}{{{genericDeclaration}}} { /// The shared constructor every variant delegates to. const {{name}}(); } {{#variants}} /// The `{{name}}` case of {{owner}}. -final class {{name}} extends {{owner}}{{ownerGenericDeclaration}} { +final class {{name}} extends {{owner}}{{{ownerGenericDeclaration}}} { /// Every field of this case, in diagram order. const {{name}}({{{constructorParameters}}}) : super(); {{#fields}} diff --git a/examples/storefront/lib/shipping.dart b/examples/storefront/lib/shipping.dart index b485c4d..61669be 100644 --- a/examples/storefront/lib/shipping.dart +++ b/examples/storefront/lib/shipping.dart @@ -1,5 +1,5 @@ // dmx: generated from docs/shipping.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition bd16c86d530f3daa, template 861e8207f9496f03, context v1, dmx 0.0.0. +// dmx: group 1, fences 1/2, definition bd16c86d530f3daa, template f826dd2e54c4c785, context v1, dmx 0.0.0. // Generated from docs/shipping.dmx.md. Edit the diagram, not this file. diff --git a/src/dmx/src/typediagram/context.rs b/src/dmx/src/typediagram/context.rs index 64514f5..b1bfc74 100644 --- a/src/dmx/src/typediagram/context.rs +++ b/src/dmx/src/typediagram/context.rs @@ -116,11 +116,13 @@ fn declaration(decl: &Decl, model: &Model, target: &Target) -> Result { + let overloaded = function.signatures.len() > 1; let signatures = function .signatures .iter() - .map(|signature| self::signature(signature, model, target)) + .map(|signature| self::signature(signature, overloaded, model, target)) .collect::>>()?; + put(&mut out, "hasOverloads", overloaded); put(&mut out, "signatures", positioned(signatures)); } } @@ -169,10 +171,24 @@ fn variant( } /// One overload signature. -fn signature(signature: &Signature, model: &Model, target: &Target) -> Result> { +/// +/// `overloaded` is repeated here from the declaration on purpose. A Mustache +/// section entered on a name the *declaration* carries pushes that value with +/// the declaration beneath it, so `{{#hasOverloads}}{{index}}{{/hasOverloads}}` +/// inside `{{#signatures}}` reads the declaration's ordinal, not the +/// signature's. A flag the signature carries itself keeps the signature under +/// the section, which is the difference between `Read0`/`Read1` and two +/// typedefs called `Read0` [typediagram.model]. +fn signature( + signature: &Signature, + overloaded: bool, + model: &Model, + target: &Target, +) -> Result> { let mut out = Map::new(); let params = fields(&signature.params, model, target)?; let returns = type_ref(&signature.returns, model, target)?; + put(&mut out, "isOverload", overloaded); put(&mut out, "isAsync", signature.is_async); put(&mut out, "hasParams", !signature.params.is_empty()); put(&mut out, "parameterList", parameter_list(¶ms)); @@ -201,22 +217,36 @@ fn members( Ok(()) } +/// The name generated code uses for one member [context.discipline]. +/// +/// typeDiagram spells a tuple variant's positional members `_0`, `_1`, … and +/// the model keeps that spelling, because upstream does and the parity corpus +/// holds it there. Generated code cannot keep it: a leading underscore makes +/// the member private in Dart, which is illegal as a named constructor +/// parameter and dead as a field. Positional members are therefore `value1`, +/// `value2`, … — a proper name [context.discipline], one-based the way every +/// language spells the first element of a tuple. Every other member keeps the +/// name its author wrote. +fn member_name(raw: &str) -> String { + match raw.strip_prefix('_').map(str::parse::) { + Some(Ok(position)) => format!("value{}", position.saturating_add(1)), + Some(Err(_)) | None => raw.to_owned(), + } +} + /// A field list — a record's, a variant's payload, or a signature's parameters. fn fields(fields: &[Field], model: &Model, target: &Target) -> Result>> { fields .iter() .map(|field| { - let mut out = named(&field.name); + let name = member_name(&field.name); + let mut out = named(&name); let typed = type_ref(&field.ty, model, target)?; typed.place_into(&mut out); put(&mut out, "typeDiagram", field.ty.canonical()); put(&mut out, "isOptional", typed.optional); put(&mut out, "isRequired", !typed.optional); - put( - &mut out, - "parameter", - parameter(&field.name, typed.optional), - ); + put(&mut out, "parameter", parameter(&name, typed.optional)); put(&mut out, "type", typed.value); Ok(out) }) diff --git a/src/dmx/src/typediagram/context_tests.rs b/src/dmx/src/typediagram/context_tests.rs index 29aa72e..5382f88 100644 --- a/src/dmx/src/typediagram/context_tests.rs +++ b/src/dmx/src/typediagram/context_tests.rs @@ -130,7 +130,9 @@ fn variants_carry_their_shape() { json!("{required this.radius}") ); assert_eq!(variants[1]["isTuple"], json!(true)); - assert_eq!(variants[1]["fields"][1]["name"], json!("_1")); + // The target name, not the model name — see + // `tuple_members_are_named_for_the_target_not_for_the_model`. + assert_eq!(variants[1]["fields"][1]["name"], json!("value2")); assert_eq!(variants[2]["isBare"], json!(true)); assert_eq!(variants[2]["constructorParameters"], json!("")); assert_eq!(variants[0]["owner"], json!("Shape")); @@ -186,3 +188,63 @@ fn targeting_removes_a_declaration_from_the_context() { assert_eq!(declarations[0]["first"], json!(true)); assert_eq!(declarations[0]["last"], json!(true)); } + +/// [typediagram.model]: a tuple variant's positional members reach the target +/// under a name the target can actually use. +/// +/// The model keeps typeDiagram's own `_0`, `_1`, … spelling, and the parity +/// corpus holds it there. Dart cannot: a leading underscore makes the member +/// library-private, which is illegal as a named constructor parameter +/// (`private_named_parameter_without_public_name`) and dead as a field. Before +/// this, every tuple variant in the language emitted Dart that did not compile. +#[test] +fn tuple_members_are_named_for_the_target_not_for_the_model() { + let triple = + first("union RequestId { Triple(Int, String, List) }")["variants"][0].clone(); + assert_eq!(triple["isTuple"], json!(true)); + + let names: Vec<&str> = triple["fields"] + .as_array() + .expect("fields") + .iter() + .filter_map(|field| field["name"].as_str()) + .collect(); + assert_eq!(names, vec!["value1", "value2", "value3"]); + + assert_eq!( + triple["constructorParameters"], + json!("{required this.value1, required this.value2, required this.value3}") + ); + assert_eq!(triple["fields"][2]["dartType"], json!("List")); + + // A named payload is untouched — the rename is for positional members only. + let circle = first("union Shape { Circle { radius: Float } }")["variants"][0].clone(); + assert_eq!(circle["fields"][0]["name"], json!("radius")); + assert_eq!( + circle["constructorParameters"], + json!("{required this.radius}") + ); +} + +/// [typediagram.model]: a function says whether it has more than one signature, +/// because a target without overloading has to name each one separately. +#[test] +fn a_function_reports_whether_it_is_overloaded() { + let single = first("function nothing() -> Unit"); + assert_eq!(single["hasOverloads"], json!(false)); + assert_eq!( + single["signatures"].as_array().expect("signatures").len(), + 1 + ); + + let many = first( + "function read {\n (path: String) -> Bytes\n async (path: String, timeout: Float) -> Bytes\n}", + ); + assert_eq!(many["hasOverloads"], json!(true)); + assert_eq!(many["signatures"][0]["isAsync"], json!(false)); + assert_eq!(many["signatures"][1]["isAsync"], json!(true)); + assert_eq!( + many["signatures"][1]["parameterList"], + json!("String path, double timeout") + ); +} diff --git a/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart b/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart new file mode 100644 index 0000000..c84f298 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart @@ -0,0 +1,55 @@ +// dmx: generated from docs/aliases-and-functions.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition fc1a006cd8bfa5cd, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. + +// Generated from docs/aliases-and-functions.dmx.md. Edit the diagram, not this file. + +/// `Email` as the diagram declares it. +typedef Email = String; + +/// `UserId` as the diagram declares it. +typedef UserId = String; + +/// `Callback` as the diagram declares it. +typedef Callback = String?; + +/// `Index` as the diagram declares it. +typedef Index = Map>; + +/// Signature 0 of `fetch`, as the diagram declares it. +typedef Fetch = Response Function(Request request, T? fallback); + +/// Signature 0 of `store`, as the diagram declares it. +typedef Store = Future Function(Request item); + +/// Signature 0 of `read`, as the diagram declares it. +typedef Read0 = List Function(String path); + +/// Signature 1 of `read`, as the diagram declares it. +typedef Read1 = Future> Function(String path, double timeout); + +/// Signature 0 of `drain`, as the diagram declares it. +typedef Drain0 = void Function(); + +/// Signature 1 of `drain`, as the diagram declares it. +typedef Drain1 = Future Function(int limit); + +/// Signature 0 of `nothing`, as the diagram declares it. +typedef Nothing = void Function(); + +/// Request — a record from the diagram. +final class Request { + /// Every field, in the order the diagram declares them. + const Request({required this.url}); + + /// The `url` field, declared as `String`. + final String url; +} + +/// Response — a record from the diagram. +final class Response { + /// Every field, in the order the diagram declares them. + const Response({required this.status}); + + /// The `status` field, declared as `Int`. + final int status; +} diff --git a/src/dmx/tests/typediagram/golden/lib/records.dart b/src/dmx/tests/typediagram/golden/lib/records.dart new file mode 100644 index 0000000..d56cd72 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/records.dart @@ -0,0 +1,94 @@ +// dmx: generated from docs/records.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition 564eca654d0cbefc, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. + +// Generated from docs/records.dmx.md. Edit the diagram, not this file. + +/// User — a record from the diagram. +final class User { + /// Every field, in the order the diagram declares them. + const User({required this.id, required this.name, this.email, required this.roles, required this.address}); + + /// The `id` field, declared as `Uuid`. + final String id; + + /// The `name` field, declared as `String`. + final String name; + + /// The `email` field, declared as `Option`. + final Email? email; + + /// The `roles` field, declared as `List`. + final List roles; + + /// The `address` field, declared as `Address`. + final Address address; +} + +/// Pair — a record from the diagram. +final class Pair { + /// Every field, in the order the diagram declares them. + const Pair({required this.first, required this.second}); + + /// The `first` field, declared as `A`. + final A first; + + /// The `second` field, declared as `B`. + final B second; +} + +/// Box — a record from the diagram. +final class Box { + /// Every field, in the order the diagram declares them. + const Box({required this.value}); + + /// The `value` field, declared as `T`. + final T value; +} + +/// Empty — a record from the diagram. +final class Empty { + /// Every field, in the order the diagram declares them. + const Empty(); +} + +/// Separators — a record from the diagram. +final class Separators { + /// Every field, in the order the diagram declares them. + const Separators({required this.a, required this.b, required this.c}); + + /// The `a` field, declared as `Int`. + final int a; + + /// The `b` field, declared as `Int`. + final int b; + + /// The `c` field, declared as `Int`. + final int c; +} + +/// Email — a record from the diagram. +final class Email { + /// Every field, in the order the diagram declares them. + const Email({required this.text}); + + /// The `text` field, declared as `String`. + final String text; +} + +/// Role — a record from the diagram. +final class Role { + /// Every field, in the order the diagram declares them. + const Role({required this.name}); + + /// The `name` field, declared as `String`. + final String name; +} + +/// Address — a record from the diagram. +final class Address { + /// Every field, in the order the diagram declares them. + const Address({required this.line}); + + /// The `line` field, declared as `String`. + final String line; +} diff --git a/src/dmx/tests/typediagram/golden/lib/scalars.dart b/src/dmx/tests/typediagram/golden/lib/scalars.dart new file mode 100644 index 0000000..099acf5 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/scalars.dart @@ -0,0 +1,55 @@ +// dmx: generated from docs/scalars.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition 7db716b44e16128d, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. + +// Generated from docs/scalars.dmx.md. Edit the diagram, not this file. + +/// Scalars — a record from the diagram. +final class Scalars { + /// Every field, in the order the diagram declares them. + const Scalars({required this.flag, required this.count, required this.ratio, required this.text, required this.blob, required this.nothing, required this.at, required this.id, required this.amount, required this.tags, required this.index, this.maybe, required this.anything, this.deep}); + + /// The `flag` field, declared as `Bool`. + final bool flag; + + /// The `count` field, declared as `Int`. + final int count; + + /// The `ratio` field, declared as `Float`. + final double ratio; + + /// The `text` field, declared as `String`. + final String text; + + /// The `blob` field, declared as `Bytes`. + final List blob; + + /// The `nothing` field, declared as `Unit`. + final void nothing; + + /// The `at` field, declared as `DateTime`. + final DateTime at; + + /// The `id` field, declared as `Uuid`. + final Uuid id; + + /// The `amount` field, declared as `Decimal`. + final String amount; + + /// The `tags` field, declared as `List`. + final List tags; + + /// The `index` field, declared as `Map>>`. + final Map> index; + + /// The `maybe` field, declared as `Option`. + final int? maybe; + + /// The `anything` field, declared as `Any`. + final Object anything; + + /// The `deep` field, declared as `Option>>>`. + final Map>? deep; +} + +/// `Uuid` as the diagram declares it. +typedef Uuid = String; diff --git a/src/dmx/tests/typediagram/golden/lib/targeting.dart b/src/dmx/tests/typediagram/golden/lib/targeting.dart new file mode 100644 index 0000000..5b3c16c --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/targeting.dart @@ -0,0 +1,54 @@ +// dmx: generated from docs/targeting.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition 6070c6f7e26a9e98, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. + +// Generated from docs/targeting.dmx.md. Edit the diagram, not this file. + +/// Only dart and rust — a record from the diagram. +final class OnlyDartAndRust { + /// Every field, in the order the diagram declares them. + const OnlyDartAndRust({required this.a}); + + /// The `a` field, declared as `Int`. + final int a; +} + +/// Not go — a record from the diagram. +final class NotGo { + /// Every field, in the order the diagram declares them. + const NotGo({required this.b}); + + /// The `b` field, declared as `String`. + final String b; +} + +/// Both — exactly one of the cases below. +sealed class Both { + /// The shared constructor every case delegates to. + const Both(); +} + +/// The `One` case of Both. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class BothOne extends Both { + /// This case's payload, in diagram order. + const BothOne() : super(); +} + +/// The `Two` case of Both. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class BothTwo extends Both { + /// This case's payload, in diagram order. + const BothTwo({required this.x}) : super(); + + /// The `x` member, declared as `Int`. + final int x; +} + +/// `Plain` as the diagram declares it. +typedef Plain = int; diff --git a/src/dmx/tests/typediagram/golden/lib/unions.dart b/src/dmx/tests/typediagram/golden/lib/unions.dart new file mode 100644 index 0000000..686cab3 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/unions.dart @@ -0,0 +1,284 @@ +// dmx: generated from docs/unions.dmx.md — do not edit. +// dmx: group 1, fences 1/2, definition 5214cc1d7a2b8b4d, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. + +// Generated from docs/unions.dmx.md. Edit the diagram, not this file. + +/// Shape — exactly one of the cases below. +sealed class Shape { + /// The shared constructor every case delegates to. + const Shape(); +} + +/// The `Circle` case of Shape. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ShapeCircle extends Shape { + /// This case's payload, in diagram order. + const ShapeCircle({required this.radius}) : super(); + + /// The `radius` member, declared as `Float`. + final double radius; +} + +/// The `Rectangle` case of Shape. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ShapeRectangle extends Shape { + /// This case's payload, in diagram order. + const ShapeRectangle({required this.width, required this.height}) : super(); + + /// The `width` member, declared as `Float`. + final double width; + + /// The `height` member, declared as `Float`. + final double height; +} + +/// The `Triangle` case of Shape. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ShapeTriangle extends Shape { + /// This case's payload, in diagram order. + const ShapeTriangle({required this.a, required this.b, required this.c}) : super(); + + /// The `a` member, declared as `Float`. + final double a; + + /// The `b` member, declared as `Float`. + final double b; + + /// The `c` member, declared as `Float`. + final double c; +} + +/// The `Point` case of Shape. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ShapePoint extends Shape { + /// This case's payload, in diagram order. + const ShapePoint() : super(); +} + +/// Error code — exactly one of the cases below. +sealed class ErrorCode { + /// The shared constructor every case delegates to. + const ErrorCode(); +} + +/// The `ParseError` case of ErrorCode. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ErrorCodeParseError extends ErrorCode { + /// This case's payload, in diagram order. + const ErrorCodeParseError() : super(); + + /// The discriminant the diagram gives this case. + static const int discriminant = -32700; +} + +/// The `InvalidRequest` case of ErrorCode. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ErrorCodeInvalidRequest extends ErrorCode { + /// This case's payload, in diagram order. + const ErrorCodeInvalidRequest() : super(); + + /// The discriminant the diagram gives this case. + static const int discriminant = -32600; +} + +/// The `MethodNotFound` case of ErrorCode. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ErrorCodeMethodNotFound extends ErrorCode { + /// This case's payload, in diagram order. + const ErrorCodeMethodNotFound() : super(); + + /// The discriminant the diagram gives this case. + static const int discriminant = -32601; +} + +/// The `Ok` case of ErrorCode. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ErrorCodeOk extends ErrorCode { + /// This case's payload, in diagram order. + const ErrorCodeOk() : super(); + + /// The discriminant the diagram gives this case. + static const int discriminant = 0; +} + +/// The `Grouped` case of ErrorCode. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ErrorCodeGrouped extends ErrorCode { + /// This case's payload, in diagram order. + const ErrorCodeGrouped() : super(); + + /// The discriminant the diagram gives this case. + static const int discriminant = 1_000; +} + +/// Option — exactly one of the cases below. +sealed class Option { + /// The shared constructor every case delegates to. + const Option(); +} + +/// The `Some` case of Option. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class OptionSome extends Option { + /// This case's payload, in diagram order. + const OptionSome({required this.value}) : super(); + + /// The `value` member, declared as `T`. + final T value; +} + +/// The `None` case of Option. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class OptionNone extends Option { + /// This case's payload, in diagram order. + const OptionNone() : super(); +} + +/// Result — exactly one of the cases below. +sealed class Result { + /// The shared constructor every case delegates to. + const Result(); +} + +/// The `Ok` case of Result. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ResultOk extends Result { + /// This case's payload, in diagram order. + const ResultOk({required this.value}) : super(); + + /// The `value` member, declared as `T`. + final T value; +} + +/// The `Err` case of Result. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class ResultErr extends Result { + /// This case's payload, in diagram order. + const ResultErr({required this.error}) : super(); + + /// The `error` member, declared as `E`. + final E error; +} + +/// Request id — exactly one of the cases below. +sealed class RequestId { + /// The shared constructor every case delegates to. + const RequestId(); +} + +/// The `Number` case of RequestId. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class RequestIdNumber extends RequestId { + /// This case's payload, in diagram order. + const RequestIdNumber({required this.value1}) : super(); + + /// The `value1` member, declared as `Int`. + final int value1; +} + +/// The `String` case of RequestId. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class RequestIdString extends RequestId { + /// This case's payload, in diagram order. + const RequestIdString({required this.value1}) : super(); + + /// The `value1` member, declared as `String`. + final String value1; +} + +/// The `Triple` case of RequestId. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class RequestIdTriple extends RequestId { + /// This case's payload, in diagram order. + const RequestIdTriple({required this.value1, required this.value2, required this.value3}) : super(); + + /// The `value1` member, declared as `Int`. + final int value1; + + /// The `value2` member, declared as `String`. + final String value2; + + /// The `value3` member, declared as `List`. + final List value3; +} + +/// Loose — exactly one of the cases below, told apart by shape +/// rather than by a tag. +sealed class Loose { + /// The shared constructor every case delegates to. + const Loose(); +} + +/// The `Left` case of Loose. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class LooseLeft extends Loose { + /// This case's payload, in diagram order. + const LooseLeft({required this.value}) : super(); + + /// The `value` member, declared as `Int`. + final int value; +} + +/// The `Right` case of Loose. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class LooseRight extends Loose { + /// This case's payload, in diagram order. + const LooseRight({required this.value}) : super(); + + /// The `value` member, declared as `String`. + final String value; +} diff --git a/src/dmx/tests/typediagram/golden/pubspec.yaml b/src/dmx/tests/typediagram/golden/pubspec.yaml new file mode 100644 index 0000000..9eb3921 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/pubspec.yaml @@ -0,0 +1,4 @@ +name: dmx_typediagram_golden +publish_to: none +environment: + sdk: ^3.6.0 diff --git a/src/dmx/tests/typediagram/golden/template.mustache b/src/dmx/tests/typediagram/golden/template.mustache new file mode 100644 index 0000000..38bfa92 --- /dev/null +++ b/src/dmx/tests/typediagram/golden/template.mustache @@ -0,0 +1,59 @@ +// Generated from {{source.path}}. Edit the diagram, not this file. +{{#declarations}} +{{#isAlias}} + +/// `{{name}}` as the diagram declares it. +typedef {{name}}{{{genericDeclaration}}} = {{{dartType}}}; +{{/isAlias}} +{{#isRecord}} + +/// {{label}} — a record from the diagram. +final class {{name}}{{{genericDeclaration}}} { + /// Every field, in the order the diagram declares them. + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/isRecord}} +{{#isUnion}} + +/// {{label}} — exactly one of the cases below{{#untagged}}, told apart by shape +/// rather than by a tag{{/untagged}}. +sealed class {{name}}{{{genericDeclaration}}} { + /// The shared constructor every case delegates to. + const {{name}}(); +} +{{#variants}} + +/// The `{{name}}` case of {{owner}}. +/// +/// The class carries its union's name because variant names collide across +/// unions in one library — `Ok` belongs to two of them here — and a template, +/// not the generator, decides what a case is called. +final class {{owner}}{{name}}{{{ownerGenericDeclaration}}} extends {{owner}}{{{ownerGenericDeclaration}}} { + /// This case's payload, in diagram order. + const {{owner}}{{name}}({{{constructorParameters}}}) : super(); +{{#hasDiscriminant}} + + /// The discriminant the diagram gives this case. + static const int discriminant = {{discriminant}}; +{{/hasDiscriminant}} +{{#fields}} + + /// The `{{name}}` member, declared as `{{{typeDiagram}}}`. + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/variants}} +{{/isUnion}} +{{#isFunction}} +{{#signatures}} + +/// Signature {{index}} of `{{name}}`, as the diagram declares it. +typedef {{pascalName}}{{#isOverload}}{{index}}{{/isOverload}}{{{genericDeclaration}}} = {{#isAsync}}Future<{{{returnType}}}>{{/isAsync}}{{^isAsync}}{{{returnType}}}{{/isAsync}} Function({{{parameterList}}}); +{{/signatures}} +{{/isFunction}} +{{/declarations}} diff --git a/src/dmx/tests/typediagram_golden.rs b/src/dmx/tests/typediagram_golden.rs new file mode 100644 index 0000000..3d42d63 --- /dev/null +++ b/src/dmx/tests/typediagram_golden.rs @@ -0,0 +1,287 @@ +//! Every corpus definition, rendered to Dart by the shipped binary [typediagram.output]. +//! +//! `tests/typediagram/corpus/*.td` is the parity corpus: `typediagram_model` +//! holds the Rust parser to typeDiagram's own model, fixture by fixture. That +//! proves the *model* is right and says nothing about the *code*, and this repo +//! holds that emitting Dart which does not compile is the worst failure +//! available to it. +//! +//! So each fixture is wrapped in a real `*.dmx.md` document over one shared +//! template, run through the real `dmx` binary, and compared byte for byte with +//! `tests/typediagram/golden/lib/.dart`. Those files are committed, and +//! `make corpus` runs `dart analyze --fatal-infos` over the package holding +//! them — so the corpus is checked as source, not just as JSON. +//! +//! The definitions are never copied. The document is assembled from the `.td` +//! file at test time, so the parity corpus stays the one place a definition is +//! written and the two suites can never drift apart. +//! +//! Hygiene is not re-asserted here. The binary refuses to write source +//! carrying `throw`, an `as` cast or a `!` assertion at all, and +//! `typediagram_cli` proves that refusal (DMX4003) against a template written +//! to trip it. A substring scan of the goldens could only re-check it worse — +//! `as` occurs in English — so the check lives where it can be made properly. +//! +//! To accept a deliberate change to the emitted shape: +//! +//! ```text +//! UPDATE_GOLDEN=1 cargo test --test typediagram_golden +//! ``` + +// [TEST-RULES] admits `expect` in a test: a fixture that cannot be built is a +// broken test, and unwinding at the point of failure names it better than any +// `Result` plumbing would. Production code is still held to `unwrap_used` and +// `expect_used` at deny — this relaxation is `cfg(test)`-scoped on purpose. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects + ) +)] + +mod support; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use support::TempDirectory; + +/// Every fixture the parity corpus carries, in the order a reader meets them. +const FIXTURES: &[&str] = &[ + "scalars", + "records", + "unions", + "aliases-and-functions", + "targeting", +]; + +/// The version token the goldens are written with. +/// +/// A release build injects `DMX_VERSION`, so the marker's version field is the +/// one thing in the file that is not a function of the fixture. Both sides are +/// normalised to this before comparing; that the field carries the running +/// build's version is asserted by the `typediagram_cli` suite, which reads it +/// out of a marker directly. +const PINNED_VERSION: &str = "0.0.0"; + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/typediagram/golden") +} + +fn corpus_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/typediagram/corpus") +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) +} + +/// The same bytes with the running build's version replaced by the pinned one. +fn normalised(source: &str) -> String { + source.replace( + &format!("dmx {}.", dmx::VERSION), + &format!("dmx {PINNED_VERSION}."), + ) +} + +/// The `*.dmx.md` document one fixture is generated from. +/// +/// The definition is the `.td` file verbatim and the template is +/// `golden/template.mustache` verbatim, so neither is written twice. +fn document(name: &str, definition: &str, template: &str) -> String { + format!( + "# {name}\n\nGenerated from the parity corpus fixture of the same name.\n\n\ + ```typeDiagram\n{definition}```\n\n\ + ```mustache {{\"dmx\":{{\"output\":\"lib/{name}.dart\"}}}}\n{template}```\n" + ) +} + +/// Runs the binary over a throwaway package holding one fixture's document and +/// returns the Dart it wrote. +fn generate(name: &str, template: &str) -> String { + let workspace = TempDirectory::create("dmx-td-golden").expect("scratch directory"); + let _ = workspace + .write( + "pubspec.yaml", + "name: dmx_typediagram_golden\npublish_to: none\nenvironment:\n sdk: ^3.6.0\n", + ) + .expect("pubspec"); + let definition = read(&corpus_dir().join(format!("{name}.td"))); + let _ = workspace + .write( + &format!("docs/{name}.dmx.md"), + &document(name, &definition, template), + ) + .expect("document"); + + let output = Command::new(env!("CARGO_BIN_EXE_dmx")) + .args(["build", "docs", "lib"]) + .current_dir(&workspace.path) + .output() + .expect("run dmx"); + assert!( + output.status.success(), + "{name}: dmx build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let written = workspace.at(&format!("lib/{name}.dart")); + assert!( + written.exists(), + "{name}: nothing was written to lib/{name}.dart\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + normalised(&read(&written)) +} + +/// [typediagram.output]: every corpus definition renders to the committed Dart, +/// byte for byte, through the shipped binary. +#[test] +fn every_corpus_fixture_generates_its_golden_dart() { + let template = read(&golden_dir().join("template.mustache")); + let updating = std::env::var_os("UPDATE_GOLDEN").is_some(); + + for name in FIXTURES { + let actual = generate(name, &template); + let expected_path = golden_dir().join(format!("lib/{name}.dart")); + + if updating { + fs::write(&expected_path, &actual).expect("write golden"); + continue; + } + + let expected = read(&expected_path); + assert_eq!( + actual, expected, + "{name}: generated Dart no longer matches tests/typediagram/golden/lib/{name}.dart. \ + Re-run with UPDATE_GOLDEN=1 if the change is deliberate." + ); + } +} + +/// [typediagram.output]: the shapes the corpus exists to reach are actually in +/// the generated Dart, so a golden emptied by a template mistake cannot pass. +/// +/// The byte comparison above proves the output is *stable*; it cannot notice +/// that a section stopped matching and quietly rendered nothing. These are the +/// constructs no other suite in the repo generates. +#[test] +fn the_goldens_cover_the_shapes_the_corpus_exists_for() { + let unions = read(&golden_dir().join("lib/unions.dart")); + // A tuple variant, under a name Dart can compile — see + // `tuple_members_are_named_for_the_target_not_for_the_model`. + assert!( + unions.contains("const RequestIdTriple({required this.value1, required this.value2, required this.value3})"), + "tuple variants missing from unions.dart" + ); + assert!(!unions.contains("this._0"), "a private member reached Dart"); + // A generic union, with its cases parameterised by the union's own list. + assert!( + unions.contains("final class OptionSome extends Option"), + "generic union cases missing from unions.dart" + ); + assert!( + unions.contains("final class ResultErr extends Result"), + "multi-parameter generic union cases missing from unions.dart" + ); + // Explicit discriminants, including the digit-separated one. + assert!( + unions.contains("static const int discriminant = -32700;") + && unions.contains("static const int discriminant = 1_000;"), + "discriminants missing from unions.dart" + ); + assert!( + unions.contains("told apart by shape"), + "the untagged union is not marked in unions.dart" + ); + + let functions = read(&golden_dir().join("lib/aliases-and-functions.dart")); + assert!( + functions.contains("typedef Fetch = Response Function(Request request, T? fallback);"), + "the generic function typedef is missing" + ); + assert!( + functions.contains("typedef Read0 = List Function(String path);") + && functions.contains( + "typedef Read1 = Future> Function(String path, double timeout);" + ), + "overloads are not written out one typedef each" + ); + assert!( + functions.contains("typedef Store = Future Function(Request item);"), + "an async single-signature function is not a Future" + ); + assert!( + functions.contains("typedef Index = Map>;"), + "the generic alias is missing" + ); + + let scalars = read(&golden_dir().join("lib/scalars.dart")); + for expected in [ + "final bool flag;", + "final int count;", + "final double ratio;", + "final List blob;", + "final void nothing;", + "final DateTime at;", + "final Object anything;", + "final Map> index;", + "final Map>? deep;", + ] { + assert!( + scalars.contains(expected), + "scalars.dart is missing `{expected}`" + ); + } + // A declaration shadows a primitive, and the field takes the declared name. + assert!( + scalars.contains("typedef Uuid = String;") && scalars.contains("final Uuid id;"), + "the shadowing alias is not honoured in scalars.dart" + ); + + let records = read(&golden_dir().join("lib/records.dart")); + assert!( + records.contains("const Empty();"), + "an empty record must take no parameter list" + ); + assert!( + records.contains("final class Pair {"), + "generic records are missing" + ); + + let targeting = read(&golden_dir().join("lib/targeting.dart")); + for expected in ["class OnlyDartAndRust", "class NotGo", "sealed class Both"] { + assert!( + targeting.contains(expected), + "targeting.dart dropped `{expected}`, which the dart target selects" + ); + } +} + +/// [typediagram.output]: every generated file carries the ownership marker the +/// emitter refuses to overwrite without. +#[test] +fn every_golden_is_marked_as_generated() { + for name in FIXTURES { + let source = read(&golden_dir().join(format!("lib/{name}.dart"))); + let first = source.lines().next().unwrap_or_default(); + assert_eq!( + first, + format!("// dmx: generated from docs/{name}.dmx.md — do not edit.") + ); + assert!( + source + .lines() + .nth(1) + .unwrap_or_default() + .contains("context v1"), + "{name}: the identity line is missing" + ); + } +} diff --git a/website/src/docs/models-in-markdown.md b/website/src/docs/models-in-markdown.md index 04598fb..f260c21 100644 --- a/website/src/docs/models-in-markdown.md +++ b/website/src/docs/models-in-markdown.md @@ -148,8 +148,33 @@ template selects a shape rather than filtering a list. | `isOptional`, `isRequired`, `parameter` | fields | Whether it is an `Option`, and its constructor fragment | | `owner`, `ownerGenericDeclaration` | variants | The union the variant belongs to, which its own `name` would otherwise hide | | `discriminant`, `hasDiscriminant`, `isTuple`, `isBare` | variants | The variant's shape | +| `untagged` | unions | Whether the cases are told apart by shape rather than a tag | +| `signatures`, `hasOverloads` | functions | Every overload, and whether there is more than one | +| `parameterList`, `returnType`, `isAsync`, `params`, `isOverload` | signatures | One signature, ready to place | | `first`, `last`, `comma`, `index` | every list member | Separators without arithmetic | +A tuple variant's positional members arrive as `value1`, `value2`, … The +diagram spells them `_0`, `_1`, and the model keeps that spelling, but a +leading underscore is private in Dart — illegal as a named constructor +parameter and dead as a field — so the target sees a name it can compile. + +## Two things worth knowing before you write a template + +{% raw %} +**Use `{{{triple}}}` braces for anything holding a type.** `{{name}}` escapes +its value as HTML, so `{{genericDeclaration}}` renders `` as `<T>` and +the file fails validation rather than being written. Every value that can hold +`<`, `>` or `&` — `dartType`, `targetType`, `genericDeclaration`, +`ownerGenericDeclaration`, `parameterList`, `returnType`, +`constructorParameters` — wants triple braces. + +**A section reads names from the level it was entered on.** Opening +`{{#hasOverloads}}` inside `{{#signatures}}` finds `hasOverloads` on the +*function*, so `{{index}}` inside that section is the function's ordinal, not +the signature's. That is why a signature carries its own `isOverload`: entering +the section on the signature's own name keeps the signature in scope. +{% endraw %} + ## Where the definitions come from dmx reads the typeDiagram language itself, in Rust. Installing dmx installs From 097e3885fb2ec24a32048615e6e26746f79ce053 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:29:06 +1000 Subject: [PATCH 3/4] One canonical model template, and typeDiagram's own names for union cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A definition with nothing beside it used to generate nothing, and every project that wanted model classes wrote and maintained its own Mustache. There is now one model template, shipped in the binary, used wherever a diagram generates model classes. Every record and every union case comes out of it as an immutable value — ==, hashCode, toString, copyWith — built by the same Rust that builds them for @dmx('model'), so the annotated path and the diagram path cannot say different things about the same type. JSON is never a member of a generated class: it goes on an `extension Json`, and nested decodes name the extension. The runtime import is prefixed, so a diagram that declares its own Result, Ok or Err cannot hide the one the codec means. Union cases are named the way typeDiagram's own emitters name them — the case's own name — and take their union's name as a prefix only where Dart's single namespace forces it: `Ok` belongs to two unions in the parity corpus, and `String` is Dart's own. A case with neither name available is refused (DMX8010) rather than generated as two classes under one name. That alignment is what tdbin interop needs. Also brings the duplication gate back under its budget. It was breached at 7.6% before this branch: the typeDiagram goldens join the generated-output list already in .deslop.toml, the two typeDiagram suites share one Workspace fixture, and several test bodies that differed only in their data are table-driven. The budget ratchets 7.0 -> 6.8. --- .deslop.toml | 11 +- Makefile | 6 +- README.md | 91 +- coverage-thresholds.json | 6 +- docs/messaging.md | 14 +- docs/plans/typediagram-integration.md | 86 +- docs/specs/SPEC.md | 2 +- docs/specs/typediagram-markdown.md | 141 --- docs/specs/typediagram.md | 199 ++++ examples/storefront/README.md | 24 +- examples/storefront/docs/shipping.dmx.md | 132 --- examples/storefront/lib/shipping.dart | 378 ++++++- examples/storefront/lib/shipping_wire.dart | 6 +- examples/storefront/models/README.md | 48 + examples/storefront/models/shipping.td | 22 + .../storefront/models/shipping.wire.mustache | 30 + examples/storefront/test/shipping_test.dart | 94 +- src/dmx/src/engine.rs | 2 +- src/dmx/src/lib.rs | 23 +- src/dmx/src/macros/diff.rs | 8 +- src/dmx/src/macros/mod.rs | 6 +- src/dmx/src/macros/model.rs | 130 ++- src/dmx/src/macros/rest.rs | 8 +- src/dmx/src/macros/table.rs | 2 +- src/dmx/src/macros/typediagram.rs | 125 +-- src/dmx/src/main.rs | 29 +- src/dmx/src/sources.rs | 258 +++++ src/dmx/src/typediagram/binding.rs | 386 +++++++ src/dmx/src/typediagram/binding_tests.rs | 238 +++++ src/dmx/src/typediagram/context.rs | 214 ++-- src/dmx/src/typediagram/document.rs | 167 +-- src/dmx/src/typediagram/markdown.rs | 148 +-- src/dmx/src/typediagram/mod.rs | 60 +- src/dmx/src/typediagram/model.rs | 10 + src/dmx/src/typediagram/naming.rs | 186 ++++ src/dmx/src/typediagram/prepared.rs | 102 ++ src/dmx/src/typediagram/run.rs | 129 +++ src/dmx/src/typediagram/scratch.rs | 60 ++ src/dmx/src/typediagram/semantics.rs | 313 ++++++ src/dmx/src/typediagram/standalone.rs | 273 +++++ src/dmx/src/typediagram/standalone_tests.rs | 427 ++++++++ src/dmx/src/typediagram/target.rs | 75 +- src/dmx/src/types.rs | 238 ++--- src/dmx/src/types_tests.rs | 145 +++ src/dmx/src/watch.rs | 254 +---- src/dmx/templates/diagram_model.mustache | 185 ++++ src/dmx/tests/support/mod.rs | 5 +- src/dmx/tests/support/watch.rs | 269 +++++ src/dmx/tests/support/workspace.rs | 113 ++ .../golden/lib/aliases-and-functions.dart | 55 - .../golden/lib/aliases_and_functions.dart | 143 +++ .../tests/typediagram/golden/lib/records.dart | 371 ++++++- .../tests/typediagram/golden/lib/scalars.dart | 49 +- .../typediagram/golden/lib/targeting.dart | 223 +++- .../tests/typediagram/golden/lib/unions.dart | 993 +++++++++++++++--- src/dmx/tests/typediagram/golden/pubspec.yaml | 2 + .../typediagram/golden/template.mustache | 59 -- src/dmx/tests/typediagram_cli.rs | 125 +-- src/dmx/tests/typediagram_golden.rs | 405 ++++--- src/dmx/tests/typediagram_standalone.rs | 487 +++++++++ src/dmx/tests/watch_cli.rs | 281 +---- src/editors/vscode/e2e/fixture.js | 30 +- src/editors/vscode/e2e/run.js | 8 +- src/editors/vscode/e2e/suite/watch.e2e.js | 39 + src/editors/vscode/package.json | 1 + src/editors/vscode/paths.js | 36 +- src/editors/vscode/test/paths.test.js | 24 +- website/e2e/navigation.spec.ts | 8 +- website/src/docs/index.md | 6 +- website/src/docs/models-from-a-diagram.md | 299 ++++++ website/src/docs/models-in-markdown.md | 198 ---- 71 files changed, 7482 insertions(+), 2238 deletions(-) delete mode 100644 docs/specs/typediagram-markdown.md create mode 100644 docs/specs/typediagram.md delete mode 100644 examples/storefront/docs/shipping.dmx.md create mode 100644 examples/storefront/models/README.md create mode 100644 examples/storefront/models/shipping.td create mode 100644 examples/storefront/models/shipping.wire.mustache create mode 100644 src/dmx/src/sources.rs create mode 100644 src/dmx/src/typediagram/binding.rs create mode 100644 src/dmx/src/typediagram/binding_tests.rs create mode 100644 src/dmx/src/typediagram/naming.rs create mode 100644 src/dmx/src/typediagram/prepared.rs create mode 100644 src/dmx/src/typediagram/run.rs create mode 100644 src/dmx/src/typediagram/scratch.rs create mode 100644 src/dmx/src/typediagram/semantics.rs create mode 100644 src/dmx/src/typediagram/standalone.rs create mode 100644 src/dmx/src/typediagram/standalone_tests.rs create mode 100644 src/dmx/src/types_tests.rs create mode 100644 src/dmx/templates/diagram_model.mustache create mode 100644 src/dmx/tests/support/watch.rs create mode 100644 src/dmx/tests/support/workspace.rs delete mode 100644 src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart create mode 100644 src/dmx/tests/typediagram/golden/lib/aliases_and_functions.dart delete mode 100644 src/dmx/tests/typediagram/golden/template.mustache create mode 100644 src/dmx/tests/typediagram_standalone.rs create mode 100644 website/src/docs/models-from-a-diagram.md delete mode 100644 website/src/docs/models-in-markdown.md diff --git a/.deslop.toml b/.deslop.toml index 9ea7eeb..e8750d2 100644 --- a/.deslop.toml +++ b/.deslop.toml @@ -4,13 +4,20 @@ [defaults] # Generator OUTPUT, not authored code. Hidden from the report, still analysed, -# so a cluster with one hand-written member survives. +# so a cluster with one hand-written member survives. Every directory here is +# one `make golden` / `make example` rewrites and AGENTS.md forbids editing by +# hand: its shape is decided by a template, and the template IS analysed. report_hide = [ "examples/storefront/lib/**", "examples/dmx_sqlite_example/lib/**", "examples/dmx_openapi_example/lib/**", "src/dmx/tests/golden/**", + "src/dmx/tests/typediagram/golden/lib/**", ] [threshold] -max_duplication_percent = 7.0 +# 0.5.1 is not bit-deterministic: three runs over an unchanged tree measured +# 6.36%, 6.59% and 6.60%. The budget carries headroom over the worst of those, +# so ratchet DOWN in steps that leave some — a gate that flakes red is a gate +# people learn to re-run. +max_duplication_percent = 6.8 diff --git a/Makefile b/Makefile index d3b00b4..37aef78 100644 --- a/Makefile +++ b/Makefile @@ -310,12 +310,12 @@ dart-package-publish: dart-package ## Prove the pub archive is publishable (need @# so this passes only from a clean checkout — which is what a tag is. cd $(DMX_PACKAGE_DIR) && dart pub publish --dry-run -example: ## Generate the example — annotated Dart and its typeDiagram document — analyze it, run its checks +example: ## Generate the example — annotated Dart and its typeDiagram definitions — analyze it, run its checks @# One invocation for both backends. Annotated Dart is generated INTO, and a - @# `*.dmx.md` document resolves its outputs against the package it belongs to + @# `.td` definition resolves its outputs against the package it belongs to @# [typediagram.output] — so neither depends on where this runs from, unlike @# the macro-worker examples below, whose workers are found from the cwd. - cargo run $(CRATE) --quiet -- build $(EXAMPLE_DIR)/lib $(EXAMPLE_DIR)/docs --insert-regions + cargo run $(CRATE) --quiet -- build $(EXAMPLE_DIR)/lib $(EXAMPLE_DIR)/models --insert-regions cd $(EXAMPLE_DIR) && dart pub get && dart analyze --fatal-infos && dart test EXAMPLE run-example: example diff --git a/README.md b/README.md index b17cb8e..cc24ca2 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,15 @@ built-ins use. Two worked examples do exactly that: one reads a live [SQLite database](examples/dmx_sqlite_example/README.md), one reads an [OpenAPI document](examples/dmx_openapi_example/README.md). -**Models defined in Markdown.** Some types have no Dart file to annotate yet. A -`*.dmx.md` document holds a [typeDiagram](https://typediagram.dev/docs/) -definition and, immediately below it, the Mustache templates that generate from -it: +**Models defined by a diagram.** Some types have no Dart file to annotate yet. +Write the model once as a [typeDiagram](https://typediagram.dev/docs/) +definition and save: + +```text +models/parcel.td the definition +lib/parcel.dart what dmx writes +``` -````markdown ```typeDiagram type Parcel { id: Uuid @@ -125,26 +128,59 @@ type Parcel { } ``` -```mustache {"dmx":{"output":"lib/parcel.dart"}} -{{#declarations}} -final class {{name}} { - const {{name}}({{{constructorParameters}}}); -{{#fields}} - final {{{dartType}}} {{name}}; -{{/fields}} +```dart +final class Parcel { + const Parcel({required this.id, required this.weightG, this.insured}); + + final String id; + final int weightG; + final String? insured; + + @override + bool operator ==(Object other) => /* every field, collections by content */; + + @override + int get hashCode => Object.hash(runtimeType, id, weightG, insured); + + Parcel copyWith({String? id, int? weightG, dmx.DmxPatch insured}); +} + +/// JSON for [Parcel]. +extension ParcelJson on Parcel { + static dmx.Result fromJson(Object? json, [String path = 'Parcel']); + Map toJson(); } -{{/declarations}} ``` -```` -Save the document and dmx writes `lib/parcel.dart`, relative to the package the -document belongs to. The definition still renders as a diagram in any -typeDiagram viewer, so one page is the model, the documentation, and the build -input. dmx reads the definition itself — no Node, no npm package, no -`typediagram` executable — and the template decides every generated byte. One -definition may feed several templates: the -[shipping document](examples/storefront/docs/shipping.dmx.md) defines four types -once and generates two different Dart files from them. +Nothing is embedded in anything: the `.td` is what any typeDiagram tool reads, +and `lib/parcel.dart` is one complete Dart file, relative to the package the +definition belongs to. The class is an immutable value — it compares by value, +hashes consistently with that comparison, and copies — and its JSON lives on an +extension beside it rather than inside it, so the class reads as what the +diagram said and nothing else. That is the **canonical model template** dmx +ships: one template, and every model class comes out of it. + +To decide the shape yourself, put `parcel.mustache` beside `parcel.td` and it +takes the canonical template's place. Any other template beside the definition +is an extra output, bound by its name — the +[shipping example](examples/storefront/models/README.md) defines four types once +and generates two different Dart files from them. dmx reads the definition +itself — no Node, no npm package, no `typediagram` executable. + +The definition and its templates can also live inside one `*.dmx.md` document, +when the model, the diagram, and the prose explaining them belong on one page: + +````markdown +```typeDiagram +type Parcel { + id: Uuid +} +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +{{#declarations}}final class {{name}} {}{{/declarations}} +``` +```` **It never writes broken Dart.** @@ -166,11 +202,12 @@ dmx watch [PATHS...] dmx explain FILE ``` -`build` and `watch` default to `lib`. Both take Dart sources, `*.dmx.md` -documents found under the paths given, and any Markdown file named explicitly. -`watch` regenerates what changed and debounces save bursts. `--check` writes -nothing and exits 2 on drift, for CI. `dmx explain docs/models.dmx.md` prints -each generation group, its outputs, its dependency digests, and the exact +`build` and `watch` default to `lib`. Both take Dart sources, `*.td` definitions +and `*.dmx.md` documents found under the paths given, and any Markdown file named +explicitly. `watch` regenerates what changed — including when you edit a +`.mustache` file beside a definition — and debounces save bursts. `--check` +writes nothing and exits 2 on drift, for CI. `dmx explain models/parcel.td` +prints each generation group, its outputs, its dependency digests, and the exact context its templates will see — without generating anything. ## Working on dmx diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 3653129..b9cd862 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -7,16 +7,16 @@ "rust": { "threshold": 93, "lcov": "lcov.info", - "_covers": "The dmx crate at src/dmx — the parser, context builder, renderer, validator and emitter. Everything the binary does, the typeDiagram Markdown front end included.", + "_covers": "The dmx crate at src/dmx — the parser, context builder, renderer, validator and emitter. Everything the binary does, both typeDiagram front ends included: standalone `.td` definitions with the canonical model template or a `.mustache` beside them, and Markdown documents.", "_produced_by": "cargo llvm-cov --workspace --all-targets", - "_measured": "93.7% (6003/6404 lines) when the typeDiagram Markdown macro landed, up from 91.2% when the macro catalogue did. [COVERAGE-THRESHOLDS] requires 85 for a CLI tool, which this clears twice over." + "_measured": "94.0% (6560/6977 lines) when the canonical model template landed, up from 93.7% when the typeDiagram Markdown macro did and 91.2% when the macro catalogue did. The threshold stays at 93 rather than tracking the measure exactly: a floor with no headroom fails CI on noise rather than on a regression. [COVERAGE-THRESHOLDS] requires 85 for a CLI tool, which this clears twice over." }, "dart-package": { "threshold": 13, "lcov": "src/dart_packages/dmx/lcov.info", "_covers": "src/dart_packages/dmx/lib — the published runtime every consumer of dmx depends on: Result/Ok/Err, the decoders, DmxPatch, the transport seam, and the macro-authoring API.", "_produced_by": "dart test --coverage + coverage:format_coverage --report-on=lib", - "_measured": "13.1% (54/411 lines) on 2026-08-15, up from 11.4% (47/411) when the release stamper's tests started exercising Result. THE WORST NUMBER IN THE REPO AND THE ONE THAT MATTERS MOST — this package is on pub.dev. dmx.dart, support.dart and transport.dart are still almost entirely unmeasured. The runtime IS exercised end-to-end by the storefront's 179 tests and the golden corpus, which is why nothing has broken; it is not exercised by its own suite, so a refactor of it is unprotected. Ratchet this hard." + "_measured": "13.1% (54/411 lines) on 2026-08-15, up from 11.4% (47/411) when the release stamper's tests started exercising Result. THE WORST NUMBER IN THE REPO AND THE ONE THAT MATTERS MOST — this package is on pub.dev. dmx.dart, support.dart and transport.dart are still almost entirely unmeasured. The runtime IS exercised end-to-end by the storefront's suite and the golden corpus, which is why nothing has broken; it is not exercised by its own suite, so a refactor of it is unprotected. Ratchet this hard." }, "vscode-extension": { "threshold": 95, diff --git a/docs/messaging.md b/docs/messaging.md index 343db2c..8ecf3fc 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -32,9 +32,9 @@ dmx does not replace one fixed model shape with another. Teams encode their exac Custom macros and Mustache templates are one system, not competing options. A macro can return Dart directly, and a small one usually should. It can also hand its model to a Mustache template and let dmx render it with the same engine the built-ins use, which is how a project keeps generation logic and output shape in separate files: the macro answers questions only the project can answer, and the template decides what the emitted Dart looks like. The [OpenAPI example](../examples/dmx_openapi_example/README.md) reads a published API document and renders a typed client and its models through project-owned templates. -Some models have no Dart file to annotate yet. A `*.dmx.md` document holds a [typeDiagram](https://typediagram.dev/docs/) definition and, immediately below it, the Mustache templates that generate from it. Save the document and dmx writes the `.dart` files those templates name. The definition still renders as a diagram in any typeDiagram viewer, so one page is the model, the documentation, and the build input. The [shipping document](../examples/storefront/docs/shipping.dmx.md) defines four types once and generates two different Dart files from them. +Some models have no Dart file to annotate yet. Write the model once as a [typeDiagram](https://typediagram.dev/docs/) definition—`shipping.td`—and save. dmx writes one complete Dart file: immutable classes with `==`, `hashCode`, `toString`, and `copyWith`, a sealed class per union, and JSON on an extension beside each class rather than inside it. That comes from the canonical model template dmx ships—the same one every model class comes out of—and nothing is embedded in anything: the `.td` file is what any typeDiagram tool reads. Put a Mustache template beside the definition and it takes the canonical template's place; add another and you get another file. The [shipping example](../examples/storefront/models/README.md) defines four types once and generates two different Dart files from them. The definition and its templates can also live inside one `*.dmx.md` document, when the model, the diagram, and the prose explaining them belong on one page. -> Save the file and keep coding. Use a built-in, change what it emits with a Mustache template, write a macro in Dart—and render a Mustache template from inside that macro too—or define the types in Markdown and let the templates write the Dart. +> Save the file and keep coding. Use a built-in, change what it emits with a Mustache template, write a macro in Dart—and render a Mustache template from inside that macro too—or define the types in a diagram and let dmx write the Dart. ## Ready-to-use copy @@ -50,13 +50,13 @@ Open the project, edit Dart, and save. dmx updates generated code automatically, [Try the real generator in your browser](https://dmx.dev/playground.html)—no install required. -### Models in Markdown +### Models from a diagram -**Define the types once in a `*.dmx.md` document; the Mustache templates under the diagram write the Dart.** Save the document and every file it names updates—no annotated Dart source, no `part` file, and the diagram still renders. +**Define the types once in a typeDiagram file and save; dmx writes the Dart.** Immutable classes that compare by value, with JSON beside them instead of inside them—no annotated Dart source, no `part` file, and the definition still renders as a diagram. Put a Mustache template beside it to decide the shape yourself, or put the definition and its templates in a `*.dmx.md` document to keep them on one page. ### Repository -Fast Dart code generation on every save, with no generated `part` files: built-in macros, team-owned Mustache templates, custom Dart macros, models defined in Markdown, and validated inline output. +Fast Dart code generation on every save, with no generated `part` files: built-in macros, team-owned Mustache templates, custom Dart macros, models defined in a typeDiagram file, and validated inline output. ## Message order @@ -66,7 +66,7 @@ Fast Dart code generation on every save, with no generated `part` files: built-i 4. **Useful immediately:** built-ins cover common models, unions, routes, clients, and more. 5. **The team's shape:** Mustache controls the exact generated Dart. 6. **Full custom macros:** inspect typed declaration data, read project data, and generate members or complete files—returning Dart directly, or rendering it through the same Mustache engine the built-ins use. -7. **Models with no Dart to annotate:** a `*.dmx.md` document defines the types once and its Mustache templates generate the `.dart` files. +7. **Models with no Dart to annotate:** a typeDiagram definition file generates the `.dart` file on its own, through the canonical model template or through a Mustache template beside it—or a `*.dmx.md` document does the same on one page. 8. **Reliable writes:** validate complete Dart files before writing and preserve handwritten source on failure. ## Demo order @@ -76,7 +76,7 @@ Fast Dart code generation on every save, with no generated `part` files: built-i 3. Rename a field, save, and show generated members update immediately. 4. Change a Mustache template and show the team's model shape appear. 5. Add a SQLite table and show a complete Dart file appear. -6. Add a field to a `*.dmx.md` diagram, save, and show both generated Dart files change together. +6. Add a field to a typeDiagram definition, save, and show both generated Dart files change together—`==`, `hashCode`, `copyWith`, and the JSON extension all move with it. ## Positioning diff --git a/docs/plans/typediagram-integration.md b/docs/plans/typediagram-integration.md index 4fadcfb..dd9871a 100644 --- a/docs/plans/typediagram-integration.md +++ b/docs/plans/typediagram-integration.md @@ -100,16 +100,22 @@ Every phase above is implemented, tested, and gated by `make ci`. |---|---| | `src/dmx/src/typediagram/{lexer,parser,ast,model}.rs` | The typeDiagram front end, in Rust, with no typeDiagram dependency | | `src/dmx/src/typediagram/json.rs` | The model in upstream's JSON shape — the compatibility surface, read only by the differential corpus | -| `src/dmx/src/typediagram/markdown.rs` | CommonMark binding over `pulldown-cmark`, fences as AST nodes | +| `src/dmx/src/typediagram/binding.rs` | What a binding *is*, shared by both front ends — group, template, output, and the sentence each origin is located by | +| `src/dmx/src/typediagram/standalone.rs` | The `.td` front end: the canonical model template, or the `.mustache` files beside the definition | +| `src/dmx/src/typediagram/semantics.rs` | Value semantics and the JSON codec one generated class gets [typediagram.canonical] | +| `src/dmx/src/typediagram/naming.rs` | What generated code calls each union case [typediagram.canonical.names] | +| `src/dmx/templates/diagram_model.mustache` | The canonical model template itself | +| `src/dmx/src/typediagram/markdown.rs` | The Markdown front end: CommonMark binding over `pulldown-cmark`, fences as AST nodes | +| `src/dmx/src/typediagram/run.rs` | The one pipeline behind both — resolve, invoke, validate, emit, explain | | `src/dmx/src/typediagram/context.rs` | The Mustache context, versioned by `CONTEXT_VERSION` | | `src/dmx/src/typediagram/target.rs` | The one place a language appears: type text, extension, project marker, validation | -| `src/dmx/src/typediagram/{emit,document}.rs` | Path safety, ownership markers, stale collection, build/check/explain | +| `src/dmx/src/typediagram/{emit,document}.rs` | Path safety, ownership markers, stale collection, and the Markdown entry point | | `src/dmx/src/macros/typediagram.rs` | The built-in macro, in the same registry `@dmx('model')` is in | | `src/dmx/src/hygiene.rs` | [hygiene] as a CST check, because a user template is nobody's reviewed code | | `src/dmx/tests/typediagram/corpus` | The `.td` fixtures and the oracle's model JSON | -| `src/dmx/tests/typediagram/golden` | The same fixtures rendered to Dart through one shared template, committed and analyzer-gated | +| `src/dmx/tests/typediagram/golden` | The same fixtures rendered to Dart as standalone files through one shared template, committed and analyzer-gated | | `scripts/typediagram-oracle.mjs` | Development-only regeneration of that oracle from a typeDiagram checkout | -| `examples/storefront/docs/shipping.dmx.md` | One definition, two generated Dart files, 9 tests over them | +| `examples/storefront/models/` | `shipping.td` and two templates beside it, two generated Dart files, 9 tests over them | ### [typediagram.delivery.corpus] Corpus → Dart @@ -118,13 +124,13 @@ serialised, and compared against the oracle's JSON, and no Dart was ever produced from them. Against [emission] — emitting Dart that does not compile is the worst failure this repo has — model parity alone was not enough. -Each fixture is now wrapped in a real `*.dmx.md` document over one shared -template, run through the shipped binary, and committed as -`tests/typediagram/golden/lib/.dart`. `cargo test --test -typediagram_golden` holds the bytes; `make corpus` runs `dart analyze ---fatal-infos` over them. The definitions are never copied — the document is -assembled from the `.td` file at test time, so the parity corpus stays the one -place a definition is written. +The whole corpus is now laid out as standalone files — `models/.td` +beside `models/.mustache` — built by the shipped binary in one `dmx +build`, and committed as `tests/typediagram/golden/lib/.dart`. `cargo +test --test typediagram_golden` holds the bytes; `make corpus` runs `dart +analyze --fatal-infos` over them. Nothing is wrapped, assembled, or extracted: +the `.td` files are copied out of the parity corpus byte for byte, so it stays +the one place a definition is written. - [x] Every corpus fixture renders to Dart and the output is committed and byte-gated. - [x] `make corpus` analyzes it with `dart analyze --fatal-infos`. @@ -132,8 +138,56 @@ place a definition is written. - [x] A signature carries `isOverload` so a target without overloading can name each one. `hasOverloads` on the declaration cannot be read from inside `{{#signatures}}`: a section entered on a name the *declaration* carries pushes that value with the declaration beneath it, so the ordinal read back is the declaration's. - [x] The shipped storefront template stopped using `{{genericDeclaration}}`, which HTML-escapes `` into `<T>`. It only ever worked there because nothing in that document is generic. +### [typediagram.delivery.standalone] Definition File → Template File → Dart + +A definition and its templates had exactly one spelling: fences inside a +Markdown document. That made the plain case — a model file, a template file, a +generated file — reachable only by writing prose around it, and it put a +CommonMark parse between an author and their own definition. + +`.td` + `.mustache` → `.dart` is now the primary spelling +[typediagram.standalone]. The binding is the file names; the pipeline behind it +is the same one, because both front ends build the same +`binding::Group` and everything after that is `run.rs`. + +- [x] `.td` files are discovered recursively by `build` and `watch`, and accepted by name. +- [x] `.mustache` binds to `.td`; `..mustache` is a second output; the longest matching definition wins. +- [x] The default output is the target's own source root, casing, and extension — `shipping.wire.mustache` → `lib/shipping_wire.dart`. A target now carries `source_root`, so the convention is a language's decision rather than a hard-coded `lib`. +- [x] A leading `{{! dmx output=… target=… }}` comment overrides it, and stays in the template because it renders to nothing. `key=value` rather than JSON: a Mustache comment ends at the first `}` inside it, which no object can survive. +- [x] A `.mustache` file with no definition beside it is left alone, so the catalogue's preview templates and every other project's Mustache stay untouched. +- [x] Editing a template regenerates the definition it is bound to — `.mustache` is watched but never generated *from*, so `--check` cannot report the same drift twice. +- [x] `dmx explain` takes a `.td`, a template bound to one, or a document. +- [x] Diagnostics are located in each origin's own terms: a file by its name and a real line number, a fence by its ordinal and the document line. No fence appears in a message about a file. +- [x] The VS Code extension watches `*.td` alongside `*.dmx.md`, and leaves templates to the binary. +- [x] The golden corpus and the storefront example both generate from standalone files. + +### [typediagram.delivery.canonical] One Model Template + +A definition with no template beside it used to generate nothing, and every +project that wanted model classes had to write — and then maintain — its own +Mustache. The golden corpus and the storefront example each carried a near-copy +of the same one, and neither produced a *value*: the classes had no `==`, no +`hashCode`, no `copyWith`, and no codec. + +There is now exactly one model template, shipped in the binary and used +wherever a diagram generates model classes [typediagram.canonical]. + +- [x] A definition with nothing beside it renders through the canonical model template; a `.mustache` takes its place; a `..mustache` is still an extra output. +- [x] Records and union cases are immutable values: `==`, `hashCode`, `toString`, `copyWith` — built by the same Rust that builds them for `@dmx('model')`, so the annotated path and the diagram path can never say different things about the same type. +- [x] JSON is on an `extension Json`, never on the class. `types::Decoders` is what makes a nested decode name the extension, and the annotated path keeps naming the class. +- [x] The runtime import is prefixed, so a diagram that declares its own `Result`, `Ok`, or `Err` — as the parity corpus does — cannot hide the one the codec means. +- [x] A declaration dmx cannot build a codec for keeps its class and its value semantics and gets no extension, and `dmx explain` says which member decided that (`DMX8009`). +- [x] A union case is called what typeDiagram calls it — the case's own name — and takes its union's name as a prefix only where Dart's one namespace forces it [typediagram.canonical.names]. +- [x] The golden corpus is the canonical template's gate: every shape typeDiagram can express, regenerated byte-for-byte and run through `dart analyze --fatal-infos`. + ### [typediagram.delivery.next] Not Yet Done +- [ ] **tdbin interop.** The names are already aligned: a case generates under the name typeDiagram's own emitters give it [typediagram.canonical.names], so a type dmx generated and a type typeDiagram generated are the same type by name. What that interop needs beyond agreeing names — which artefacts are exchanged, in which direction, and what dmx reads or writes — is not yet written down here. +- [ ] **A union case cannot be a field's type unless its union can be decoded.** A field typed by a *generic* or *untagged* union has no codec, so its owner has none either. Tagged, non-generic unions work; the other two need something in the payload that says which case it is, and the diagram does not say it. +- [ ] **Mustache partials.** `model.mustache` and `diagram_model.mustache` place the same prepared expressions in two layouts — one into a class body somebody else owns, one into a whole file. The expressions are shared in Rust; the *layout* is written twice because ramhorns resolves partials from a folder and dmx's templates are compiled in. + +- [ ] **A `.td` grammar for the editor.** The extension ships a Mustache grammar and a Dart injection; a `.td` file gets no language id, no comment toggle, and no highlighting. It is the most visible gap now that definitions are files people open. + - [ ] **Decide what `{{ }}` means for a code generator.** Mustache escapes it as HTML, which is never right for Dart: any value holding `<`, `>`, `&` or `"` — every generic type, every function type — silently becomes uncompilable. `{{{ }}}` is the documented way out and the built-in templates use it, but the default is a trap that fails at the analyzer rather than at the template. Either drop escaping for code targets (`jsoncontent.rs` `render_escaped`, plus the two tests that pin the current behaviour) or make an unescaped-by-default tag the documented norm. - [ ] **A rule for reading a parent's name inside a child section.** `isOverload` solves one instance of a general trap: any `{{#parentFlag}}…{{childName}}…{{/parentFlag}}` reads the parent's value. Either document the rule where template authors will meet it or push the flags every loop body needs onto the loop's own members. - [ ] **A second generation target.** The seam is in place and carries one row; the value of the split is unproven until a second language uses it. It is also what would force the questions the Dart-only path never asks: identifier casing per target, reserved words, and how positional members are named somewhere other than Dart. @@ -141,16 +195,18 @@ place a definition is written. - [ ] **Prove `@targets` exclusion end to end.** `targeting.td` selects nothing away for `dart`, so the corpus shows the filter keeping declarations and never shows it dropping one. A fixture that excludes the target under test would. - [ ] **`dmx explain --stages` for documents.** `explain` prints groups, dependencies, paths, and the exact context, but not the render → hygiene → validation stages [execution]. - [ ] **A persistent build cache.** Outputs are compared whole, which is correct and re-renders more than a cache would. -- [ ] **Partials in document templates.** Every bound fence is self-contained, so two templates over one definition cannot share a fragment. +- [ ] **Partials in templates.** Every template is self-contained, so two templates over one definition cannot share a fragment — which is why the golden corpus copies one template body per fixture instead of referencing it. +- [ ] **Two definitions claiming one output.** Duplicate outputs are refused within one definition's bindings and within one document, but not across two sources in the same pass: each would take the other's file over and the last pass would win. The ownership marker already records which source wrote a file, so the check has what it needs. +- [ ] **A definition and a document in one package.** Nothing prevents it and nothing tests it. The storefront now shows only the file spelling; the document spelling is proved by `typediagram_cli` and the extension's end-to-end suite instead. ## [typediagram.delivery.acceptance] Acceptance Criteria -- Ordinary typeDiagram Markdown remains valid and renderable outside dmx. -- `typeDiagram` is resolved by the ordinary built-in macro registry; Markdown binding does not create a second macro engine. +- A `.td` file is ordinary typeDiagram and an ordinary typeDiagram Markdown document stays renderable outside dmx; a `.mustache` file is ordinary Mustache. +- `typeDiagram` is resolved by the ordinary built-in macro registry; neither front end creates a second macro engine, a second context shape, or a second ownership protocol. - Production parsing and resolution run entirely in Rust without typeDiagram tooling or Node. - A definition is authored once and may feed multiple Mustache outputs without copying the model. - Mustache, not typeDiagram's language emitter, controls every generated byte. - Templates receive resolved, target-ready values and contain no type-system logic. - Invalid definitions, metadata, templates, paths, or Dart fail without changing output. - `build`, `check`, `watch`, and `explain` agree on groups, context, dependencies, and output paths. -- Every generated file is deterministic, owned, analyzer-clean, below 500 lines, and reproducible from its Markdown source. +- Every generated file is deterministic, owned, analyzer-clean, below 500 lines, and reproducible from its definition and template. diff --git a/docs/specs/SPEC.md b/docs/specs/SPEC.md index 5d469e3..9478cc6 100644 --- a/docs/specs/SPEC.md +++ b/docs/specs/SPEC.md @@ -11,7 +11,7 @@ Implementation code, tests, and diagnostics MUST cite the identifier they satisf | [Authoring, conformance, and goals](authoring-and-goals.md) | `[authoring]`, `[conformance]`, `[goals]` | Roles, authoring contract, conformance language, goals and non-goals | | [Repository layout](repository.md) | `[repo]` | Where code lives, and holding the repository's own tooling to it | | [Architecture](architecture.md) | `[architecture]` | Parse-to-cache pipeline and purity boundary | -| [typeDiagram Markdown macro](typediagram-markdown.md) | `[typediagram]` | Built-in macro: typeDiagram definitions plus Mustache templates to generated Dart | +| [typeDiagram macro](typediagram.md) | `[typediagram]` | Built-in macro: typeDiagram definitions plus Mustache templates to generated Dart | | [Consumer surface and front end](consumer-surface.md) | `[surface]`, `[context]`, `[frontend]` | Annotations, template context, parsing and name resolution | | [Extension layers and Dart macros](extensions.md) | `[extensions]`, `[dartmacros]` | Workers, transforms, engines, and user-defined macros | | [Rendering, hygiene, and validation](rendering-and-validation.md) | `[rendering]`, `[hygiene]`, `[validation]` | Template execution and generated-source safety | diff --git a/docs/specs/typediagram-markdown.md b/docs/specs/typediagram-markdown.md deleted file mode 100644 index 9f708f4..0000000 --- a/docs/specs/typediagram-markdown.md +++ /dev/null @@ -1,141 +0,0 @@ -# dmx — typeDiagram Markdown Macro - -Part of the [dmx specification](SPEC.md). - -## [typediagram] typeDiagram Definitions plus Mustache Templates - -dmx MUST generate Dart model source from typeDiagram definitions embedded in Markdown and user-authored Mustache templates: - -```mermaid -flowchart LR - markdown["Markdown document"] --> fences["CommonMark fenced blocks"] - fences --> invocation["Built-in typeDiagram macro invocation"] - invocation --> model["Native Rust typeDiagram model"] - model --> context["dmx template context"] - context --> render["Mustache render"] - render --> validation["Hygiene and Dart validation"] - validation --> output["Owned generated file"] -``` - -[typeDiagram](https://typediagram.dev/docs/) supplies the model language and semantics. Mustache supplies the output shape. dmx joins them and owns parsing, validation, deterministic execution, and safe file emission. The production path MUST NOT invoke the typeDiagram CLI, library, runtime, `--to dart`, or any other typeDiagram language emitter. - -### [typediagram.macro] Built-in Macro - -`typeDiagram` is a built-in dmx macro. Its target is a Markdown generation group rather than a Dart declaration, so it is activated by [typediagram.binding] instead of an `@dmx('typeDiagram')` annotation. - -The Markdown front end MUST synthesize one immutable macro invocation per definition/template group and dispatch it through the same built-in macro registry as annotation-triggered macros. The invocation carries the typeDiagram source span, each bound template and output path, and the parsed model. The macro's Rust context builder enriches that model for Mustache; each render becomes a macro-authored whole file using the existing output branch in [dartmacros.files]. - -Built-in resolution, determinism, diagnostics, caching, explain output, hygiene, validation, and emission rules apply unchanged. The different trigger syntax MUST NOT create a second macro engine or bypass the shared pipeline. - -### [typediagram.documents] Source Documents - -`dmx build ` and `dmx watch ` MUST accept an explicit Markdown file. Recursive discovery MUST include files named `*.dmx.md` and MUST ignore other Markdown files unless they are passed explicitly. - -dmx MUST parse Markdown with a CommonMark-compatible parser and inspect fenced-code nodes. It MUST NOT locate fences with regex. Prose, headings, links, lists, quotes, HTML, and unrelated fenced blocks are documentation and MUST remain untouched byte-for-byte. - -A typeDiagram source fence uses backticks, has the ordinary upstream-compatible info string `typeDiagram`, compared case-insensitively, and contains valid typeDiagram DSL. The fence MUST remain renderable by typeDiagram's Markdown tooling; dmx-specific metadata is therefore never added to the typeDiagram fence. - -### [typediagram.binding] Definition-to-Template Binding - -A generation group is one typeDiagram fence followed immediately in the Markdown AST by one or more dmx-enabled Mustache fences. Blank lines do not create AST nodes and do not break the group. Any other Markdown node ends the group. - -A dmx-enabled Mustache fence uses `mustache` as its language and a JSON object as the remainder of its info string. The object MUST contain `dmx.output`, an output path relative to the document's output root ([typediagram.output]), and MAY contain `dmx.target`, the name of a generation target, defaulting to `dart`. Any other key under `dmx` is an error rather than a value dmx ignores, so a misspelling is reported instead of silently generating nothing. Metadata that does not begin with `{` belongs to another convention and MUST be left alone: - -```typeDiagram -type Product { - id: String - name: String - price: Decimal -} -``` - -````markdown -## Store models - -A template binds to the definition immediately above it, so the two fences are -consecutive: a heading between them would end the group. - -```typeDiagram -type Product { - id: String - name: String - price: Decimal -} -``` - -```mustache {"dmx":{"output":"lib/models/store.dart"}} -{{#declarations}} -{{#isRecord}} -final class {{name}}{{genericDeclaration}} { - const {{name}}({{#fields}}required this.{{name}}{{comma}}{{/fields}}); -{{#fields}} - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/isRecord}} -{{/declarations}} -``` -```` - -One typeDiagram fence MAY feed several consecutive dmx-enabled Mustache fences, allowing the same model to generate several files without duplicating definitions. A typeDiagram fence with no bound dmx template remains documentation-only and MUST be ignored by dmx. A Mustache fence without `dmx` metadata is an example and MUST be ignored. - -Malformed metadata, a dmx-enabled Mustache fence without an immediately preceding definition group, or two templates resolving to the same output path MUST fail the build. Association MUST never depend on a heading's text, fence ordinal across the document, or implicit global state. - -### [typediagram.model] Model and Context - -dmx MUST tokenize, parse, resolve, and validate the definition natively in Rust with typeDiagram-compatible semantics, then build one immutable context for each generation group. Production generation MUST require no Node process, npm package, `typediagram` executable, or network access. The compatibility baseline MUST be pinned and covered in development by differential fixtures against typeDiagram's public parser and versioned model JSON. - -Declaration order, field order, variant order, generic parameter order, explicit discriminants, and recursively nested type arguments MUST be preserved. Unknown or unsupported type references MUST fail before rendering rather than pass through as source text. - -The Mustache root contains `source`, `declarations`, and `modelVersion`. `source` contains the Markdown path and one-based fence position. `declarations` contains each typeDiagram declaration exactly once in source order. - -Every declaration exposes `kind`, `name`, `generics`, and mutually exclusive `isRecord`, `isUnion`, `isAlias`, and `isFunction` booleans. Records expose `fields`; unions expose `variants`; aliases expose `target`; functions expose `signatures`. Nested members carry `first`, `last`, and `comma` values so templates remain logic-free. Every type reference exposes its canonical typeDiagram spelling and a precomputed `dartType`; templates MUST NOT implement type resolution or Dart type mapping. - -The context builder MAY add further derived strings and booleans, but it MUST NOT discard or reorder source model data. Context schema changes require a version bump and golden fixtures. - -Every target-language decision MUST be confined to one generation target: the mapping from a resolved reference to that language's type text, the extension its outputs carry, and the validation a finished file passes. Nothing else in the feature — tokenizer, parser, model, binder, context builder, emitter — may name a language. A target a document names but this build does not carry is `DMX8007`. - -### [typediagram.templates] Rendering - -The built-in `typeDiagram` macro renders each bound Mustache body once against its group's complete context. It follows all determinism, partial-resolution, span-mapping, and no-I/O requirements in [rendering]. All target-language decisions needed by the template MUST be finished in the macro's Rust context builder; the template only selects and places prepared values. - -A template failure MUST identify the Markdown file, template fence, template line, and bound typeDiagram fence. Rendering one output MUST NOT mutate context observed by another output in the same group. - -### [typediagram.output] Validation and Emission - -`dmx.output` MUST resolve against the document's **output root**: the nearest ancestor of the document that carries a project marker any target recognises — `pubspec.yaml` for Dart — bounded by the workspace, and the workspace itself when there is none. `lib/models.dart` therefore means *this package's* `lib`, so a document generates the same bytes in the same place whether dmx was run from the package, from the repository root, or from an editor that opened the whole tree. - -`dmx.output` MUST normalize to a path inside that root, MUST carry the extension its target generates, and MUST NOT traverse a symbolic link outside it. Absolute paths and parent traversal are errors, and an output path equal to the source document is an error. A document is identified, in its ownership markers and its templates' contexts, by its path relative to that same root, so nothing recorded in a generated file depends on where dmx was launched. - -Rendered output MUST pass the same whitespace normalization, hygiene, full-file Dart re-parse, and `dart analyze --fatal-infos` corpus gates as other generated Dart. It MUST NOT contain `throw`, casts, null assertions, or other constructs forbidden in generated Dart; that is [hygiene], enforced over the tree-sitter CST rather than over the text. The file MUST carry a dmx ownership marker containing the source Markdown path, fence identity, template hash, typeDiagram definition hash, context version, and dmx version. Its first line MUST be the same ownership marker whole-file emission already uses [dartmacros.files], so one predicate decides ownership for every backend that writes a file dmx owns. - -Whole-file emission follows [dartmacros.files]: never overwrite an unmarked file, write atomically, avoid no-op writes, remove stale owned outputs when their template disappears, and report drift without writing under `--check`. The source Markdown is never rewritten. - -An output MUST have one live source. A target already carrying another source's ownership marker MUST be refused while that source still exists, because each pass would otherwise undo the other's. A marker naming a source that is gone identifies an orphan, and taking it over is what renaming a document is supposed to do. - -### [typediagram.execution] Build, Check, Watch, and Explain - -`build`, `check`, and `watch` MUST treat the Markdown document, definition fence, template fence, and resolved partials as dependencies of every output. A change to prose outside a generation group MUST NOT invalidate its output. A semantic definition or template change MUST invalidate every dependent output. - -`watch` MUST retain the last valid output after an invalid edit and recover on the next valid save. `dmx explain ` MUST print each group, its source spans, normalized output paths, dependency hashes, and exact context JSON without rendering or writing. - -Stale collection is scoped to the roots the pass was asked to manage: an output whose ownership marker names this document, which the document no longer produces, MUST be removed (or, under `--check`, reported) when it is inside those roots. - -### [typediagram.diagnostics] Diagnostics - -The feature owns the `DMX8xxx` range: - -| Code | Meaning | -|---|---| -| `DMX8001` | Malformed or incomplete JSON metadata on a dmx-enabled Mustache fence | -| `DMX8002` | dmx-enabled Mustache fence is not bound to a typeDiagram definition group | -| `DMX8003` | Two templates claim the same normalized output path | -| `DMX8004` | typeDiagram definition failed parsing, resolution, or compatibility validation | -| `DMX8005` | Output path is absolute, escapes the workspace, crosses an unsafe symlink, or is not Dart | -| `DMX8006` | Output exists without the matching dmx ownership marker | -| `DMX8007` | typeDiagram compatibility or context schema version is unsupported | -| `DMX8008` | A bound Mustache template does not compile, or its render is not source the target accepts | - -Every diagnostic MUST carry the Markdown path and fenced-block span. When applicable it also carries the typeDiagram line/column, template line/column, generated Dart line/column, and output path. - -Rendered source that does not parse, or that breaks [hygiene], is refused by the shared diagnostics those stages already own — `DMX4001` and `DMX4003` — wrapped in a `DMX8008` that names the document, the group, and the template fence. A macro name this registry serves from a Markdown group MUST NOT be reachable as an annotation: `@dmx('typeDiagram')` is `DMX2006`. diff --git a/docs/specs/typediagram.md b/docs/specs/typediagram.md new file mode 100644 index 0000000..07d0f97 --- /dev/null +++ b/docs/specs/typediagram.md @@ -0,0 +1,199 @@ +# dmx — typeDiagram Macro + +Part of the [dmx specification](SPEC.md). + +## [typediagram] typeDiagram Definitions plus Mustache Templates + +dmx MUST generate Dart model source from typeDiagram definitions and user-authored Mustache templates: + +```mermaid +flowchart LR + files["shipping.td + shipping.mustache"] --> group + markdown["Markdown document"] --> fences["CommonMark fenced blocks"] + fences --> group["Generation group"] + group --> invocation["Built-in typeDiagram macro invocation"] + invocation --> model["Native Rust typeDiagram model"] + model --> context["dmx template context"] + context --> render["Mustache render"] + render --> validation["Hygiene and Dart validation"] + validation --> output["Owned generated file"] +``` + +[typeDiagram](https://typediagram.dev/docs/) supplies the model language and semantics. Mustache supplies the output shape. dmx joins them and owns parsing, validation, deterministic execution, and safe file emission. The production path MUST NOT invoke the typeDiagram CLI, library, runtime, `--to dart`, or any other typeDiagram language emitter. + +There are two ways to write a definition and its templates down, and exactly one pipeline behind them. Standalone files ([typediagram.standalone]) are the plain spelling: a `.td` file, the `.mustache` files beside it, and the generated source. A Markdown document ([typediagram.documents]) keeps both inside prose that typeDiagram's own tooling still renders. Both front ends MUST produce the same generation group, and everything after binding — resolution, context, rendering, validation, emission, diagnostics, explain — MUST be shared. A front end MUST NOT introduce a second pipeline, a second context shape, or a second ownership protocol. + +### [typediagram.macro] Built-in Macro + +`typeDiagram` is a built-in dmx macro. Its target is a generation group rather than a Dart declaration, so it is activated by [typediagram.binding] instead of an `@dmx('typeDiagram')` annotation. + +A front end MUST synthesize one immutable macro invocation per definition/template group and dispatch it through the same built-in macro registry as annotation-triggered macros. The invocation carries the typeDiagram source span, each bound template and output path, and the parsed model. The macro's Rust context builder enriches that model for Mustache; each render becomes a macro-authored whole file using the existing output branch in [dartmacros.files]. + +Built-in resolution, determinism, diagnostics, caching, explain output, hygiene, validation, and emission rules apply unchanged. The different trigger syntax MUST NOT create a second macro engine or bypass the shared pipeline. + +### [typediagram.standalone] Standalone Definition and Template Files + +A typeDiagram definition MAY be a file of its own. `.td` contains typeDiagram DSL and nothing else: no front matter, no directives, no dmx metadata. It MUST remain byte-for-byte what typeDiagram's own tooling reads. + +`dmx build ` and `dmx watch ` MUST accept a `.td` file explicitly, and recursive discovery MUST include every `.td` file under the paths given. + +A template file is a `.mustache` file beside a definition. `.mustache` is bound to `.td`; `..mustache` is bound to `.td` as a second output. A template whose name matches more than one definition MUST bind to the longest match, so `shipping.wire.mustache` renders `shipping.wire.td` where one exists and `shipping.td` where it does not. Binding MUST NOT depend on anything else — not directory listing order, not a manifest, not a directive inside the definition. + +A `.mustache` file with no definition beside it is not a dmx source. It MUST be left alone and MUST NOT be generated from, because a project may hold Mustache files that are nothing to do with typeDiagram. + +The default output path is the target's source root, the template's name in that language's file-name casing, and the target's extension: `shipping.mustache` generates `lib/shipping.dart` and `shipping.wire.mustache` generates `lib/shipping_wire.dart`, resolved against the definition's output root ([typediagram.output]). + +A template MAY override that with a leading Mustache comment on its first line: + +```mustache +{{! dmx output=lib/models/shipping.dart target=dart }} +``` + +The comment MUST be a Mustache comment, so a template carrying one is still an ordinary template that any engine renders. Its settings are `key=value` pairs separated by whitespace rather than the JSON object a fence carries, because a Mustache comment cannot contain `}`. The keys, their meanings, their defaults, and every refusal MUST be identical to [typediagram.binding]'s: `output` and `target`, and any other key is `DMX8001`. + +The definition file, not the template, is what a pass generates from: a definition always renders, through the canonical model template where nothing beside it says otherwise ([typediagram.canonical]), and the ownership marker on every output names the definition, so removing a template collects the file it used to write. + +### [typediagram.canonical] The Canonical Model Template + +dmx ships one model template per target, compiled into the binary. A definition with no `.mustache` beside it MUST render through it, to the default output path. A `.mustache` beside the definition MUST take its place; a `..mustache` is an additional output and MUST NOT displace it. There is exactly one canonical template per target: every model class dmx generates from a diagram comes out of the same file. + +For every record, and for every case of every union, it MUST write a `final class` with a `const` constructor, its fields in declaration order, and value semantics: `==`, `hashCode`, `toString`, and `copyWith`. A union MUST become a `sealed` base class its cases extend; an alias a `typedef`; a function one `typedef` per signature. Union cases are named by [typediagram.canonical.names]. + +Value semantics MUST be the ones `@dmx('model')` generates, built by the same code ([model.equality], [model.copywith]): collections compare by content and hash consistently with that comparison, and a nullable field's `copyWith` parameter takes a patch so that omitting it and clearing it are different calls. + +`Unit` is Dart's `void`, which is not a value. A `void` member MUST take part in no comparison, no hash, and no `toString`, and a class holding one MUST NOT get a `copyWith`, because a `void` expression cannot be passed on. + +**JSON MUST NOT be a member of a generated class.** A class the diagram described MUST read as what the diagram said and nothing else. `toJson` and `fromJson` MUST be written on an `extension Json on ` beside it, and every generated call into another declaration's decoder MUST name that extension ([model.json-codec]). A union's extension MUST decode by reading its cases' tag under the `type` key and encode by writing it, matching what `@dmx('union')` writes when nobody names another key. + +The runtime import MUST be prefixed — `import 'package:dmx/dmx.dart' as dmx;` — and every runtime name in generated code MUST carry that prefix. A diagram may declare a type called `Result`, a local declaration hides an imported name, and an unprefixed import would resolve the codec to the wrong type. The import MUST be omitted from a file that reaches nothing in the runtime. + +A declaration MUST NOT be given a JSON extension when a codec cannot be built for one of its members: a type parameter, a generic declaration, an untagged union, `Unit`, or a map keyed by anything but a string. The class, its value semantics, and its `copyWith` are unaffected, and `dmx explain` MUST report `hasJson` together with the reason each refusal gave (`DMX8009`). + +#### [typediagram.canonical.names] What A Union Case Is Called + +A union case's class MUST be named by the case's own name — `final class Circle extends Shape` — which is what typeDiagram's own emitters name it. A diagram is a source of truth two tools generate from, and they MUST agree on what the types are called. + +A case name is unique only inside its union, and a Dart library has one namespace. So a case whose name is already taken MUST take its union's name as a prefix and become `` instead. A name is taken when another declaration in the same definition carries it, when another union declares a case of that name, or when it is a Dart name generated code writes itself (`bool`, `double`, `int`, `void`, `DateTime`, `Function`, `List`, `Map`, `Object`, `String`). A shared name MUST qualify on every side, so that no case is renamed by the accident of being declared second. + +A case that can be called neither by its own name nor by its qualified one MUST be refused (`DMX8010`), naming both. Two classes under one name is Dart that does not compile, and a numbered suffix would be a name nobody chose. + +### [typediagram.documents] Source Documents + +A definition and its templates MAY instead live inside one Markdown document, which keeps the model, the templates, and the prose explaining them in a page typeDiagram's own tooling still renders. + +`dmx build ` and `dmx watch ` MUST accept an explicit Markdown file. Recursive discovery MUST include files named `*.dmx.md` and MUST ignore other Markdown files unless they are passed explicitly. + +dmx MUST parse Markdown with a CommonMark-compatible parser and inspect fenced-code nodes. It MUST NOT locate fences with regex. Prose, headings, links, lists, quotes, HTML, and unrelated fenced blocks are documentation and MUST remain untouched byte-for-byte. + +A typeDiagram source fence uses backticks, has the ordinary upstream-compatible info string `typeDiagram`, compared case-insensitively, and contains valid typeDiagram DSL. The fence MUST remain renderable by typeDiagram's Markdown tooling; dmx-specific metadata is therefore never added to the typeDiagram fence. + +### [typediagram.binding] Definition-to-Template Binding + +A generation group is one definition and every template bound to it. Both front ends MUST build the same group, and it MUST remember its origin only for the purpose of naming a place in a diagnostic: a file is named, a fence is located by ordinal and line, and neither borrows the other's sentence. + +Inside a Markdown document, a generation group is one typeDiagram fence followed immediately in the Markdown AST by one or more dmx-enabled Mustache fences. Blank lines do not create AST nodes and do not break the group. Any other Markdown node ends the group. + +A dmx-enabled Mustache fence uses `mustache` as its language and a JSON object as the remainder of its info string. The object MUST contain `dmx.output`, an output path relative to the document's output root ([typediagram.output]), and MAY contain `dmx.target`, the name of a generation target, defaulting to `dart`. Any other key under `dmx` is an error rather than a value dmx ignores, so a misspelling is reported instead of silently generating nothing. Metadata that does not begin with `{` belongs to another convention and MUST be left alone: + +```typeDiagram +type Product { + id: String + name: String + price: Decimal +} +``` + +````markdown +## Store models + +A template binds to the definition immediately above it, so the two fences are +consecutive: a heading between them would end the group. + +```typeDiagram +type Product { + id: String + name: String + price: Decimal +} +``` + +```mustache {"dmx":{"output":"lib/models/store.dart"}} +{{#declarations}} +{{#isRecord}} +final class {{name}}{{genericDeclaration}} { + const {{name}}({{#fields}}required this.{{name}}{{comma}}{{/fields}}); +{{#fields}} + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/isRecord}} +{{/declarations}} +``` +```` + +One typeDiagram fence MAY feed several consecutive dmx-enabled Mustache fences, allowing the same model to generate several files without duplicating definitions. A typeDiagram fence with no bound dmx template remains documentation-only and MUST be ignored by dmx. A Mustache fence without `dmx` metadata is an example and MUST be ignored. + +Malformed metadata, a dmx-enabled Mustache fence without an immediately preceding definition group, or two templates resolving to the same output path MUST fail the build. The last of those is a rule about bindings, not about documents, and MUST be enforced identically for standalone files. Association MUST never depend on a heading's text, fence ordinal across the document, or implicit global state. + +### [typediagram.model] Model and Context + +dmx MUST tokenize, parse, resolve, and validate the definition natively in Rust with typeDiagram-compatible semantics, then build one immutable context for each generation group. Production generation MUST require no Node process, npm package, `typediagram` executable, or network access. The compatibility baseline MUST be pinned and covered in development by differential fixtures against typeDiagram's public parser and versioned model JSON. + +Declaration order, field order, variant order, generic parameter order, explicit discriminants, and recursively nested type arguments MUST be preserved. Unknown or unsupported type references MUST fail before rendering rather than pass through as source text. + +The Mustache root contains `source`, `declarations`, and `modelVersion`. `source` contains the definition's path, the template's path where the template is a file of its own, and the one-based fence positions where they are fences. `declarations` contains each typeDiagram declaration exactly once in source order. + +Every declaration exposes `kind`, `name`, `generics`, and mutually exclusive `isRecord`, `isUnion`, `isAlias`, and `isFunction` booleans. Records expose `fields`; unions expose `variants`; aliases expose `target`; functions expose `signatures`. Nested members carry `first`, `last`, and `comma` values so templates remain logic-free. Every type reference exposes its canonical typeDiagram spelling and a precomputed `dartType`; templates MUST NOT implement type resolution or Dart type mapping. + +The context builder MAY add further derived strings and booleans, but it MUST NOT discard or reorder source model data. Context schema changes require a version bump and golden fixtures. + +Every target-language decision MUST be confined to one generation target: the mapping from a resolved reference to that language's type text, the extension its outputs carry, and the validation a finished file passes. Nothing else in the feature — tokenizer, parser, model, binder, context builder, emitter — may name a language. A target a document names but this build does not carry is `DMX8007`. + +### [typediagram.templates] Rendering + +The built-in `typeDiagram` macro renders each bound Mustache body once against its group's complete context. It follows all determinism, partial-resolution, span-mapping, and no-I/O requirements in [rendering]. All target-language decisions needed by the template MUST be finished in the macro's Rust context builder; the template only selects and places prepared values. + +A template failure MUST identify the definition and the template it was rendering — the two files for a standalone pair, and the document, template fence, template line, and bound typeDiagram fence for a document. Rendering one output MUST NOT mutate context observed by another output in the same group. + +### [typediagram.output] Validation and Emission + +An output path MUST resolve against its source's **output root**: the nearest ancestor of the definition or document that carries a project marker any target recognises — `pubspec.yaml` for Dart — bounded by the workspace, and the workspace itself when there is none. `lib/models.dart` therefore means *this package's* `lib`, so a document generates the same bytes in the same place whether dmx was run from the package, from the repository root, or from an editor that opened the whole tree. + +An output path MUST normalize to a path inside that root, MUST carry the extension its target generates, and MUST NOT traverse a symbolic link outside it. Absolute paths and parent traversal are errors, and an output path equal to its own source is an error. A source is identified, in its ownership markers and its templates' contexts, by its path relative to that same root, so nothing recorded in a generated file depends on where dmx was launched. + +Rendered output MUST pass the same whitespace normalization, hygiene, full-file Dart re-parse, and `dart analyze --fatal-infos` corpus gates as other generated Dart. It MUST NOT contain `throw`, casts, null assertions, or other constructs forbidden in generated Dart; that is [hygiene], enforced over the tree-sitter CST rather than over the text. The file MUST carry a dmx ownership marker containing the source path, the binding's identity — the template file for a standalone pair, the group and fence ordinals for a document — the template hash, the typeDiagram definition hash, the context version, and the dmx version. Its first line MUST be the same ownership marker whole-file emission already uses [dartmacros.files], so one predicate decides ownership for every backend that writes a file dmx owns. + +Whole-file emission follows [dartmacros.files]: never overwrite an unmarked file, write atomically, avoid no-op writes, remove stale owned outputs when their template disappears, and report drift without writing under `--check`. Neither the definition nor the template is ever rewritten. + +An output MUST have one live source. A target already carrying another source's ownership marker MUST be refused while that source still exists, because each pass would otherwise undo the other's. A marker naming a source that is gone identifies an orphan, and taking it over is what renaming a document is supposed to do. + +### [typediagram.execution] Build, Check, Watch, and Explain + +`build`, `check`, and `watch` MUST treat the definition, every bound template, and resolved partials as dependencies of every output. A change to prose outside a generation group MUST NOT invalidate its output. A semantic definition or template change MUST invalidate every dependent output. + +A `.mustache` file is never generated *from*, so `watch` MUST answer an edit to one by regenerating the definition it is bound to. A watcher that only ever noticed definitions would go silent on half the edits a template author makes. + +`watch` MUST retain the last valid output after an invalid edit and recover on the next valid save. `dmx explain` MUST print each group, its source spans, normalized output paths, dependency hashes, and exact context JSON without rendering or writing. It MUST accept a `.td` definition, a `.mustache` template bound to one — which explains that template's definition — and a Markdown document. + +Stale collection is scoped to the roots the pass was asked to manage: an output whose ownership marker names this document, which the document no longer produces, MUST be removed (or, under `--check`, reported) when it is inside those roots. + +### [typediagram.diagnostics] Diagnostics + +The feature owns the `DMX8xxx` range: + +| Code | Meaning | +|---|---| +| `DMX8001` | Malformed or incomplete metadata on a dmx-enabled Mustache fence or a template file's leading comment | +| `DMX8002` | dmx-enabled Mustache fence is not bound to a typeDiagram definition group | +| `DMX8003` | Two templates claim the same normalized output path | +| `DMX8004` | typeDiagram definition failed parsing, resolution, or compatibility validation | +| `DMX8005` | Output path is absolute, escapes the workspace, crosses an unsafe symlink, or is not Dart | +| `DMX8006` | Output exists without the matching dmx ownership marker | +| `DMX8007` | typeDiagram compatibility or context schema version is unsupported | +| `DMX8008` | A bound Mustache template does not compile, or its render is not source the target accepts | +| `DMX8009` | A declaration has no JSON codec, so the canonical model template writes no extension for it | +| `DMX8010` | A union case can be called neither by its own name nor by its qualified one | + +Every diagnostic MUST carry the definition's path, and the fenced-block span where the definition is a fence. When applicable it also carries the typeDiagram line/column, template line/column, generated Dart line/column, and output path. A position inside a `.td` file is a position in that file: the author's editor and the diagnostic MUST agree on the line. + +Rendered source that does not parse, or that breaks [hygiene], is refused by the shared diagnostics those stages already own — `DMX4001` and `DMX4003` — wrapped in a `DMX8008` that names the definition and the template that produced it. A macro name this registry serves from a Markdown group MUST NOT be reachable as an annotation: `@dmx('typeDiagram')` is `DMX2006`. diff --git a/examples/storefront/README.md b/examples/storefront/README.md index d5c0b02..2036906 100644 --- a/examples/storefront/README.md +++ b/examples/storefront/README.md @@ -33,20 +33,24 @@ dart test | [inventory.dart](lib/inventory.dart) | `@dmx('diff')` `@dmx('model')` | What changed, as data, for audit trails and unsaved-changes banners. Collections compare by content, so `diff` agrees with `==`. | | [l10n.dart](lib/l10n.dart) | `@dmx('strings')` | A message is a method signature. `{count}` in the template must correspond to a parameter called `count`, checked at generation time rather than by a customer. | -## One model, defined in Markdown +## One model, defined by a diagram -[docs/shipping.dmx.md](docs/shipping.dmx.md) has no annotated Dart behind it at -all. The types are declared once in a typeDiagram fence, and the two Mustache -fences under it generate [lib/shipping.dart](lib/shipping.dart) — records, -a sealed union, and a typedef — and +[models/shipping.td](models/shipping.td) has no annotated Dart behind it at all. +The types are declared once. With no template beside it, the definition renders +through the canonical model template dmx ships, producing +[lib/shipping.dart](lib/shipping.dart) — immutable records that compare by +value, a sealed union whose cases are values too, a typedef, and a JSON +extension beside each class rather than inside it. The one template that *is* +beside it, [shipping.wire.mustache](models/shipping.wire.mustache), generates [lib/shipping_wire.dart](lib/shipping_wire.dart), a constant wire-name table. -Both are functions of the same definition, so a field added to the diagram -changes both files together. The definition still renders as a diagram, so that -one page is the model, its documentation, and the build input. +Both files are functions of the same definition, so a field added to the +definition changes both together. The `.td` file is pure typeDiagram, so it +still renders as a diagram anywhere typeDiagram is supported. +[models/README.md](models/README.md) walks through it. [test/shipping_test.dart](test/shipping_test.dart) constructs the generated -types, switches over the union without a default arm, and checks that the two -generated files agree. +types, compares them by value, round-trips them through JSON, switches over the +union without a default arm, and checks that the two generated files agree. ## Reading order diff --git a/examples/storefront/docs/shipping.dmx.md b/examples/storefront/docs/shipping.dmx.md deleted file mode 100644 index 8ab4cb7..0000000 --- a/examples/storefront/docs/shipping.dmx.md +++ /dev/null @@ -1,132 +0,0 @@ -# Shipping - -Everything under the diagram is generated from it. There is no Dart source of -truth for these types and no `@dmx` annotation anywhere — the definition *is* -the source, the templates decide the shape, and `dmx build docs lib` writes the -files. The fence renders as a diagram in any typeDiagram viewer, so this page is -documentation and a build input at the same time. - -## What the two templates do - -The first template turns every declaration into immutable Dart: records become -`final class`es with a `const` constructor, the union becomes a sealed class -with one subclass per variant, and the alias becomes a `typedef`. It places -prepared values and computes nothing — `dartType`, `constructorParameters` and -`owner` are all finished before it runs. - -The second reads the same definition and writes something completely different: -the snake-case wire names each declaration uses, as a constant table a -serializer can index. One definition, two outputs, no copying. - -## The definition and its templates - -A template binds to the definition immediately above it, so the fences below are -consecutive: a heading between them would end the group and orphan the template. -That is the whole binding rule — no ordinals, no headings, no document-global -state. - -```typeDiagram -# A parcel on its way to a customer. -type Parcel { - id: Uuid - weightG: Int - insured: Option - labels: List -} - -# Where the parcel has got to. One of these, never two. -union Leg { - Pickup { at: DateTime } - Transit { carrier: String, etaHours: Int } - Delivered { at: DateTime, signedBy: Option } -} - -alias TrackingNumber = String - -type Shipment { - parcel: Parcel - legs: List - tracking: TrackingNumber -} -``` - -```mustache {"dmx":{"output":"lib/shipping.dart"}} -// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. -{{#declarations}} -{{#isAlias}} - -/// `{{name}}` as the diagram declares it. -typedef {{name}}{{{genericDeclaration}}} = {{{dartType}}}; -{{/isAlias}} -{{#isRecord}} - -/// {{label}}, generated from the shipping diagram. -final class {{name}}{{{genericDeclaration}}} { - /// Every field of {{label}}, in the order the diagram declares them. - const {{name}}({{{constructorParameters}}}); -{{#fields}} - - /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/isRecord}} -{{#isUnion}} - -/// {{label}} — exactly one of the variants below. -sealed class {{name}}{{{genericDeclaration}}} { - /// The shared constructor every variant delegates to. - const {{name}}(); -} -{{#variants}} - -/// The `{{name}}` case of {{owner}}. -final class {{name}} extends {{owner}}{{{ownerGenericDeclaration}}} { - /// Every field of this case, in diagram order. - const {{name}}({{{constructorParameters}}}) : super(); -{{#fields}} - - /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/variants}} -{{/isUnion}} -{{/declarations}} -``` - -```mustache {"dmx":{"output":"lib/shipping_wire.dart"}} -// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. - -/// The wire name of every field, keyed by declaration and then by Dart name. -const shippingWireNames = >{ -{{#declarations}} -{{#isRecord}} - '{{name}}': { -{{#fields}} - '{{name}}': '{{snakeName}}', -{{/fields}} - }, -{{/isRecord}} -{{#isUnion}} -{{#variants}} - '{{owner}}.{{name}}': { -{{#fields}} - '{{name}}': '{{snakeName}}', -{{/fields}} - }, -{{/variants}} -{{/isUnion}} -{{/declarations}} -}; - -/// Every declaration the shipping diagram carries, in source order. -const shippingDeclarations = [ -{{#declarations}} - '{{name}}', -{{/declarations}} -]; -``` - -Delete either fence and its file goes with it. Change a field and both files -move together, because both are functions of the same definition. diff --git a/examples/storefront/lib/shipping.dart b/examples/storefront/lib/shipping.dart index 61669be..161d23a 100644 --- a/examples/storefront/lib/shipping.dart +++ b/examples/storefront/lib/shipping.dart @@ -1,11 +1,13 @@ -// dmx: generated from docs/shipping.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition bd16c86d530f3daa, template f826dd2e54c4c785, context v1, dmx 0.0.0. +// dmx: generated from models/shipping.td — do not edit. +// dmx: rendered through the canonical model template, definition bd16c86d530f3daa, template 5fba7c04728545cb, context v1, dmx 0.0.0. -// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. +// Generated from models/shipping.td. Edit the definition, not this file. -/// Parcel, generated from the shipping diagram. +import 'package:dmx/dmx.dart' as dmx; + +/// Parcel — an immutable value from the diagram. final class Parcel { - /// Every field of Parcel, in the order the diagram declares them. + /// Every field, in the order the diagram declares them. const Parcel({required this.id, required this.weightG, this.insured, required this.labels}); /// The `id` field, declared as `Uuid`. @@ -19,26 +21,191 @@ final class Parcel { /// The `labels` field, declared as `List`. final List labels; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Parcel && + other.id == id && + other.weightG == weightG && + other.insured == insured && + dmx.dmxDeepEquals(other.labels, labels)); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + weightG, + insured, + dmx.dmxDeepHash(labels), + ); + + @override + String toString() => 'Parcel(id: $id, weightG: $weightG, insured: $insured, labels: $labels)'; + + /// A copy of this value with the named fields replaced. + Parcel copyWith({ + String? id, + int? weightG, + dmx.DmxPatch insured = const dmx.DmxKeep(), + List? labels, + }) => + Parcel( + id: id ?? this.id, + weightG: weightG ?? this.weightG, + insured: switch (insured) { dmx.DmxKeep() => this.insured, dmx.DmxTo(value: final value) => value }, + labels: labels ?? this.labels, + ); } -/// Leg — exactly one of the variants below. +/// JSON for [Parcel]. +extension ParcelJson on Parcel { + /// Decodes a `Parcel` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Parcel']) => + switch (json) { + { + 'id': final String id, + 'weightG': final int weightG, + 'labels': final List labels, + } => + switch (( + dmx.dmxNullable(dmx.dmxKey(json, 'insured'), '$path.insured', (value, path) => switch (value) { + final String value => dmx.Ok(value), + _ => dmx.Err(dmx.DecodeError(path, 'String', value)), + }), + dmx.dmxList(labels, '$path.labels', (value, path) => switch (value) { + final String value => dmx.Ok(value), + _ => dmx.Err(dmx.DecodeError(path, 'String', value)), + }), + )) { + ( + dmx.Ok(value: final insured), + dmx.Ok(value: final labels), + ) => + dmx.Ok(Parcel( + id: id, + weightG: weightG, + insured: insured, + labels: labels, + )), + (dmx.Err(error: final e), _) => dmx.Err(e), + (_, dmx.Err(error: final e)) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Parcel', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'id': id, + 'weightG': weightG, + 'insured': insured, + 'labels': labels, + }; +} + +/// Leg — exactly one of the cases below. sealed class Leg { - /// The shared constructor every variant delegates to. + /// The shared constructor every case delegates to. const Leg(); } -/// The `Pickup` case of Leg. +/// JSON for [Leg]. +extension LegJson on Leg { + /// Decodes whichever case the payload's 'type' names. + static dmx.Result fromJson(Object? json, [String path = 'Leg']) => + switch (json) { + { + 'type': final String type, + } => + switch (type) { + 'pickup' => PickupJson.fromJson(json, path), + 'transit' => TransitJson.fromJson(json, path), + 'delivered' => DeliveredJson.fromJson(json, path), + _ => dmx.Err(dmx.DecodeError(path, 'Leg', json)), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Leg', json)), + }; + + /// This value as a JSON map, tagged with the case it is. + Map toJson() => switch (this) { + final Pickup value => { + 'type': 'pickup', + ...value.toJson(), + }, + final Transit value => { + 'type': 'transit', + ...value.toJson(), + }, + final Delivered value => { + 'type': 'delivered', + ...value.toJson(), + }, + }; +} + +/// The `Pickup` case of Leg, as an immutable value. final class Pickup extends Leg { - /// Every field of this case, in diagram order. + /// Every field, in the order the diagram declares them. const Pickup({required this.at}) : super(); /// The `at` field, declared as `DateTime`. final DateTime at; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Pickup && + other.at == at); + + @override + int get hashCode => Object.hash( + runtimeType, + at, + ); + + @override + String toString() => 'Pickup(at: $at)'; + + /// A copy of this value with the named fields replaced. + Pickup copyWith({ + DateTime? at, + }) => + Pickup( + at: at ?? this.at, + ); } -/// The `Transit` case of Leg. +/// JSON for [Pickup]. +extension PickupJson on Pickup { + /// Decodes a `Pickup` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Pickup']) => + switch (json) { + { + 'at': final String at, + } => + switch (( + switch (DateTime.tryParse(at)) { final DateTime parsed => dmx.Ok(parsed), null => dmx.Err(dmx.DecodeError('$path.at', 'DateTime', at)) }, + )) { + ( + dmx.Ok(value: final at), + ) => + dmx.Ok(Pickup( + at: at, + )), + (dmx.Err(error: final e),) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Pickup', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'at': at.toIso8601String(), + }; +} + +/// The `Transit` case of Leg, as an immutable value. final class Transit extends Leg { - /// Every field of this case, in diagram order. + /// Every field, in the order the diagram declares them. const Transit({required this.carrier, required this.etaHours}) : super(); /// The `carrier` field, declared as `String`. @@ -46,11 +213,61 @@ final class Transit extends Leg { /// The `etaHours` field, declared as `Int`. final int etaHours; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Transit && + other.carrier == carrier && + other.etaHours == etaHours); + + @override + int get hashCode => Object.hash( + runtimeType, + carrier, + etaHours, + ); + + @override + String toString() => 'Transit(carrier: $carrier, etaHours: $etaHours)'; + + /// A copy of this value with the named fields replaced. + Transit copyWith({ + String? carrier, + int? etaHours, + }) => + Transit( + carrier: carrier ?? this.carrier, + etaHours: etaHours ?? this.etaHours, + ); +} + +/// JSON for [Transit]. +extension TransitJson on Transit { + /// Decodes a `Transit` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Transit']) => + switch (json) { + { + 'carrier': final String carrier, + 'etaHours': final int etaHours, + } => + dmx.Ok(Transit( + carrier: carrier, + etaHours: etaHours, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Transit', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'carrier': carrier, + 'etaHours': etaHours, + }; } -/// The `Delivered` case of Leg. +/// The `Delivered` case of Leg, as an immutable value. final class Delivered extends Leg { - /// Every field of this case, in diagram order. + /// Every field, in the order the diagram declares them. const Delivered({required this.at, this.signedBy}) : super(); /// The `at` field, declared as `DateTime`. @@ -58,14 +275,77 @@ final class Delivered extends Leg { /// The `signedBy` field, declared as `Option`. final String? signedBy; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Delivered && + other.at == at && + other.signedBy == signedBy); + + @override + int get hashCode => Object.hash( + runtimeType, + at, + signedBy, + ); + + @override + String toString() => 'Delivered(at: $at, signedBy: $signedBy)'; + + /// A copy of this value with the named fields replaced. + Delivered copyWith({ + DateTime? at, + dmx.DmxPatch signedBy = const dmx.DmxKeep(), + }) => + Delivered( + at: at ?? this.at, + signedBy: switch (signedBy) { dmx.DmxKeep() => this.signedBy, dmx.DmxTo(value: final value) => value }, + ); +} + +/// JSON for [Delivered]. +extension DeliveredJson on Delivered { + /// Decodes a `Delivered` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Delivered']) => + switch (json) { + { + 'at': final String at, + } => + switch (( + switch (DateTime.tryParse(at)) { final DateTime parsed => dmx.Ok(parsed), null => dmx.Err(dmx.DecodeError('$path.at', 'DateTime', at)) }, + dmx.dmxNullable(dmx.dmxKey(json, 'signedBy'), '$path.signedBy', (value, path) => switch (value) { + final String value => dmx.Ok(value), + _ => dmx.Err(dmx.DecodeError(path, 'String', value)), + }), + )) { + ( + dmx.Ok(value: final at), + dmx.Ok(value: final signedBy), + ) => + dmx.Ok(Delivered( + at: at, + signedBy: signedBy, + )), + (dmx.Err(error: final e), _) => dmx.Err(e), + (_, dmx.Err(error: final e)) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Delivered', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'at': at.toIso8601String(), + 'signedBy': signedBy, + }; } /// `TrackingNumber` as the diagram declares it. typedef TrackingNumber = String; -/// Shipment, generated from the shipping diagram. +/// Shipment — an immutable value from the diagram. final class Shipment { - /// Every field of Shipment, in the order the diagram declares them. + /// Every field, in the order the diagram declares them. const Shipment({required this.parcel, required this.legs, required this.tracking}); /// The `parcel` field, declared as `Parcel`. @@ -76,4 +356,72 @@ final class Shipment { /// The `tracking` field, declared as `TrackingNumber`. final TrackingNumber tracking; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Shipment && + other.parcel == parcel && + dmx.dmxDeepEquals(other.legs, legs) && + other.tracking == tracking); + + @override + int get hashCode => Object.hash( + runtimeType, + parcel, + dmx.dmxDeepHash(legs), + tracking, + ); + + @override + String toString() => 'Shipment(parcel: $parcel, legs: $legs, tracking: $tracking)'; + + /// A copy of this value with the named fields replaced. + Shipment copyWith({ + Parcel? parcel, + List? legs, + TrackingNumber? tracking, + }) => + Shipment( + parcel: parcel ?? this.parcel, + legs: legs ?? this.legs, + tracking: tracking ?? this.tracking, + ); +} + +/// JSON for [Shipment]. +extension ShipmentJson on Shipment { + /// Decodes a `Shipment` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Shipment']) => + switch (json) { + { + 'parcel': final Object? parcel, + 'legs': final List legs, + 'tracking': final String tracking, + } => + switch (( + ParcelJson.fromJson(parcel, '$path.parcel'), + dmx.dmxList(legs, '$path.legs', LegJson.fromJson), + )) { + ( + dmx.Ok(value: final parcel), + dmx.Ok(value: final legs), + ) => + dmx.Ok(Shipment( + parcel: parcel, + legs: legs, + tracking: tracking, + )), + (dmx.Err(error: final e), _) => dmx.Err(e), + (_, dmx.Err(error: final e)) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Shipment', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'parcel': parcel.toJson(), + 'legs': legs.map((e0) => e0.toJson()).toList(), + 'tracking': tracking, + }; } diff --git a/examples/storefront/lib/shipping_wire.dart b/examples/storefront/lib/shipping_wire.dart index 0063ad6..bd97d35 100644 --- a/examples/storefront/lib/shipping_wire.dart +++ b/examples/storefront/lib/shipping_wire.dart @@ -1,7 +1,7 @@ -// dmx: generated from docs/shipping.dmx.md — do not edit. -// dmx: group 1, fences 1/3, definition bd16c86d530f3daa, template 6737510090829881, context v1, dmx 0.0.0. +// dmx: generated from models/shipping.td — do not edit. +// dmx: rendered through models/shipping.wire.mustache, definition bd16c86d530f3daa, template 40b2301c361563df, context v1, dmx 0.0.0. -// Generated from docs/shipping.dmx.md. Edit the diagram, not this file. +// Generated from models/shipping.td. Edit the definition, not this file. /// The wire name of every field, keyed by declaration and then by Dart name. const shippingWireNames = >{ diff --git a/examples/storefront/models/README.md b/examples/storefront/models/README.md new file mode 100644 index 0000000..c1a17c1 --- /dev/null +++ b/examples/storefront/models/README.md @@ -0,0 +1,48 @@ +# Shipping + +Two files and no wrapper. + +| File | What it is | +| --- | --- | +| `shipping.td` | the definition — pure typeDiagram, byte for byte what any typeDiagram tool reads | +| `shipping.wire.mustache` | a second template over the same definition — pure Mustache | + +`dmx build models lib` writes [`../lib/shipping.dart`](../lib/shipping.dart) +and [`../lib/shipping_wire.dart`](../lib/shipping_wire.dart). There is no Dart +source of truth for these types, no `@dmx` annotation anywhere, and nothing to +extract from anything: the definition *is* the source. + +## Where the first file comes from + +`shipping.td` has no template beside it, so it renders through the **canonical +model template** dmx ships. That is the one template every model class in the +product comes out of: a `final class` with a `const` constructor, its fields, +`==`, `hashCode`, `toString` and `copyWith` — and JSON on a `ShipmentJson` +extension rather than on the class, so the class stays exactly what the diagram +said it was. The union becomes a sealed class with one case per variant, each +case an immutable value in its own right, and the alias becomes a `typedef`. + +To reshape that output, write `shipping.mustache` beside `shipping.td` and it +takes the canonical template's place. + +## What the second file does + +`shipping.wire.mustache` reads the same definition and writes something +completely different: the snake-case wire names each declaration uses, as a +constant table a serializer can index. One definition, two outputs, no copying. + +## How the files are bound + +By their names, and by nothing else. `shipping.wire.mustache` renders the +`shipping.td` beside it into `lib/shipping_wire.dart`. Add a third template and +you have a third file; delete one and its file goes with it. + +A template that wants a different destination says so in a leading Mustache +comment, which every engine renders to nothing: + +```mustache +{{! dmx output=lib/models/shipping.dart }} +``` + +Change a field in `shipping.td` and both outputs move together, because both +are functions of the same definition. diff --git a/examples/storefront/models/shipping.td b/examples/storefront/models/shipping.td new file mode 100644 index 0000000..9637688 --- /dev/null +++ b/examples/storefront/models/shipping.td @@ -0,0 +1,22 @@ +# A parcel on its way to a customer. +type Parcel { + id: Uuid + weightG: Int + insured: Option + labels: List +} + +# Where the parcel has got to. One of these, never two. +union Leg { + Pickup { at: DateTime } + Transit { carrier: String, etaHours: Int } + Delivered { at: DateTime, signedBy: Option } +} + +alias TrackingNumber = String + +type Shipment { + parcel: Parcel + legs: List + tracking: TrackingNumber +} diff --git a/examples/storefront/models/shipping.wire.mustache b/examples/storefront/models/shipping.wire.mustache new file mode 100644 index 0000000..e98ca8d --- /dev/null +++ b/examples/storefront/models/shipping.wire.mustache @@ -0,0 +1,30 @@ +// Generated from models/shipping.td. Edit the definition, not this file. + +/// The wire name of every field, keyed by declaration and then by Dart name. +const shippingWireNames = >{ +{{#declarations}} +{{#isRecord}} + '{{name}}': { +{{#fields}} + '{{name}}': '{{snakeName}}', +{{/fields}} + }, +{{/isRecord}} +{{#isUnion}} +{{#variants}} + '{{owner}}.{{name}}': { +{{#fields}} + '{{name}}': '{{snakeName}}', +{{/fields}} + }, +{{/variants}} +{{/isUnion}} +{{/declarations}} +}; + +/// Every declaration the shipping diagram carries, in source order. +const shippingDeclarations = [ +{{#declarations}} + '{{name}}', +{{/declarations}} +]; diff --git a/examples/storefront/test/shipping_test.dart b/examples/storefront/test/shipping_test.dart index 261dde1..748dbe2 100644 --- a/examples/storefront/test/shipping_test.dart +++ b/examples/storefront/test/shipping_test.dart @@ -1,15 +1,21 @@ -// Proves the two files generated from docs/shipping.dmx.md [typediagram]. +// Proves the two files generated from models/shipping.td [typediagram]. // // Nothing here is generated. The point of the suite is that a definition -// written once in Markdown, with no Dart source of truth and no `@dmx` +// written once as a typeDiagram file, with no Dart source of truth and no `@dmx` // annotation anywhere, produces Dart you can actually construct, match on, and // index — and that both outputs agree, because both are functions of the same // definition. +import 'package:dmx/dmx.dart'; import 'package:dmx_storefront_example/shipping.dart'; import 'package:dmx_storefront_example/shipping_wire.dart'; import 'package:test/test.dart'; +/// A parcel built at run time, so two of them are separate objects — which is +/// what makes an equality test about equality rather than about identity. +Parcel parcelNamed(String id) => + Parcel(id: id, weightG: 1200, labels: ['fragile', 'up']); + /// A leg description that proves the switch is exhaustive: no default arm, no /// cast, no null assertion — the sealed class is what makes that possible. String describe(Leg leg) => switch (leg) { @@ -92,6 +98,90 @@ void main() { }); }); + group('value semantics', () { + test('two values built from the same fields are equal and hash alike', () { + final one = parcelNamed('b0a1'); + final two = parcelNamed('b0a1'); + + expect(identical(one, two), isFalse); + expect(one, two, reason: 'a diagram declares values, not identities'); + expect(one.hashCode, two.hashCode); + expect({one, two}, hasLength(1)); + expect({one, parcelNamed('b0a2')}, hasLength(2)); + }); + + test('a list field compares by content, not by reference', () { + const one = Parcel(id: 'b0a1', weightG: 1, labels: ['a']); + const two = Parcel(id: 'b0a1', weightG: 1, labels: ['b']); + + expect(one, isNot(two)); + }); + + test('copyWith replaces what it is given and keeps the rest', () { + const parcel = Parcel( + id: 'b0a1', weightG: 1200, insured: '19.99', labels: []); + + expect(parcel.copyWith(weightG: 30).weightG, 30); + expect(parcel.copyWith(weightG: 30).id, 'b0a1'); + expect(parcel.copyWith().insured, '19.99', + reason: 'omitting a nullable field keeps it'); + expect(parcel.copyWith(insured: const DmxTo(null)).insured, isNull, + reason: 'clearing one is a different call from omitting it'); + }); + + test('toString names the class and every field that carries a value', () { + const parcel = Parcel(id: 'b0a1', weightG: 12, labels: []); + + expect(parcel.toString(), + 'Parcel(id: b0a1, weightG: 12, insured: null, labels: [])'); + }); + }); + + group('json, which lives beside the class rather than in it', () { + test('a record round-trips through its extension', () { + const parcel = Parcel( + id: 'b0a1', weightG: 1200, labels: ['fragile', 'up']); + + final json = parcel.toJson(); + expect(json, { + 'id': 'b0a1', + 'weightG': 1200, + 'insured': null, + 'labels': ['fragile', 'up'], + }); + expect(ParcelJson.fromJson(json), Ok(parcel)); + }); + + test('a nested record and a list of union cases decode too', () { + final shipment = Shipment( + parcel: const Parcel(id: 'c3', weightG: 10, labels: []), + legs: [Pickup(at: DateTime.utc(2026, 8, 19, 9))], + tracking: 'NF-0001', + ); + + expect(ShipmentJson.fromJson(shipment.toJson()), + Ok(shipment)); + }); + + test('a union tags itself on the way out and reads the tag back', () { + final leg = Transit(carrier: 'Nimble Freight', etaHours: 30); + + expect(leg.toJson(), + {'carrier': 'Nimble Freight', 'etaHours': 30}); + expect(LegJson.fromJson({ + 'type': 'transit', + 'carrier': 'Nimble Freight', + 'etaHours': 30, + }), Ok(leg)); + }); + + test('a bad payload is an error value, never an exception', () { + final decoded = ParcelJson.fromJson({'id': 7}); + + expect(decoded, isA>()); + }); + }); + group('the wire-name table', () { test('it carries every record and every variant', () { expect( diff --git a/src/dmx/src/engine.rs b/src/dmx/src/engine.rs index 3c6f8c8..2f55a5d 100644 --- a/src/dmx/src/engine.rs +++ b/src/dmx/src/engine.rs @@ -19,7 +19,7 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt as _}; use tokio_util::sync::CancellationToken; -use crate::watch::collect_sources; +use crate::sources::collect_sources; use crate::{Options, Outcome, process_path}; /// Generation events a slow subscriber may fall behind by before it starts diff --git a/src/dmx/src/lib.rs b/src/dmx/src/lib.rs index 4bf724e..0335151 100644 --- a/src/dmx/src/lib.rs +++ b/src/dmx/src/lib.rs @@ -28,6 +28,8 @@ pub mod hygiene; pub mod jsoncontent; pub mod macros; pub mod render; +#[cfg(not(target_arch = "wasm32"))] +pub mod sources; pub mod typediagram; pub mod types; #[cfg(not(target_arch = "wasm32"))] @@ -244,9 +246,10 @@ fn process_source_inner( /// Runs the pipeline over one source, whatever kind it is /// [typediagram.execution]. /// -/// A Dart file is generated into; a Markdown document generates whole files -/// from its typeDiagram groups. `roots` is the scope this pass was asked to -/// manage, which is where a document's stale outputs are collected from. +/// A Dart file is generated into; a Markdown document and a `.td` definition +/// file both generate whole files from their typeDiagram groups. `roots` is +/// the scope this pass was asked to manage, which is where stale outputs are +/// collected from. /// /// # Errors /// @@ -262,6 +265,20 @@ pub fn process_path(path: &Path, roots: &[std::path::PathBuf], opts: &Options) - if typediagram::is_markdown(path) { return typediagram::document::process(path, roots, opts); } + if typediagram::is_definition(path) { + return typediagram::standalone::process(path, roots, opts); + } + if typediagram::is_template(path) { + // A template is not a source of its own: nothing is generated *from* + // it, and what changed is what the definition beside it generates. A + // template with no definition beside it is somebody else's Mustache + // file — the catalogue's previews are exactly that — and dmx leaves it + // alone [typediagram.standalone]. + return match typediagram::definition_of(path) { + Some(definition) => typediagram::standalone::process(&definition, roots, opts), + None => Ok(Outcome::Unchanged), + }; + } process_file(path, opts) } diff --git a/src/dmx/src/macros/diff.rs b/src/dmx/src/macros/diff.rs index 65c666f..44bad17 100644 --- a/src/dmx/src/macros/diff.rs +++ b/src/dmx/src/macros/diff.rs @@ -62,7 +62,13 @@ fn build(decl: &RawDecl) -> Result { let fields = macros::typed_fields(decl)? .iter() .map(|field| FieldCtx { - differs: model::comparison(&field.ty, &other, field.name(), false), + differs: model::comparison( + &field.ty, + &other, + field.name(), + false, + crate::types::Runtime::IN_CLASS, + ), key: model::json_key(field, policy.as_deref()), name: field.name().to_owned(), }) diff --git a/src/dmx/src/macros/mod.rs b/src/dmx/src/macros/mod.rs index 830b307..f4f2037 100644 --- a/src/dmx/src/macros/mod.rs +++ b/src/dmx/src/macros/mod.rs @@ -25,7 +25,7 @@ mod diff; mod enums; mod fake; mod lerp; -mod model; +pub(crate) mod model; mod rest; mod route; mod table; @@ -386,8 +386,8 @@ pub fn query_string(ty: &DartType, name: &str) -> String { /// expression instead — which is the kind of thing a template must never be /// asked to know [context.discipline]. #[must_use] -pub fn error_patterns(arity: usize) -> Vec { - slot_patterns(arity, "Err(error: final e)") +pub fn error_patterns(arity: usize, runtime: crate::types::Runtime) -> Vec { + slot_patterns(arity, &format!("{}(error: final e)", runtime.name("Err"))) } /// The record patterns that put `marker` in each slot of `arity` in turn, and diff --git a/src/dmx/src/macros/model.rs b/src/dmx/src/macros/model.rs index 20693c3..c2fe4ea 100644 --- a/src/dmx/src/macros/model.rs +++ b/src/dmx/src/macros/model.rs @@ -13,7 +13,7 @@ use crate::casing; use crate::frontend::{Annotated, DeclKind, RawDecl}; use crate::macros::{self, Field, union}; use crate::render; -use crate::types::{self, DartType}; +use crate::types::{self, DartType, Runtime}; /// The template this macro renders [rendering]. const TEMPLATE: &str = include_str!("../../templates/model.mustache"); @@ -124,7 +124,7 @@ pub fn build(decl: &RawDecl, file: &[RawDecl]) -> Result { // The record pattern that selects each failing field, binding the error // payload it carries: `(_, Err(error: final e), _)`. let arity = fields.iter().filter(|f| f.isComplex).count(); - let mut patterns = macros::error_patterns(arity).into_iter(); + let mut patterns = macros::error_patterns(arity, Runtime::IN_CLASS).into_iter(); for field in fields.iter_mut().filter(|f| f.isComplex) { field.errPattern = patterns.next().unwrap_or_default(); } @@ -175,11 +175,46 @@ pub fn json_key(field: &Field<'_>, policy: Option<&str>) -> String { } } -/// Everything the template names about one field. -fn field_context(field: &Field<'_>, other: &str, policy: Option<&str>) -> Result { - let (name, ty) = (field.name(), &field.ty); +/// One field's JSON codec, both directions [model.json-codec]. +/// +/// Separate from the rest of [`FieldCtx`] because the two halves fail +/// differently: a field always compares, hashes, and copies, and only *some* +/// fields have a codec at all — a `void` member has none, and neither does a +/// map keyed by anything but a string. Whole-file generation +/// [typediagram.canonical] needs the halves apart so a declaration it cannot +/// encode still gets its value semantics. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Codec { + /// Local name the pattern binds this field to. + pub bind: String, + /// What the constructor receives: the binding, or a pure transform of it. + pub ctor_expr: String, + /// The JSON key this field is read from and written to. + pub json_key: String, + /// Dart type the map pattern binds this field at — its *JSON* shape. + pub pattern_type: String, + /// Required fields are destructured by the map pattern; nullable fields are + /// read with `dmxKey` so that an absent key decodes as null. + pub in_pattern: bool, + /// Contributes a `Result` to the record that sequences the decode. + pub is_complex: bool, + /// The `Result` this entry contributes. + pub result_expr: String, + /// This entry on the way out. + pub encode_expr: String, +} + +/// One field's codec, in whichever direction the generated code reaches a +/// declared type's decoder from [model.json-codec]. +/// +/// `key` is the JSON key as a Dart string literal, quotes included. +/// +/// # Errors +/// +/// Fails when the type has no codec — the same refusal [`types::decode_bound`] +/// makes, reported against the field that asked for it. +pub fn codec(name: &str, ty: &DartType, key: String, runtime: Runtime) -> Result { let bind = macros::binding_name(name); - let key = json_key(field, policy); // Interpolated, not baked: a nested failure reports the path it was reached // by — `Order.lines[2].product` — rather than the type it happened in. The // path names the *wire* key, because that is what the payload in front of @@ -194,40 +229,59 @@ fn field_context(field: &Field<'_>, other: &str, policy: Option<&str>) -> Result } else if ty.nullable { let inner = ty.non_null(); format!( - "dmxNullable<{}>(dmxKey(json, {key}), {path}, {})", + "{}<{}>({}(json, {key}), {path}, {})", + runtime.name("dmxNullable"), inner.source, - types::decoder(&inner, 12)? + runtime.name("dmxKey"), + types::decoder(&inner, 12, runtime)? ) } else { - types::decode_bound(ty, &bind, &path, 12)? + types::decode_bound(ty, &bind, &path, 12, runtime)? }; - Ok(FieldCtx { - patternType: if ty.nullable { + Ok(Codec { + pattern_type: if ty.nullable { String::new() } else { types::json_shape(ty) }, - inPattern: !ty.nullable, - isComplex: !direct, - resultExpr: result_expr, - errPattern: String::new(), // arity is only known once all fields are in - encodeExpr: types::encode(ty, name, 0), - equalsExpr: comparison(ty, other, name, true), - hashExpr: hash_component(ty, name), - copyParam: copy_param(ty, name), - copyArg: copy_arg(ty, name), - toStringExpr: format!("{name}: ${name}"), + in_pattern: !ty.nullable, + is_complex: !direct, + result_expr, + encode_expr: types::encode(ty, name, 0), // Direct fields carry their transform into the constructor call; // everything else arrives already decoded, bound by the record pattern. - ctorExpr: if direct { + ctor_expr: if direct { types::pure_transform(ty, &bind).unwrap_or_else(|| bind.clone()) } else { bind.clone() }, - jsonKey: key, - name: name.to_owned(), + json_key: key, bind, + }) +} + +/// Everything the template names about one field. +fn field_context(field: &Field<'_>, other: &str, policy: Option<&str>) -> Result { + let (name, ty) = (field.name(), &field.ty); + let codec = self::codec(name, ty, json_key(field, policy), Runtime::IN_CLASS)?; + + Ok(FieldCtx { + patternType: codec.pattern_type, + inPattern: codec.in_pattern, + isComplex: codec.is_complex, + resultExpr: codec.result_expr, + errPattern: String::new(), // arity is only known once all fields are in + encodeExpr: codec.encode_expr, + equalsExpr: comparison(ty, other, name, true, Runtime::IN_CLASS), + hashExpr: hash_component(ty, name, Runtime::IN_CLASS), + copyParam: copy_param(ty, name, Runtime::IN_CLASS), + copyArg: copy_arg(ty, name, Runtime::IN_CLASS), + toStringExpr: format!("{name}: ${name}"), + ctorExpr: codec.ctor_expr, + jsonKey: codec.json_key, + name: name.to_owned(), + bind: codec.bind, isLast: false, }) } @@ -236,19 +290,20 @@ fn field_context(field: &Field<'_>, other: &str, policy: Option<&str>) -> Result /// /// `equal` picks the sense. `@dmx('diff')` asks for the negation rather than forming /// its own opinion, so "changed" and "unequal" can never drift apart. -pub fn comparison(ty: &DartType, other: &str, name: &str, equal: bool) -> String { +pub fn comparison(ty: &DartType, other: &str, name: &str, equal: bool, runtime: Runtime) -> String { + let deep = runtime.name("dmxDeepEquals"); match (ty.is_collection(), equal) { - (true, true) => format!("dmxDeepEquals({other}.{name}, {name})"), - (true, false) => format!("!dmxDeepEquals({other}.{name}, {name})"), + (true, true) => format!("{deep}({other}.{name}, {name})"), + (true, false) => format!("!{deep}({other}.{name}, {name})"), (false, true) => format!("{other}.{name} == {name}"), (false, false) => format!("{other}.{name} != {name}"), } } /// [model.equality]: a hash consistent with [`comparison`]. -pub fn hash_component(ty: &DartType, name: &str) -> String { +pub fn hash_component(ty: &DartType, name: &str, runtime: Runtime) -> String { if ty.is_collection() { - format!("dmxDeepHash({name})") + format!("{}({name})", runtime.name("dmxDeepHash")) } else { name.to_owned() } @@ -256,20 +311,29 @@ pub fn hash_component(ty: &DartType, name: &str) -> String { /// [model.copywith]: a nullable field takes a patch, so omitting it and /// clearing it are different calls; everything else takes `T?` and `??`. -fn copy_param(ty: &DartType, name: &str) -> String { +#[must_use] +pub fn copy_param(ty: &DartType, name: &str, runtime: Runtime) -> String { if ty.nullable { - format!("DmxPatch<{}> {name} = const DmxKeep()", ty.source) + format!( + "{}<{}> {name} = const {}()", + runtime.name("DmxPatch"), + ty.source, + runtime.name("DmxKeep") + ) } else { format!("{}? {name}", ty.source) } } /// [model.copywith]: what `copyWith` passes on for one field. -fn copy_arg(ty: &DartType, name: &str) -> String { +#[must_use] +pub fn copy_arg(ty: &DartType, name: &str, runtime: Runtime) -> String { if ty.nullable { format!( "{name}: switch ({name}) {{ \ - DmxKeep() => this.{name}, DmxTo(value: final value) => value }}" + {}() => this.{name}, {}(value: final value) => value }}", + runtime.name("DmxKeep"), + runtime.name("DmxTo") ) } else { format!("{name}: {name} ?? this.{name}") diff --git a/src/dmx/src/macros/rest.rs b/src/dmx/src/macros/rest.rs index be635c2..4cbaac1 100644 --- a/src/dmx/src/macros/rest.rs +++ b/src/dmx/src/macros/rest.rs @@ -310,7 +310,13 @@ fn decode(ty: &DartType, method: &str) -> Result { Ok(format!( "switch (response.body) {{ final {shape} body => {bound}, _ => Err<{source}, DecodeError>(DecodeError('{method}', '{source}', response.body)) }}", shape = types::json_shape(ty), - bound = types::decode_bound(ty, "body", &format!("'{method}'"), 12)?, + bound = types::decode_bound( + ty, + "body", + &format!("'{method}'"), + 12, + types::Runtime::IN_CLASS + )?, source = ty.source, )) } diff --git a/src/dmx/src/macros/table.rs b/src/dmx/src/macros/table.rs index 5f4c560..bf95da7 100644 --- a/src/dmx/src/macros/table.rs +++ b/src/dmx/src/macros/table.rs @@ -152,7 +152,7 @@ fn build(decl: &RawDecl) -> Result { } let arity = columns.iter().filter(|c| c.isComplex).count(); - let mut patterns = macros::error_patterns(arity).into_iter(); + let mut patterns = macros::error_patterns(arity, crate::types::Runtime::IN_CLASS).into_iter(); for column in columns.iter_mut().filter(|c| c.isComplex) { column.errPattern = patterns.next().unwrap_or_default(); } diff --git a/src/dmx/src/macros/typediagram.rs b/src/dmx/src/macros/typediagram.rs index debf750..218bc46 100644 --- a/src/dmx/src/macros/typediagram.rs +++ b/src/dmx/src/macros/typediagram.rs @@ -38,24 +38,21 @@ pub fn expand(invocation: &Invocation<'_>) -> Result> { .templates .iter() .map(|template| { - let target = target::find(&template.target) - .with_context(|| where_it_is(invocation, template.fence.line))?; + let located = || invocation.group.located(invocation.document, template); + let target = target::find(&template.target).with_context(located)?; invocation .model .validate_for_target(target.name) .map_err(|found| { anyhow::anyhow!( - "DMX8004 [typediagram.model]: the typeDiagram definition in {} (fence {}, \ - line {}) uses types the `{}` target cannot generate:\n{}", - invocation.document, - invocation.group.definition.ordinal, - invocation.group.definition.line, + "DMX8004 [typediagram.model]: the typeDiagram definition in {} uses types \ + the `{}` target cannot generate:\n{}", + invocation.group.definition_at(invocation.document), target.name, found.in_document(invocation.group.definition.line) ) })?; - require_target_extension(&template.output, target) - .with_context(|| where_it_is(invocation, template.fence.line))?; + require_target_extension(&template.output, target).with_context(located)?; let model = context::build( invocation.document, @@ -64,13 +61,13 @@ pub fn expand(invocation: &Invocation<'_>) -> Result> { invocation.model, target, ) - .with_context(|| where_it_is(invocation, template.fence.line))?; + .with_context(located)?; let body = render::render_json(&template.fence.body, &model).with_context(|| { format!( "DMX8008 [typediagram.templates]: the Mustache template generating `{}` does \ not compile ({})", template.output, - where_it_is(invocation, template.fence.line) + located() ) })?; @@ -81,7 +78,7 @@ pub fn expand(invocation: &Invocation<'_>) -> Result> { source the `{}` target refuses ({})", template.output, target.name, - where_it_is(invocation, template.fence.line) + located() ) })?; Ok(GeneratedFile { @@ -92,14 +89,6 @@ pub fn expand(invocation: &Invocation<'_>) -> Result> { .collect() } -/// Where in the document a failure happened, in the terms the author reads. -fn where_it_is(invocation: &Invocation<'_>, line: usize) -> String { - format!( - "in {} group {}, definition fence on line {}, template fence on line {line}", - invocation.document, invocation.group.ordinal, invocation.group.definition.line - ) -} - /// Refuses an output the named target does not generate [typediagram.output]. fn require_target_extension(output: &str, target: &target::Target) -> Result<()> { if std::path::Path::new(output) @@ -184,84 +173,78 @@ mod tests { assert!(files[1].text.contains("fences 1/3"), "{}", files[1].text); } + /// The refusal `run` produced, which it must have produced, proved to say + /// every one of `needles`. + fn refused(definition: &str, metadata: &str, template: &str, why: &str, needles: &[&str]) { + let error = format!("{:#}", run(definition, metadata, template).expect_err(why)); + for needle in needles { + assert!(error.contains(needle), "{why}: {error}"); + } + } + /// [typediagram.output]: a template whose render is not valid Dart, or is /// Dart that generated code may not contain, fails before any write. #[test] fn invalid_or_unhygienic_output_is_refused() { - let error = format!( - "{:#}", - run( - "type A { x: Int }", - OUT, - "final class {{#declarations}}{{name}}{{/declarations}} {" - ) - .expect_err("unbalanced Dart") + refused( + "type A { x: Int }", + OUT, + "final class {{#declarations}}{{name}}{{/declarations}} {", + "unbalanced Dart", + &["DMX4001", "template fence on line 5"], ); - assert!(error.contains("DMX4001"), "{error}"); - assert!(error.contains("template fence on line 5"), "{error}"); - - let error = format!( - "{:#}", - run( - "type A { x: Int }", - OUT, - "int probe(Object? v) => throw StateError('{{#declarations}}{{name}}{{/declarations}}');", - ) - .expect_err("throwing Dart") + refused( + "type A { x: Int }", + OUT, + "int probe(Object? v) => throw StateError('{{#declarations}}{{name}}{{/declarations}}');", + "throwing Dart", + &["DMX4003", "never throws"], ); - assert!(error.contains("DMX4003"), "{error}"); - assert!(error.contains("never throws"), "{error}"); } /// [typediagram.model]: a type the target cannot render fails before the /// template runs, naming the document line. #[test] fn an_unrenderable_type_fails_before_rendering() { - let error = format!( - "{:#}", - run("type A { at: Timestamp }", OUT, "// {{name}}").expect_err("unknown type") + refused( + "type A { at: Timestamp }", + OUT, + "// {{name}}", + "unknown type", + &["DMX8004", "unknown type 'Timestamp'"], ); - assert!(error.contains("DMX8004"), "{error}"); - assert!(error.contains("unknown type 'Timestamp'"), "{error}"); } /// [typediagram.output]: a target only generates its own kind of file, and /// only targets dmx knows may be named. #[test] fn targets_and_extensions_are_checked() { - let error = format!( - "{:#}", - run( - "type A { x: Int }", - "{\"dmx\":{\"output\":\"lib/a.txt\"}}", - "// x" - ) - .expect_err("not a Dart file") + refused( + "type A { x: Int }", + "{\"dmx\":{\"output\":\"lib/a.txt\"}}", + "// x", + "not a Dart file", + &["DMX8005", "does not end in `.dart`"], ); - assert!(error.contains("DMX8005"), "{error}"); - assert!(error.contains("does not end in `.dart`"), "{error}"); - - let error = format!( - "{:#}", - run( - "type A { x: Int }", - "{\"dmx\":{\"output\":\"lib/a.dart\",\"target\":\"kotlin\"}}", - "// x" - ) - .expect_err("no such target") + refused( + "type A { x: Int }", + "{\"dmx\":{\"output\":\"lib/a.dart\",\"target\":\"kotlin\"}}", + "// x", + "no such target", + &["DMX8007"], ); - assert!(error.contains("DMX8007"), "{error}"); } /// [typediagram.templates]: a template that does not compile names the /// document, the group, and its own fence. #[test] fn a_broken_template_names_where_it_is() { - let error = format!( - "{:#}", - run("type A { x: Int }", OUT, "{{> nowhere}}").expect_err("unresolvable partial") + refused( + "type A { x: Int }", + OUT, + "{{> nowhere}}", + "unresolvable partial", + &["DMX8008", "docs/a.dmx.md group 1"], ); - assert!(error.contains("DMX8008"), "{error}"); - assert!(error.contains("docs/a.dmx.md group 1"), "{error}"); } } diff --git a/src/dmx/src/main.rs b/src/dmx/src/main.rs index 9bc37b3..79a1940 100644 --- a/src/dmx/src/main.rs +++ b/src/dmx/src/main.rs @@ -4,7 +4,7 @@ use anyhow::{Result, bail}; use std::path::PathBuf; use std::process::ExitCode; -use dmx::{Options, Outcome, process_path, typediagram, watch}; +use dmx::{Options, Outcome, process_path, sources, typediagram, watch}; /// What `dmx` prints when it cannot tell what was asked of it. const USAGE: &str = "usage:\n dmx build [PATHS...] [--insert-regions] [--check]\n \ @@ -85,25 +85,34 @@ fn run() -> Result { } /// Prints the generation groups, dependencies, and exact context of one -/// Markdown document [typediagram.execution]. +/// typeDiagram source [typediagram.execution]. +/// +/// A definition file, a template beside one, or a Markdown document: three +/// spellings of the same question, so all three answer it. fn explain(paths: &[PathBuf]) -> Result { let [path] = paths else { bail!("[cli] `dmx explain` takes exactly one file\n{USAGE}"); }; - if !typediagram::is_markdown(path) { - bail!( - "[cli] `dmx explain` currently explains Markdown documents; {} is not one", - path.display() - ); - } - print!("{}", typediagram::document::explain(path)?); + let report = match path { + _ if typediagram::is_markdown(path) => typediagram::document::explain(path)?, + _ if typediagram::is_definition(path) => typediagram::standalone::explain(path)?, + _ => match typediagram::definition_of(path) { + Some(definition) => typediagram::standalone::explain(&definition)?, + None => bail!( + "[cli] `dmx explain` explains a typeDiagram definition (`.td`), a template bound \ + to one, or a Markdown document; {} is none of those", + path.display() + ), + }, + }; + print!("{report}"); Ok(ExitCode::SUCCESS) } /// One generation pass, reporting what it wrote and exiting non-zero under /// `--check` when anything was out of date [execution]. fn build(paths: &[PathBuf], opts: Options) -> Result { - let files = watch::collect_sources(paths)?; + let files = sources::collect_sources(paths)?; let mut updated = 0usize; for file in &files { if let Outcome::Updated = process_path(file, paths, &opts)? { diff --git a/src/dmx/src/sources.rs b/src/dmx/src/sources.rs new file mode 100644 index 0000000..0c90d1a --- /dev/null +++ b/src/dmx/src/sources.rs @@ -0,0 +1,258 @@ +//! What dmx generates from, and where it looks for it +//! [surface.zero-config], [typediagram.standalone], [typediagram.documents]. +//! +//! One place decides three questions that have to agree: which files a +//! recursive sweep discovers, which files a watch answers an event about, and +//! which files could be an output a pass no longer produces. They are not the +//! same set — a `.mustache` template is watched but never discovered, and a +//! Markdown file is discovered only when it is named — and a copy of any one +//! of them that drifted would show up as a generator that had quietly stopped +//! working. + +use anyhow::{Context as _, Result, bail}; +use notify::RecursiveMode; +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; + +#[derive(Clone, Debug, Eq, PartialEq)] +/// What one watch argument turned out to be. +pub(crate) enum Scope { + /// A directory, watched recursively. + Directory(PathBuf), + /// One Dart source, watched through its parent directory. + File(PathBuf), +} + +impl Scope { + /// Resolves one command-line path, refusing what cannot be watched. + pub(crate) fn from_path(path: &Path) -> Result { + let absolute = path + .canonicalize() + .with_context(|| format!("DMX1002 [cli]: cannot watch {}", path.display()))?; + match (absolute.is_dir(), absolute.is_file()) { + (true, false) => Ok(Self::Directory(absolute)), + (false, true) if Sweep::Sources.wants_named(&absolute) => Ok(Self::File(absolute)), + (false, true) => bail!( + "DMX1002 [cli]: watch target is not a Dart source or a Markdown document: {}", + path.display() + ), + _ => bail!( + "DMX1002 [cli]: watch target is not a file or directory: {}", + path.display() + ), + } + } + + /// Whether this scope's tree contains `path`, by name alone. + /// + /// Nothing here touches the filesystem: it answers where a path sits, and + /// the callers below add what it has to BE. + pub(crate) fn contains(&self, path: &Path) -> bool { + match self { + Self::File(file) => path == file, + Self::Directory(directory) => path + .strip_prefix(directory) + .is_ok_and(|relative| relative.components().all(visible_component)), + } + } + + /// Whether an event about this path is one this scope wants. + pub(crate) fn accepts(&self, path: &Path) -> bool { + let named = matches!(self, Self::File(file) if file == path); + // Recursive discovery takes `*.dmx.md`; a Markdown file named directly + // is watched whatever it is called [typediagram.documents]. + let wanted = if named { + Sweep::Sources.wants_named(path) + } else { + Sweep::Sources.watches(path) + }; + !path.is_symlink() && path.is_file() && wanted && self.contains(path) + } + + /// Whether `path` is a directory inside this scope's tree. + /// + /// A directory that appears inside a watched tree can already hold sources + /// whose own creation events never arrive. A recursive watch on Linux is + /// one inotify registration per directory, added when the directory is + /// seen, so anything written into a new directory before that registration + /// lands is never announced. macOS reports a whole tree from a single + /// registration and never shows this, which is why it has to be handled + /// here rather than left to whichever platform notices first + /// [execution.modes]. + pub(crate) fn covers_directory(&self, path: &Path) -> bool { + matches!(self, Self::Directory(_)) + && !path.is_symlink() + && path.is_dir() + && self.contains(path) + } + + /// The canonical path this scope covers, which is what the engine rescans. + pub(crate) fn root(&self) -> PathBuf { + match self { + Self::Directory(path) | Self::File(path) => path.clone(), + } + } + + /// The path to register with the watcher, and how deeply. + pub(crate) fn registration(&self) -> Result<(PathBuf, RecursiveMode)> { + match self { + Self::Directory(path) => Ok((path.clone(), RecursiveMode::Recursive)), + Self::File(path) => path + .parent() + .map(|parent| (parent.to_owned(), RecursiveMode::NonRecursive)) + .ok_or_else(|| { + anyhow::anyhow!("DMX1002 [cli]: {} has no parent directory", path.display()) + }), + } + } +} + +/// What one sweep of the tree is looking for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Sweep { + /// Everything dmx generates from: Dart files and Markdown documents. + Sources, + /// Anything carrying an extension some generation target writes — the + /// candidates a generated output could be hiding among when a pass + /// collects what it no longer produces [typediagram.output]. + Outputs, +} + +impl Sweep { + /// Whether a file *recursive discovery* found is one this sweep wants. + pub(crate) fn wants(self, path: &Path) -> bool { + match self { + Self::Sources => { + is_dart_source(path) + || crate::typediagram::is_document(path) + || crate::typediagram::is_definition(path) + } + Self::Outputs => crate::typediagram::target::extensions() + .any(|extension| has_extension(path, extension)), + } + } + + /// Whether an event about `path` is one a watch answers. + /// + /// Wider than what a sweep discovers, by exactly one kind of file: a + /// `.mustache` template is never a source in its own right — nothing is + /// generated *from* it — but editing one changes what the definition + /// beside it generates, so a watch that ignored it would go quiet on half + /// the edits a template author makes [typediagram.standalone]. + pub(crate) fn watches(self, path: &Path) -> bool { + self.wants(path) || (self == Self::Sources && crate::typediagram::is_template(path)) + } + + /// Whether a file *named directly* is one this sweep wants. + /// + /// Wider again, by one more kind: recursive discovery takes `*.dmx.md` and + /// nothing else, and naming a Markdown file is how any other one is + /// generated from [typediagram.documents]. + pub(crate) fn wants_named(self, path: &Path) -> bool { + self.watches(path) || (self == Self::Sources && crate::typediagram::is_markdown(path)) + } +} + +/// Every source dmx generates from at or under `paths` — Dart files and +/// Markdown documents alike [surface.zero-config], [typediagram.documents]. +/// +/// # Errors +/// +/// Fails when a directory cannot be read. +pub fn collect_sources(paths: &[PathBuf]) -> Result> { + collect(paths, Sweep::Sources) +} + +/// Every file at or under `paths` that some generation target could have +/// written [typediagram.output]. +/// +/// # Errors +/// +/// Fails when a directory cannot be read. +pub fn collect_outputs(paths: &[PathBuf]) -> Result> { + collect(paths, Sweep::Outputs) +} + +/// Every file `sweep` accepts at or under `paths`, deduplicated and ordered. +fn collect(paths: &[PathBuf], sweep: Sweep) -> Result> { + paths + .iter() + .map(|path| collect_path(path, sweep, Sweep::wants_named)) + .collect::>>() + .map(|groups| { + groups + .into_iter() + .flatten() + .collect::>() + .into_iter() + .collect() + }) +} + +/// Every source at or under one path, with `accept` deciding what a *file* +/// there has to be — which differs between a path somebody named and one +/// discovery walked into. +pub(crate) fn collect_path( + path: &Path, + sweep: Sweep, + accept: fn(Sweep, &Path) -> bool, +) -> Result> { + match (path.is_symlink(), path.is_dir(), path.is_file()) { + (false, true, _) => collect_directory(path, sweep), + (false, false, true) if accept(sweep, path) => Ok(vec![path.to_owned()]), + // A symlink is never followed [surface.zero-config], and anything that + // is not a source is not dmx's to read. + _ => Ok(Vec::new()), + } +} + +/// Every source under one directory, hidden entries excluded. +fn collect_directory(directory: &Path, sweep: Sweep) -> Result> { + std::fs::read_dir(directory) + .with_context(|| { + format!( + "DMX1002 [surface.zero-config]: cannot read {}", + directory.display() + ) + })? + .filter_map(|entry| match entry { + Ok(entry) if visible_name(&entry.file_name()) => { + Some(collect_path(&entry.path(), sweep, Sweep::wants)) + } + Ok(_) => None, + Err(error) => Some(Err(anyhow::Error::from(error).context(format!( + "DMX1002 [surface.zero-config]: cannot inspect {}", + directory.display() + )))), + }) + .collect::>>() + .map(|groups| groups.into_iter().flatten().collect()) +} + +/// A Dart source dmx owns — not a `.g.dart` somebody else generates. +fn is_dart_source(path: &Path) -> bool { + has_extension(path, "dart") + && path + .file_name() + .is_some_and(|name| !name.to_string_lossy().ends_with(".g.dart")) +} + +/// Whether `path` carries `extension`, however it is cased. +fn has_extension(path: &Path, extension: &str) -> bool { + path.extension() + .is_some_and(|found| found.eq_ignore_ascii_case(extension)) +} + +/// Whether a directory entry is one the zero-config rules look at. +fn visible_name(name: &OsStr) -> bool { + !name.to_string_lossy().starts_with('.') +} + +/// The same rule, applied to one component of a relative path. +fn visible_component(component: Component<'_>) -> bool { + match component { + Component::Normal(name) => visible_name(name), + _ => true, + } +} diff --git a/src/dmx/src/typediagram/binding.rs b/src/dmx/src/typediagram/binding.rs new file mode 100644 index 0000000..3272b8b --- /dev/null +++ b/src/dmx/src/typediagram/binding.rs @@ -0,0 +1,386 @@ +//! What a typeDiagram definition bound to a Mustache template *is* +//! [typediagram.binding]. +//! +//! A binding is two pieces of text and a destination: the definition, the +//! template, and the path the render lands on. Nothing in this module knows +//! whether those pieces were written as fences inside one Markdown document +//! [typediagram.documents] or as two files beside each other +//! [typediagram.standalone] — that is the whole point of it existing. The two +//! front ends build the same [`Group`], so there is one context builder, one +//! macro, one validator, one ownership protocol, and one set of diagnostics. +//! +//! [`Origin`] is the only thing a group remembers about where it came from, +//! and it is remembered for exactly one reason: a human reading a diagnostic +//! needs to be told where to look, and "fence 2 on line 10" is the wrong +//! sentence for a file. + +use anyhow::Result; +use serde_json::{Map, Value}; + +/// The default generation target when a template does not name one. +pub const DEFAULT_TARGET: &str = "dart"; + +/// Every key a `dmx` metadata object may carry. +const DMX_KEYS: &[&str] = &["output", "target"]; + +/// How a group's definition and templates were written down +/// [typediagram.binding]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Origin { + /// Fences inside one Markdown document [typediagram.documents]. + Document, + /// A `.td` definition file and the `.mustache` files beside it + /// [typediagram.standalone]. + Files, +} + +/// One block of source text dmx bound [typediagram.binding]. +/// +/// It is a fenced code block in a Markdown document and a whole file in a +/// standalone pair; `line` is what tells the two apart, because it is the +/// offset a position inside `body` is rebased by. A fence's body starts on the +/// line after its opening marker, so the marker's line is that offset. A +/// file's body starts on line one, so its offset is zero. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fence { + /// Its one-based position among the document's fenced blocks, or `1` for a + /// file, which is the only block it has. + pub ordinal: usize, + /// The offset that turns a position inside `body` into one in the file the + /// author is editing. + pub line: usize, + /// Its content, exactly as it was read. + pub body: String, +} + +/// How the canonical model template is named wherever a template is named +/// [typediagram.canonical]. +pub const CANONICAL: &str = "the canonical model template"; + +/// Where a bound template's text came from [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Source { + /// A Mustache fence inside a Markdown document, which has no file of its + /// own [typediagram.documents]. + Fence, + /// A `.mustache` file beside the definition, named relative to the output + /// root [typediagram.standalone]. + File(String), + /// The canonical model template dmx ships, which a definition renders + /// through when nothing beside it says otherwise [typediagram.canonical]. + Canonical, +} + +impl Source { + /// How this source is named in the context and on an output's marker line, + /// or `None` for a fence, which is named by its position instead. + #[must_use] + pub fn label(&self) -> Option<&str> { + match self { + Self::Fence => None, + Self::File(path) => Some(path), + Self::Canonical => Some(CANONICAL), + } + } +} + +/// A template bound to the definition it renders [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoundTemplate { + /// The template text. + pub fence: Fence, + /// The workspace-relative output path, as the author wrote it or as the + /// convention derived it. + pub output: String, + /// The generation target, defaulting to [`DEFAULT_TARGET`]. + pub target: String, + /// Where the template text came from. + pub source: Source, +} + +impl BoundTemplate { + /// How a diagnostic names this template. + #[must_use] + pub fn located(&self, document: &str) -> String { + match &self.source { + Source::File(path) => format!("the template {path}"), + Source::Canonical => CANONICAL.to_owned(), + Source::Fence => format!( + "the Mustache template in {document} on line {}", + self.fence.line + ), + } + } + + /// How `dmx explain` heads this template [typediagram.execution]. + #[must_use] + pub fn heading(&self) -> String { + match &self.source { + Source::File(path) => format!("template {path}"), + Source::Canonical => CANONICAL.to_owned(), + Source::Fence => format!("fence {} on line {}", self.fence.ordinal, self.fence.line), + } + } +} + +/// One definition and every template bound to it [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Group { + /// How it was written down. + pub origin: Origin, + /// Its one-based position among the document's generation groups, or `1` + /// for a definition file, which is the only group it has. + pub ordinal: usize, + /// The typeDiagram definition. + pub definition: Fence, + /// The templates it generates through, in binding order. + pub templates: Vec, +} + +impl Group { + /// How a diagnostic names this group's definition + /// [typediagram.diagnostics]. + #[must_use] + pub fn definition_at(&self, document: &str) -> String { + match self.origin { + Origin::Document => format!( + "{document} (fence {}, line {})", + self.definition.ordinal, self.definition.line + ), + Origin::Files => document.to_owned(), + } + } + + /// How a diagnostic names one whole binding — where the definition is and + /// which template was rendering when it failed + /// [typediagram.diagnostics]. + #[must_use] + pub fn located(&self, document: &str, template: &BoundTemplate) -> String { + match (self.origin, template.source.label()) { + (Origin::Files, Some(name)) => format!("in {document}, rendered through {name}"), + _ => format!( + "in {document} group {}, definition fence on line {}, template fence on line {}", + self.ordinal, self.definition.line, template.fence.line + ), + } + } + + /// How `dmx explain` heads this group [typediagram.execution]. + #[must_use] + pub fn heading(&self) -> String { + match self.origin { + Origin::Document => format!( + "typeDiagram fence {} on line {}", + self.definition.ordinal, self.definition.line + ), + Origin::Files => "the definition file".to_owned(), + } + } + + /// The second marker line's origin-specific half [typediagram.output]. + /// + /// A document's outputs are identified by the group and the fences that + /// produced them, because that is what a reader of the document can point + /// at. A standalone pair's outputs name the template file instead: the + /// definition is already on the line above, and the template is the other + /// half of what a reader has to open to change the result. + #[must_use] + pub fn identity(&self, template: &BoundTemplate) -> String { + match (self.origin, template.source.label()) { + (Origin::Files, Some(name)) => format!("rendered through {name}"), + _ => format!( + "group {}, fences {}/{}", + self.ordinal, self.definition.ordinal, template.fence.ordinal + ), + } + } +} + +/// Where a template's dmx metadata was written [typediagram.binding]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Metadata<'a> { + /// How a diagnostic names the place the metadata was written. + pub located: String, + /// The spelling a reader should copy when theirs is refused. + pub example: &'a str, +} + +/// The settings a `dmx` metadata object carried, with nothing filled in. +#[derive(Clone, Debug, Eq, PartialEq)] +struct Declared { + /// The output path it named, if it named one. + output: Option, + /// The target it named, or [`DEFAULT_TARGET`]. + target: String, +} + +impl Default for Declared { + fn default() -> Self { + Self { + output: None, + target: DEFAULT_TARGET.to_owned(), + } + } +} + +/// The binding a Mustache fence inside a document declares, or `None` when it +/// declares none [typediagram.binding]. +/// +/// A fence names its own output or it is not a binding at all: a document has +/// no convention to fall back on, because a fence has no file name to derive +/// one from. +/// +/// # Errors +/// +/// Fails (`DMX8001`) when the metadata is not a JSON object, when `dmx` is not +/// an object, when an unrecognised key appears, or when `output` is missing or +/// empty. +pub fn in_document(meta: &str, fence: Fence, at: &Metadata<'_>) -> Result> { + let Some(dmx) = dmx_object(meta, at)? else { + return Ok(None); + }; + let declared = settings(&dmx, at)?; + let Some(output) = declared.output else { + return Err(fault(at, "`dmx.output` must be a non-empty output path")); + }; + Ok(Some(BoundTemplate { + fence, + output, + target: declared.target, + source: Source::Fence, + })) +} + +/// The binding a standalone template file declares, filled in from the +/// convention wherever it declared nothing [typediagram.standalone]. +/// +/// `declared` is the `key=value` text the template's leading comment carried, +/// or `""` when it carried none. `name` is the base name the output takes when +/// the template names none; the directory it lands in and the extension it +/// carries belong to the target, which is the only thing that knows where a +/// language keeps its sources. +/// +/// # Errors +/// +/// Fails (`DMX8001`) for the same metadata faults a fence fails on, and +/// (`DMX8007`) when the named target does not exist. +pub fn in_file( + declared: &str, + fence: Fence, + source: String, + name: &str, + at: &Metadata<'_>, +) -> Result { + let declared = settings(&pairs(declared, at)?, at)?; + let target = super::target::find(&declared.target)?; + Ok(BoundTemplate { + fence, + output: declared + .output + .unwrap_or_else(|| format!("{}/{name}.{}", target.source_root, target.extension)), + target: declared.target, + source: Source::File(source), + }) +} + +/// The `dmx` object `meta` carries, or `None` when it carries none. +/// +/// Metadata that does not open with `{` belongs to somebody else's convention +/// and is left alone. Metadata that does is dmx's to read: a JSON object +/// without a `dmx` key is an ordinary example, and anything else is a mistake +/// worth reporting rather than silently generating nothing +/// [typediagram.binding]. +fn dmx_object(meta: &str, at: &Metadata<'_>) -> Result>> { + if !meta.starts_with('{') { + return Ok(None); + } + let Ok(Value::Object(metadata)) = serde_json::from_str::(meta) else { + return Err(fault(at, "it is not a JSON object")); + }; + match metadata.get("dmx") { + None => Ok(None), + Some(Value::Object(dmx)) => Ok(Some(dmx.clone())), + Some(_) => Err(fault(at, "`dmx` is not an object")), + } +} + +/// The settings a standalone template's leading comment declared, as the +/// object [`settings`] reads [typediagram.standalone]. +/// +/// `key=value`, separated by spaces, because a Mustache comment cannot contain +/// a `}` — the engine reads the first one as the start of the closing braces — +/// and therefore cannot contain the JSON object a fence's info string carries. +/// The keys mean the same thing either way, and so does every refusal, because +/// what reads them is the same function. +fn pairs(text: &str, at: &Metadata<'_>) -> Result> { + text.split_whitespace() + .map(|token| match token.split_once('=') { + Some((key, value)) if !key.is_empty() && !value.is_empty() => { + Ok((key.to_owned(), Value::String(value.to_owned()))) + } + _ => Err(fault( + at, + &format!("`{token}` is not a `key=value` setting"), + )), + }) + .collect() +} + +/// The settings one `dmx` object declared, whichever front end read it. +fn settings(dmx: &Map, at: &Metadata<'_>) -> Result { + if let Some(unknown) = dmx.keys().find(|key| !DMX_KEYS.contains(&key.as_str())) { + return Err(fault( + at, + &format!("`dmx.{unknown}` is not a setting dmx knows"), + )); + } + let output = match dmx.get("output") { + None => None, + Some(Value::String(output)) if !output.trim().is_empty() => Some(output.trim().to_owned()), + Some(_) => return Err(fault(at, "`dmx.output` must be a non-empty output path")), + }; + let target = match dmx.get("target") { + None => DEFAULT_TARGET.to_owned(), + Some(Value::String(target)) if !target.trim().is_empty() => target.trim().to_owned(), + Some(_) => return Err(fault(at, "`dmx.target` must be a target name")), + }; + Ok(Declared { output, target }) +} + +/// One unusable-metadata refusal, naming the place and the spelling that works. +fn fault(at: &Metadata<'_>, detail: &str) -> anyhow::Error { + anyhow::anyhow!( + "DMX8001 [typediagram.binding]: {} has unusable dmx metadata: {detail}\n\n {}", + at.located, + at.example + ) +} + +/// Refuses two templates that would write the same file [typediagram.binding]. +/// +/// # Errors +/// +/// Fails (`DMX8003`) when two bindings claim one output path. +pub fn refuse_duplicate_outputs(document: &str, groups: &[Group]) -> Result<()> { + let mut seen: Vec<(&str, String)> = Vec::new(); + for group in groups { + for template in &group.templates { + let here = template.located(document); + match seen.iter().find(|(path, _)| *path == template.output) { + Some((path, first)) => { + return Err(anyhow::anyhow!( + "DMX8003 [typediagram.binding]: {first} and {here} both generate \ + `{path}`; one output has one template" + )); + } + None => seen.push((&template.output, here)), + } + } + } + Ok(()) +} + +// A separate file only because binding.rs is near the 500-line ceiling. +#[cfg(test)] +#[path = "binding_tests.rs"] +mod tests; diff --git a/src/dmx/src/typediagram/binding_tests.rs b/src/dmx/src/typediagram/binding_tests.rs new file mode 100644 index 0000000..9cd6216 --- /dev/null +++ b/src/dmx/src/typediagram/binding_tests.rs @@ -0,0 +1,238 @@ +//! What a binding is, in the terms each front end writes it +//! [typediagram.binding]. +//! +//! Both halves are asserted together on purpose: the whole reason `binding` is +//! its own module is that a definition bound inside a document and a +//! definition bound to the file beside it have to reach the pipeline as the +//! same thing, and differ only in the sentence a human is shown. + +use super::{ + Fence, Group, Metadata, Origin, Source, in_document, in_file, refuse_duplicate_outputs, +}; + +/// A whole-file fence, the way a standalone pair builds one. +fn file(body: &str) -> Fence { + Fence { + ordinal: 1, + line: 0, + body: body.to_owned(), + } +} + +/// The metadata rules a standalone template is read under. +fn beside() -> Metadata<'static> { + Metadata { + located: "the template models/a.mustache".to_owned(), + example: "{{! dmx output=lib/a.dart }}", + } +} + +/// The metadata rules a fence inside a document is read under. +fn fenced() -> Metadata<'static> { + Metadata { + located: "the Mustache fence on line 7".to_owned(), + example: "```mustache {\"dmx\": {\"output\": \"lib/models.dart\"}}", + } +} + +/// One standalone template, bound the way a definition file binds it. +fn bound(meta: &str) -> super::BoundTemplate { + in_file( + meta, + file("x"), + "models/a.mustache".to_owned(), + "a", + &beside(), + ) + .expect(meta) +} + +/// [typediagram.standalone]: a template file with no metadata at all is +/// bound anyway — the convention answers both questions it could ask, and +/// the target answers where its language keeps sources and what they are +/// called. +#[test] +fn a_standalone_template_needs_no_metadata() { + let template = bound(""); + assert_eq!(template.output, "lib/a.dart"); + assert_eq!(template.target, "dart"); + assert_eq!(template.source.label(), Some("models/a.mustache")); +} + +/// [typediagram.standalone]: metadata overrides the convention, one key at +/// a time, and the keys are exactly the document's keys. +#[test] +fn standalone_metadata_overrides_the_convention() { + assert_eq!( + bound("output=lib/models/a.dart").output, + "lib/models/a.dart" + ); + let targeted = bound("target=dart"); + assert_eq!(targeted.output, "lib/a.dart"); + assert_eq!(targeted.target, "dart"); +} + +/// [typediagram.standalone]: a target nothing generates is refused where +/// it was named, not later and not silently. +#[test] +fn an_unknown_target_is_refused() { + let error = format!( + "{:#}", + in_file( + "target=kotlin", + file("x"), + "models/a.mustache".to_owned(), + "a", + &beside(), + ) + .expect_err("unknown target") + ); + assert!(error.contains("kotlin"), "{error}"); +} + +/// [typediagram.binding]: inside a document a template that declares no +/// output declared no binding, and is left alone as an example. +#[test] +fn a_document_template_without_metadata_is_not_bound() { + for meta in ["", "{\"lang\": \"dart\"}"] { + assert!( + in_document(meta, file("x"), &fenced()) + .expect(meta) + .is_none(), + "{meta}" + ); + } + let error = format!( + "{:#}", + in_document("{\"dmx\": {\"target\": \"dart\"}}", file("x"), &fenced()) + .expect_err("no output") + ); + assert!( + error.contains("`dmx.output` must be a non-empty output path"), + "{error}" + ); +} + +/// [typediagram.binding]: a refusal names where the metadata was written, +/// whichever front end wrote it, and offers the spelling that works. +#[test] +fn a_refusal_names_the_place_and_the_fix() { + const BAD: &str = "{\"dmx\": {\"ouput\": \"lib/a.dart\"}}"; + let refusals = [ + format!( + "{:#}", + in_file( + "ouput=lib/a.dart", + file("x"), + "models/a.mustache".to_owned(), + "a", + &beside() + ) + .expect_err("unknown key") + ), + format!( + "{:#}", + in_document(BAD, file("x"), &fenced()).expect_err("unknown key") + ), + ]; + for (error, place, example) in refusals + .iter() + .zip([("models/a.mustache", "{{!"), ("on line 7", "```mustache")]) + .map(|(error, (place, example))| (error, place, example)) + { + assert!(error.contains("DMX8001"), "{error}"); + assert!( + error.contains("`dmx.ouput` is not a setting dmx knows"), + "{error}" + ); + assert!(error.contains(place), "{error}"); + assert!(error.contains(example), "{error}"); + } +} + +/// [typediagram.binding]: two templates cannot claim one output, and the +/// refusal names both of them the way their front end names them. +#[test] +fn one_output_has_one_template() { + let templates = ["models/a.mustache", "models/a.wire.mustache"] + .into_iter() + .map(|source| in_file("", file("x"), source.to_owned(), "a", &beside()).expect(source)) + .collect(); + let group = Group { + origin: Origin::Files, + ordinal: 1, + definition: file("type A { x: Int }"), + templates, + }; + let error = format!( + "{:#}", + refuse_duplicate_outputs("models/a.td", std::slice::from_ref(&group)) + .expect_err("one output, two templates") + ); + assert!(error.contains("DMX8003"), "{error}"); + assert!(error.contains("the template models/a.mustache"), "{error}"); + assert!( + error.contains("the template models/a.wire.mustache"), + "{error}" + ); + assert!(error.contains("`lib/a.dart`"), "{error}"); +} + +/// [typediagram.diagnostics]: a file is located by its name, a fence by +/// its position, and neither borrows the other's sentence. +#[test] +fn each_origin_is_located_in_its_own_terms() { + let template = bound(""); + let files = Group { + origin: Origin::Files, + ordinal: 1, + definition: file("type A { x: Int }"), + templates: vec![template.clone()], + }; + assert_eq!(files.definition_at("models/a.td"), "models/a.td"); + assert_eq!( + files.located("models/a.td", &template), + "in models/a.td, rendered through models/a.mustache" + ); + assert_eq!( + files.identity(&template), + "rendered through models/a.mustache" + ); + assert_eq!(files.heading(), "the definition file"); + assert_eq!(template.heading(), "template models/a.mustache"); + + let fenced = Group { + origin: Origin::Document, + ordinal: 2, + definition: Fence { + ordinal: 3, + line: 12, + body: "type A { x: Int }".to_owned(), + }, + templates: vec![super::BoundTemplate { + fence: Fence { + ordinal: 4, + line: 17, + body: "x".to_owned(), + }, + output: "lib/a.dart".to_owned(), + target: "dart".to_owned(), + source: Source::Fence, + }], + }; + assert_eq!( + fenced.definition_at("docs/a.dmx.md"), + "docs/a.dmx.md (fence 3, line 12)" + ); + assert_eq!( + fenced.located("docs/a.dmx.md", &fenced.templates[0]), + "in docs/a.dmx.md group 2, definition fence on line 12, template fence on line 17" + ); + assert_eq!(fenced.identity(&fenced.templates[0]), "group 2, fences 3/4"); + assert_eq!(fenced.heading(), "typeDiagram fence 3 on line 12"); + assert_eq!(fenced.templates[0].heading(), "fence 4 on line 17"); + assert_eq!( + fenced.templates[0].located("docs/a.dmx.md"), + "the Mustache template in docs/a.dmx.md on line 17" + ); +} diff --git a/src/dmx/src/typediagram/context.rs b/src/dmx/src/typediagram/context.rs index b1bfc74..ffe2977 100644 --- a/src/dmx/src/typediagram/context.rs +++ b/src/dmx/src/typediagram/context.rs @@ -17,11 +17,20 @@ use anyhow::Result; use serde_json::{Map, Value, json}; use super::ast::{Decl, Field, Signature, TypeRef, Variant}; -use super::markdown::{BoundTemplate, Group}; +use super::binding::{BoundTemplate, Group}; use super::model::{Model, Resolution}; +use super::naming::Names; +use super::prepared::{ + constructor_parameters, generic_list, named, parameter, parameter_list, positioned, put, +}; +use super::semantics::{self, Class}; use super::target::Target; use crate::casing; +/// The JSON key a union's payload carries its case's tag under, matching what +/// `@dmx('union')` writes when nobody names another [catalogue.macros]. +const DISCRIMINATOR: &str = "type"; + /// The context schema version. A change to the shape below bumps it, and the /// golden fixtures move in the same commit [typediagram.model]. pub const CONTEXT_VERSION: u64 = 1; @@ -40,15 +49,20 @@ pub fn build( model: &Model, target: &Target, ) -> Result { + let names = Names::of(model, target.name)?; let declarations = model .visible(target.name) - .map(|decl| declaration(decl, model, target)) + .map(|decl| declaration(decl, &names, model, target)) .collect::>>()?; + let declarations = positioned(declarations); Ok(json!({ "modelVersion": CONTEXT_VERSION, "target": target.name, + "runtimeImport": semantics::RUNTIME_IMPORT, + "needsRuntime": declarations.iter().any(needs_runtime), "source": { "path": document, + "template": template.source.label(), "group": group.ordinal, "definitionFence": group.definition.ordinal, "definitionLine": group.definition.line, @@ -56,22 +70,32 @@ pub fn build( "templateLine": template.fence.line, "output": template.output, }, - "declarations": positioned(declarations), + "declarations": declarations, })) } -/// Adds one prepared value to a context object. +/// Whether one declaration renders anything that reaches the dmx runtime +/// [typediagram.canonical]. /// -/// `Map::insert` returns whatever it displaced, which is never anything here -/// and which `unused_results` obliges every caller to discard. Written out, the -/// builders below would be `let _ =` noise wrapped around the one thing that -/// matters — the name and the value. -fn put(out: &mut Map, name: &str, value: impl Into) { - drop(out.insert(name.to_owned(), value.into())); +/// A union answers for its variants: the sealed class itself places nothing, +/// and the classes underneath it place everything. +fn needs_runtime(decl: &Value) -> bool { + let flag = + |value: &Value, name: &str| value.get(name).and_then(Value::as_bool).unwrap_or_default(); + flag(decl, "usesRuntime") + || decl + .get("variants") + .and_then(Value::as_array) + .is_some_and(|variants| variants.iter().any(|variant| flag(variant, "usesRuntime"))) } /// One declaration, with the flags and members its kind carries. -fn declaration(decl: &Decl, model: &Model, target: &Target) -> Result> { +fn declaration( + decl: &Decl, + names: &Names, + model: &Model, + target: &Target, +) -> Result> { let mut out = named(decl.name()); let generics = decl.generics(); put(&mut out, "kind", kind_name(decl)); @@ -94,7 +118,19 @@ fn declaration(decl: &Decl, model: &Model, target: &Target) -> Result { put(&mut out, "hasFields", !record.fields.is_empty()); - members(&mut out, "fields", &record.fields, model, target)?; + let class = Class { + name: record.name.clone(), + ty: format!("{}{}", record.name, generic_list(generics)), + generic: !generics.is_empty(), + fields: &record.fields, + }; + // A record extends nothing and delegates to nobody, which is what + // lets one template block write a record and a union case alike. + put(&mut out, "superClause", ""); + put(&mut out, "superCall", ""); + members(&mut out, "fields", &class, model, target)?; + let view = Value::Object(out.clone()); + classes(&mut out, vec![view]); } Decl::Union(union) => { put(&mut out, "untagged", union.untagged); @@ -106,9 +142,34 @@ fn declaration(decl: &Decl, model: &Model, target: &Target) -> Result>>()?; - put(&mut out, "variants", positioned(variants)); + // A union decodes by reading its cases' tag, so it has a codec + // exactly when every case has one and something in the payload says + // which case it is [typediagram.canonical]. + let decodable = !union.untagged + && generics.is_empty() + && variants.iter().all(|variant| { + variant + .get("hasJson") + .and_then(Value::as_bool) + .unwrap_or_default() + }); + put( + &mut out, + "discriminator", + casing::dart_string(DISCRIMINATOR), + ); + semantics::codec_names( + &mut out, + decodable, + &union.name, + &owner.applied(), + union.variants.is_empty(), + ); + let variants = positioned(variants); + classes(&mut out, variants.clone()); + put(&mut out, "variants", variants); } Decl::Alias(alias) => { let typed = type_ref(&alias.target, model, target)?; @@ -143,10 +204,18 @@ struct Owner<'a> { generic_declaration: String, } +impl Owner<'_> { + /// The union's Dart type, type parameters included. + fn applied(&self) -> String { + format!("{}{}", self.name, self.generic_declaration) + } +} + /// One variant of a union, with its payload shape already decided. fn variant( variant: &Variant, owner: &Owner<'_>, + names: &Names, model: &Model, target: &Target, ) -> Result> { @@ -166,7 +235,27 @@ fn variant( "discriminant", variant.discriminant.clone().unwrap_or_default(), ); - members(&mut out, "fields", &variant.fields, model, target)?; + // The tag the payload carries, spelled the way `@dmx('union')` spells one, + // so a diagram and an annotated sealed class agree on the wire. + put( + &mut out, + "tag", + casing::dart_string(&casing::camel(&variant.name)), + ); + put( + &mut out, + "superClause", + format!(" extends {}", owner.applied()), + ); + put(&mut out, "superCall", " : super()"); + let name = names.case(owner.name, &variant.name); + let class = Class { + ty: format!("{name}{}", owner.generic_declaration), + name, + generic: !owner.generic_declaration.is_empty(), + fields: &variant.fields, + }; + members(&mut out, "fields", &class, model, target)?; Ok(out) } @@ -198,16 +287,27 @@ fn signature( Ok(out) } +/// The classes one declaration writes out [typediagram.canonical]. +/// +/// A record is one class and a union is one per case, and a template that has +/// to know which it is has to say everything twice. `classes` is that list, +/// whichever kind produced it: the record itself, or its cases. +fn classes(out: &mut Map, list: Vec) { + put(out, "hasClasses", !list.is_empty()); + put(out, "classes", list); +} + /// Adds a member list under `name`, together with the constructor fragment it /// adds up to — the two things a record and a variant both need, in one place. fn members( out: &mut Map, name: &str, - source: &[Field], + class: &Class<'_>, model: &Model, target: &Target, ) -> Result<()> { - let members = fields(source, model, target)?; + let mut members = fields(class.fields, model, target)?; + semantics::place(out, &mut members, class, model, target)?; put( out, "constructorParameters", @@ -329,86 +429,6 @@ fn kind_name(decl: &Decl) -> &'static str { } } -/// A name in every casing a template might place it in -/// [context.helpers]. -fn named(name: &str) -> Map { - let mut out = Map::new(); - put(&mut out, "name", name); - put(&mut out, "camelName", casing::camel(name)); - put(&mut out, "pascalName", casing::pascal(name)); - put(&mut out, "snakeName", casing::snake(name)); - put( - &mut out, - "screamingSnakeName", - casing::screaming_snake(name), - ); - put(&mut out, "label", casing::label(name)); - out -} - -/// ``, or the empty string when there are no parameters. -fn generic_list(generics: &[String]) -> String { - if generics.is_empty() { - return String::new(); - } - format!("<{}>", generics.join(", ")) -} - -/// The named-parameter list a constructor takes, braces included, or the empty -/// string when there is nothing to take. -fn constructor_parameters(fields: &[Map]) -> String { - let parts: Vec<&str> = fields - .iter() - .filter_map(|field| field.get("parameter").and_then(Value::as_str)) - .collect(); - if parts.is_empty() { - return String::new(); - } - format!("{{{}}}", parts.join(", ")) -} - -/// One constructor parameter. An optional member has a default of `null` -/// already, so requiring it would only make callers write it. -fn parameter(name: &str, optional: bool) -> String { - if optional { - return format!("this.{name}"); - } - format!("required this.{name}") -} - -/// The positional parameter list a free function takes. -fn parameter_list(params: &[Map]) -> String { - params - .iter() - .filter_map(|param| { - Some(format!( - "{} {}", - param.get("targetType")?.as_str()?, - param.get("name")?.as_str()? - )) - }) - .collect::>() - .join(", ") -} - -/// Stamps `first`, `last`, and `comma` onto every member of a list, so a -/// template lays out separators without counting [context.discipline]. -fn positioned(items: Vec>) -> Vec { - let last = items.len().saturating_sub(1); - items - .into_iter() - .enumerate() - .map(|(index, mut item)| { - let final_item = index == last; - put(&mut item, "first", index == 0); - put(&mut item, "last", final_item); - put(&mut item, "index", index); - put(&mut item, "comma", if final_item { "" } else { "," }); - Value::Object(item) - }) - .collect() -} - // A separate file only because context.rs is at the 500-line ceiling. #[cfg(test)] #[path = "context_tests.rs"] diff --git a/src/dmx/src/typediagram/document.rs b/src/dmx/src/typediagram/document.rs index 6d03f56..73b1957 100644 --- a/src/dmx/src/typediagram/document.rs +++ b/src/dmx/src/typediagram/document.rs @@ -1,149 +1,55 @@ //! One Markdown document through the whole pipeline //! [typediagram.execution]. //! -//! Bind → resolve → invoke the built-in macro → check the paths → emit. The -//! document itself is never rewritten: it is the source of truth, and dmx only -//! ever reads it [typediagram.output]. -//! -//! `explain` walks the same path and stops before emission, printing what the -//! templates will actually see. It is the template author's only tool, so it -//! prints the exact context rather than a summary of it. +//! This is a front end and nothing else: read the file, bind its fences, and +//! hand the groups to [`super::run`], which is the pipeline both front ends +//! share. The document itself is never rewritten — it is the source of truth, +//! and dmx only ever reads it [typediagram.output]. -use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; -use serde_json::json; - -use super::{Invocation, context, emit, markdown, resolve, target}; -use crate::{Options, Outcome, macros}; -/// Everything one document produced, resolved onto real paths. -struct Rendered { - /// Each output's absolute path and complete text. - outputs: Vec<(PathBuf, String)>, -} +use super::binding::Group; +use super::{emit, markdown, run}; +use crate::{Options, Outcome}; /// Generates every group in `path`, writing what changed /// [typediagram.execution]. /// /// `roots` is the scope this pass was asked to manage, and therefore the scope -/// stale outputs are collected from: an output that a removed template used to -/// produce is found by its ownership marker among the files dmx already walks. +/// stale outputs are collected from. /// /// # Errors /// -/// Fails when the document cannot be read, when binding, resolution, rendering, -/// validation, or path safety refuses it, or on I/O. +/// Fails when the document cannot be read, when binding, resolution, +/// rendering, validation, or path safety refuses it, or on I/O. pub fn process(path: &Path, roots: &[PathBuf], opts: &Options) -> Result { - let source = fs::read_to_string(path) - .with_context(|| format!("DMX1002: cannot read {}", path.display()))?; - let workspace = std::env::current_dir().context("DMX1002: cannot resolve the workspace")?; - let root = emit::output_root(&workspace, path); - let document = emit::document_name(&root, path); - let rendered = render(&document, &root, &source)?; - let candidates = crate::watch::collect_outputs(roots)?; - let changed = emit::emit(&document, &root, &rendered.outputs, &candidates, opts.check)?; - Ok(if changed { - Outcome::Updated - } else { - Outcome::Unchanged - }) + let (document, root, groups) = bind(path)?; + run::generate(&document, &root, &groups, roots, opts) } -/// Every output `source` declares, rendered and validated but not written. -fn render(document: &str, root: &Path, source: &str) -> Result { - let groups = markdown::groups(source).with_context(|| format!("in {document}"))?; - let mut outputs = Vec::new(); - for group in &groups { - let model = resolve(document, group)?; - let files = macros::expand_group(&Invocation { - document, - group, - model: &model, - })?; - // The macro renders one file per bound template, in template order, so - // a path fault can name the fence that declared it. - for (template, file) in group.templates.iter().zip(files) { - let located = || { - format!( - "in {document}, the Mustache template on line {}", - template.fence.line - ) - }; - emit::refuse_self_overwrite(document, &file.name).with_context(located)?; - let path = emit::resolve_output(root, &file.name).with_context(located)?; - outputs.push((path, file.text)); - } - } - Ok(Rendered { outputs }) -} - -/// What `dmx explain` prints for a Markdown document -/// [typediagram.execution]. -/// -/// Nothing is rendered and nothing is written: this is the input side of the -/// pipeline, laid out so a template author can see the names they may place -/// before they place them. +/// What `dmx explain` prints for a Markdown document [typediagram.execution]. /// /// # Errors /// /// Fails when the document cannot be read, or when binding or resolution /// refuses it — the same failures generation would report. pub fn explain(path: &Path) -> Result { + let (document, root, groups) = bind(path)?; + run::report(&document, &root, &groups) +} + +/// The document's name, the root its outputs resolve against, and its groups. +fn bind(path: &Path) -> Result<(String, PathBuf, Vec)> { let source = fs::read_to_string(path) .with_context(|| format!("DMX1002: cannot read {}", path.display()))?; let workspace = std::env::current_dir().context("DMX1002: cannot resolve the workspace")?; let root = emit::output_root(&workspace, path); let document = emit::document_name(&root, path); let groups = markdown::groups(&source).with_context(|| format!("in {document}"))?; - let mut out = format!( - "{document}: {} generation group(s), outputs under {}\n", - groups.len(), - root.display() - ); - for group in &groups { - let model = resolve(&document, group)?; - writeln!( - out, - "\ngroup {} — typeDiagram fence {} on line {}, {} declaration(s), digest {}", - group.ordinal, - group.definition.ordinal, - group.definition.line, - model.decls().len(), - super::digest(&group.definition.body), - ) - .map_err(report_fault)?; - for template in &group.templates { - let target = target::find(&template.target)?; - writeln!( - out, - " -> {} (target {}, fence {} on line {}, digest {})", - template.output, - target.name, - template.fence.ordinal, - template.fence.line, - super::digest(&template.fence.body), - ) - .map_err(report_fault)?; - let ctx = context::build(&document, group, template, &model, target)?; - writeln!( - out, - "{}", - serde_json::to_string_pretty(&json!({ "context": ctx })) - .context("DMX2000: internal error — the context is not serializable")? - ) - .map_err(report_fault)?; - } - } - Ok(out) -} - -/// A `String` that cannot be written to is not a condition this program can -/// act on, and saying so is better than a panic that says less. -fn report_fault(error: std::fmt::Error) -> anyhow::Error { - anyhow::anyhow!("DMX2000: internal error — cannot format the explain report: {error}") + Ok((document, root, groups)) } #[cfg(test)] @@ -153,38 +59,9 @@ mod tests { use super::{explain, process}; use crate::{Options, Outcome}; - /// A scratch workspace holding one document, with the process working - /// directory pointed at it. - /// - /// The working directory is process-wide, so these tests run under one - /// mutex rather than in parallel — the alternative is a `workspace` option - /// nothing but the tests would ever set. + /// The canonical worked document, in a workspace of its own. fn in_workspace(document: &str, body: impl FnOnce(&std::path::Path) -> T) -> T { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let guard = LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let directory = scratch(); - fs::create_dir_all(directory.join("docs")).expect("docs directory"); - fs::write(directory.join("docs").join("models.dmx.md"), document).expect("document"); - let previous = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(&directory).expect("enter workspace"); - let outcome = body(&directory); - std::env::set_current_dir(previous).expect("leave workspace"); - drop(fs::remove_dir_all(&directory)); - drop(guard); - outcome - } - - /// A directory nobody else holds. - fn scratch() -> std::path::PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_nanos()) - .unwrap_or_default(); - let path = std::env::temp_dir().join(format!("dmx-td-{}-{unique}", std::process::id())); - fs::create_dir_all(&path).expect("scratch directory"); - path + crate::typediagram::scratch::in_workspace(&[("docs/models.dmx.md", document)], body) } /// The canonical worked document. diff --git a/src/dmx/src/typediagram/markdown.rs b/src/dmx/src/typediagram/markdown.rs index 12fe8c9..0dd8e9c 100644 --- a/src/dmx/src/typediagram/markdown.rs +++ b/src/dmx/src/typediagram/markdown.rs @@ -14,10 +14,8 @@ use anyhow::{Result, bail}; use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; -use serde_json::Value; -/// The default generation target when a template does not name one. -pub const DEFAULT_TARGET: &str = "dart"; +use super::binding::{self, BoundTemplate, Fence, Group, Metadata, Origin}; /// The info-string language that opens a definition, compared case-insensitively. const DEFINITION_LANGUAGE: &str = "typediagram"; @@ -25,46 +23,16 @@ const DEFINITION_LANGUAGE: &str = "typediagram"; /// The info-string language a bound template uses. const TEMPLATE_LANGUAGE: &str = "mustache"; -/// One fenced code block dmx looked at [typediagram.documents]. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Fence { - /// Its one-based position among the document's top-level fenced blocks. - pub ordinal: usize, - /// The one-based document line its opening marker sits on. - pub line: usize, - /// Its content, exactly as `CommonMark` reads it. - pub body: String, -} - -/// A template fence bound to the definition above it [typediagram.binding]. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BoundTemplate { - /// The fence itself. - pub fence: Fence, - /// The workspace-relative output path, as the author wrote it. - pub output: String, - /// The generation target, defaulting to [`DEFAULT_TARGET`]. - pub target: String, -} - -/// One definition and every template bound to it [typediagram.binding]. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Group { - /// Its one-based position among the document's generation groups. - pub ordinal: usize, - /// The typeDiagram fence. - pub definition: Fence, - /// The templates it generates through, in document order. - pub templates: Vec, -} +/// The spelling a reader copies when their fence metadata is refused. +const EXAMPLE: &str = "```mustache {\"dmx\": {\"output\": \"lib/models.dart\"}}"; /// Every generation group in `source`, in document order. /// /// # Errors /// -/// Fails on malformed fence metadata (`DMX8001`), a bound template with no -/// definition above it (`DMX8002`), or two templates claiming one output path -/// (`DMX8003`). +/// Fails on malformed fence metadata (`DMX8001`) or on a bound template with +/// no definition above it (`DMX8002`). Two templates claiming one output is +/// [`super::binding::refuse_duplicate_outputs`], which both front ends run. pub fn groups(source: &str) -> Result> { let nodes = top_level_fences(source)?; let mut groups: Vec = Vec::new(); @@ -80,6 +48,7 @@ pub fn groups(source: &str) -> Result> { } if !templates.is_empty() { groups.push(Group { + origin: Origin::Document, ordinal: groups.len().saturating_add(1), definition: definition.clone(), templates, @@ -99,28 +68,9 @@ pub fn groups(source: &str) -> Result> { Node::Other => {} } } - refuse_duplicate_outputs(&groups)?; Ok(groups) } -/// Refuses two templates that would write the same file [typediagram.binding]. -fn refuse_duplicate_outputs(groups: &[Group]) -> Result<()> { - let mut seen: Vec<(&str, usize)> = Vec::new(); - for group in groups { - for template in &group.templates { - match seen.iter().find(|(path, _)| *path == template.output) { - Some((path, line)) => bail!( - "DMX8003 [typediagram.binding]: the templates on lines {line} and {} both \ - generate `{path}`; one output has one template", - template.fence.line - ), - None => seen.push((&template.output, template.fence.line)), - } - } - } - Ok(()) -} - /// What one top-level fenced block turned out to be. #[derive(Clone, Debug)] enum Node { @@ -212,7 +162,7 @@ fn classify(info: &str, fence: Fence) -> Result { Ok(Node::Definition(fence)) } () if language.eq_ignore_ascii_case(TEMPLATE_LANGUAGE) => { - Ok(match binding(meta, &fence)? { + Ok(match declared(meta, &fence)? { Some(template) => Node::Template(template), None => Node::Other, }) @@ -223,61 +173,22 @@ fn classify(info: &str, fence: Fence) -> Result { /// The dmx binding a Mustache fence declares, or `None` when it declares none. /// -/// Metadata that does not open with `{` belongs to somebody else's convention -/// and is left alone. Metadata that does is dmx's to read: a JSON object -/// without a `dmx` key is an ordinary example, and anything else is a mistake -/// worth reporting rather than silently generating nothing -/// [typediagram.binding]. -/// /// # Errors /// -/// Fails when the metadata is not a JSON object, when `dmx` is not an object, -/// when `output` is missing or empty, or when an unrecognised key appears — -/// all `DMX8001`. -fn binding(meta: &str, fence: &Fence) -> Result> { - if !meta.starts_with('{') { - return Ok(None); - } - let fault = |detail: &str| { - anyhow::anyhow!( - "DMX8001 [typediagram.binding]: the Mustache fence on line {} has unusable dmx \ - metadata: {detail}\n\n ```mustache {{\"dmx\": {{\"output\": \"lib/models.dart\"}}}}", - fence.line - ) - }; - let Ok(Value::Object(metadata)) = serde_json::from_str::(meta) else { - return Err(fault("it is not a JSON object")); - }; - let Some(dmx) = metadata.get("dmx") else { - return Ok(None); - }; - let Value::Object(dmx) = dmx else { - return Err(fault("`dmx` is not an object")); - }; - if let Some(unknown) = dmx.keys().find(|key| !DMX_KEYS.contains(&key.as_str())) { - return Err(fault(&format!( - "`dmx.{unknown}` is not a setting dmx knows" - ))); - } - let output = match dmx.get("output") { - Some(Value::String(output)) if !output.trim().is_empty() => output.trim().to_owned(), - _ => return Err(fault("`dmx.output` must be a non-empty output path")), - }; - let target = match dmx.get("target") { - None => DEFAULT_TARGET.to_owned(), - Some(Value::String(target)) if !target.trim().is_empty() => target.trim().to_owned(), - Some(_) => return Err(fault("`dmx.target` must be a target name")), - }; - Ok(Some(BoundTemplate { - fence: fence.clone(), - output, - target, - })) +/// Fails (`DMX8001`) for every reason [`binding::in_document`] fails. A fence names +/// its own output or it is not a binding at all: a document has no convention +/// to fall back on, because a fence has no file name to derive one from. +fn declared(meta: &str, fence: &Fence) -> Result> { + binding::in_document( + meta, + fence.clone(), + &Metadata { + located: format!("the Mustache fence on line {}", fence.line), + example: EXAMPLE, + }, + ) } -/// Every key a `dmx` metadata object may carry. -const DMX_KEYS: &[&str] = &["output", "target"]; - /// The language and the metadata halves of an info string. fn split_info(info: &str) -> (&str, &str) { match info.trim().split_once(char::is_whitespace) { @@ -306,7 +217,8 @@ fn line_of(starts: &[usize], offset: usize) -> usize { #[cfg(test)] mod tests { - use super::{DEFAULT_TARGET, groups}; + use super::binding::DEFAULT_TARGET; + use super::groups; /// A document with `body` between two ordinary paragraphs, so every test /// also proves prose neither binds nor breaks. @@ -445,14 +357,22 @@ mod tests { } } - /// [typediagram.binding]: two templates may not claim one path. + /// [typediagram.binding]: two templates may not claim one path, and the + /// refusal names each fence by the line its reader will scroll to. #[test] fn one_output_has_one_template() { - let error = groups(&document( + let found = groups(&document( "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nb\n```", )) - .expect_err("duplicate output"); - assert!(format!("{error:#}").contains("DMX8003"), "{error:#}"); + .expect("bind"); + let error = format!( + "{:#}", + super::binding::refuse_duplicate_outputs("docs/a.dmx.md", &found) + .expect_err("duplicate output") + ); + assert!(error.contains("DMX8003"), "{error}"); + assert!(error.contains("on line 9"), "{error}"); + assert!(error.contains("on line 13"), "{error}"); } /// [typediagram.binding]: longer fences, CRLF, Unicode prose, and several diff --git a/src/dmx/src/typediagram/mod.rs b/src/dmx/src/typediagram/mod.rs index 2e591f6..4594c76 100644 --- a/src/dmx/src/typediagram/mod.rs +++ b/src/dmx/src/typediagram/mod.rs @@ -1,19 +1,25 @@ //! The built-in `typeDiagram` macro [typediagram]. //! -//! typeDiagram definitions plus Mustache templates equal generated code. The -//! definitions live in an ordinary Markdown document that typeDiagram's own -//! tooling still renders; the templates live beside them; dmx owns everything -//! in between — parsing, resolution, context, rendering, validation, and safe -//! emission — and never runs typeDiagram's CLI, library, or language emitters -//! [typediagram.delivery.baseline]. +//! A typeDiagram definition plus a Mustache template equals generated code, +//! and there are two ways to write that down. A `.td` file is a definition and +//! no wrapper [typediagram.standalone]: it renders through the canonical model +//! template dmx ships [typediagram.canonical] unless a `.mustache` beside it +//! says otherwise, and any further template beside it is a further output. A +//! `.dmx.md` document keeps definition and templates inside prose that +//! typeDiagram's own tooling still renders [typediagram.documents]. Either way +//! dmx owns everything in between — parsing, resolution, context, rendering, +//! validation, and safe emission — and never runs typeDiagram's CLI, library, +//! or language emitters [typediagram.delivery.baseline]. //! -//! The pipeline is the ordinary one. The Markdown front end synthesizes one -//! [`Invocation`] per generation group and dispatches it through the same -//! macro registry an `@dmx('model')` annotation goes through +//! The pipeline is the ordinary one, and there is exactly one of it. Both +//! front ends build the same [`binding::Group`]; [`run`] resolves it, +//! synthesizes one [`Invocation`] per group, and dispatches it through the +//! same macro registry an `@dmx('model')` annotation goes through //! [typediagram.macro]; what comes back is whole files, emitted by the same //! ownership protocol a Dart-authored macro's siblings use [dartmacros.files]. pub mod ast; +pub mod binding; pub mod context; pub mod diagnostic; #[cfg(not(target_arch = "wasm32"))] @@ -24,15 +30,27 @@ pub mod json; pub mod lexer; pub mod markdown; pub mod model; +pub mod naming; pub mod parser; +pub mod prepared; +#[cfg(not(target_arch = "wasm32"))] +pub mod run; +#[cfg(test)] +pub mod scratch; +pub mod semantics; +#[cfg(not(target_arch = "wasm32"))] +pub mod standalone; pub mod target; use anyhow::Result; +use binding::{BoundTemplate, Group}; use diagnostic::Diagnostics; -use markdown::{BoundTemplate, Group}; use model::Model; +#[cfg(not(target_arch = "wasm32"))] +pub use standalone::{definition_of, is_definition, is_template}; + /// The file-name suffix that makes a Markdown document one dmx generates from /// [typediagram.documents]. pub const DOCUMENT_SUFFIX: &str = ".dmx.md"; @@ -81,19 +99,17 @@ pub fn ownership_marker(document: &str) -> String { crate::emit::file_marker(document) } -/// The second line: which group, which fences, and the content that produced -/// the file [typediagram.output]. +/// The second line: which binding, and the content that produced the file +/// [typediagram.output]. /// -/// The digests are what make drift visible without reading the whole document. -/// A definition or template edit changes them; prose outside the group does -/// not, which is exactly the dependency rule [typediagram.execution] states. +/// The digests are what make drift visible without reading the whole source. A +/// definition or template edit changes them; prose outside the group does not, +/// which is exactly the dependency rule [typediagram.execution] states. #[must_use] pub fn identity_line(group: &Group, template: &BoundTemplate) -> String { format!( - "// dmx: group {}, fences {}/{}, definition {}, template {}, context v{}, dmx {}.", - group.ordinal, - group.definition.ordinal, - template.fence.ordinal, + "// dmx: {}, definition {}, template {}, context v{}, dmx {}.", + group.identity(template), digest(&group.definition.body), digest(&template.fence.body), context::CONTEXT_VERSION, @@ -131,10 +147,8 @@ pub fn file_text(document: &str, group: &Group, template: &BoundTemplate, body: pub fn resolve(document: &str, group: &Group) -> Result { let fault = |found: Diagnostics| { anyhow::anyhow!( - "DMX8004 [typediagram.model]: the typeDiagram definition in {document} (fence {}, \ - line {}) is not valid:\n{}", - group.definition.ordinal, - group.definition.line, + "DMX8004 [typediagram.model]: the typeDiagram definition in {} is not valid:\n{}", + group.definition_at(document), found.in_document(group.definition.line) ) }; diff --git a/src/dmx/src/typediagram/model.rs b/src/dmx/src/typediagram/model.rs index c73c72c..95ce0ad 100644 --- a/src/dmx/src/typediagram/model.rs +++ b/src/dmx/src/typediagram/model.rs @@ -94,6 +94,16 @@ impl Model { &self.decls } + /// The declaration `name` refers to, when this model has one. + /// + /// Resolution says *that* a name is declared [typediagram.model]; this says + /// what it was declared as, which is what a codec needs before it can + /// decide whether the name has one. + #[must_use] + pub fn declaration(&self, name: &str) -> Option<&Decl> { + self.decls.iter().find(|decl| decl.name() == name) + } + /// The declarations `target` generates from — everything, minus what a /// `@targets` / `@skipTargets` annotation excludes. pub fn visible(&self, target: &str) -> impl Iterator { diff --git a/src/dmx/src/typediagram/naming.rs b/src/dmx/src/typediagram/naming.rs new file mode 100644 index 0000000..e1a4a02 --- /dev/null +++ b/src/dmx/src/typediagram/naming.rs @@ -0,0 +1,186 @@ +//! What generated code calls each union case [typediagram.canonical.names]. +//! +//! typeDiagram's own emitters name a case's class by the case's own name — +//! `final class Circle extends Shape` — and dmx names it the same way, because +//! a diagram is a shared source of truth and two tools generating from it must +//! agree on what the types are called. +//! +//! A case name is only unique inside its union, though, and a Dart library has +//! one namespace. So a case whose name is already taken — by another +//! declaration in the same definition, by a case of another union, or by a Dart +//! name generated code writes itself — takes its union's name as a prefix and +//! becomes ``. That is the [PROPER NAMES] rule: the name a case +//! was given, qualified only on a real collision. +//! +//! When both are taken the definition is refused (`DMX8010`) rather than +//! guessed at: two classes with one name is Dart that does not compile, and a +//! numbered suffix would be a name nobody chose. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, bail}; + +use super::ast::Decl; +use super::model::Model; + +/// The Dart names generated code writes itself, which a declaration therefore +/// cannot take without changing what those words mean in the file. +/// +/// This is the target's mapping table read back: every name +/// [`super::target::dart_type`] can produce, plus the ones the canonical +/// template spells out — `Object` for a JSON value and for `Object.hash`, +/// `String` for `toString`, `Function` for a signature typedef. +const DART_NAMES: &[&str] = &[ + "bool", "double", "int", "void", "DateTime", "Function", "List", "Map", "Object", "String", +]; + +/// The class name every union case in one model generates under +/// [typediagram.canonical.names]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Names { + /// `(union, case)` to the class name that case generates. + chosen: BTreeMap<(String, String), String>, +} + +impl Names { + /// Decides a class name for every case of every union `target` generates + /// from. + /// + /// # Errors + /// + /// Fails (`DMX8010`) when a case can be called neither by its own name nor + /// by its qualified one, naming both and what holds them. + pub fn of(model: &Model, target: &str) -> Result { + let mut taken: BTreeSet = DART_NAMES.iter().map(|&name| name.to_owned()).collect(); + for decl in model.visible(target) { + let _ = taken.insert(decl.name().to_owned()); + } + let cases = cases(model, target); + let mut shared: BTreeMap<&str, usize> = BTreeMap::new(); + for &(_, variant) in &cases { + let seen = shared.entry(variant).or_default(); + *seen = seen.saturating_add(1); + } + + let mut chosen = BTreeMap::new(); + for &(union, variant) in &cases { + let qualified = format!("{union}{variant}"); + let bare_is_free = + !taken.contains(variant) && shared.get(variant).is_none_or(|count| *count == 1); + let name = if bare_is_free { + variant.to_owned() + } else if taken.contains(&qualified) { + bail!( + "DMX8010 [typediagram.canonical.names]: the `{variant}` case of `{union}` \ + has no name left to generate under — `{variant}` is already taken, and so \ + is `{qualified}`" + ) + } else { + qualified + }; + let _ = taken.insert(name.clone()); + let _ = chosen.insert((union.to_owned(), variant.to_owned()), name); + } + Ok(Self { chosen }) + } + + /// What the `variant` case of `union` is called. + /// + /// A case this model never declared falls back to its qualified name, which + /// is the answer that collides with nothing. + #[must_use] + pub fn case(&self, union: &str, variant: &str) -> String { + self.chosen + .get(&(union.to_owned(), variant.to_owned())) + .cloned() + .unwrap_or_else(|| format!("{union}{variant}")) + } +} + +/// Every `(union, case)` pair `target` generates, in declaration order. +fn cases<'a>(model: &'a Model, target: &'a str) -> Vec<(&'a str, &'a str)> { + model + .visible(target) + .flat_map(|decl| match decl { + Decl::Union(union) => union + .variants + .iter() + .map(|variant| (union.name.as_str(), variant.name.as_str())) + .collect(), + Decl::Record(_) | Decl::Alias(_) | Decl::Function(_) => Vec::new(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::model::Model; + use super::super::parser::parse; + use super::Names; + + /// The names one definition's cases generate under, for the Dart target. + fn names(source: &str) -> Names { + let model = Model::resolve(parse(source).expect("parse")).expect("resolve"); + Names::of(&model, "dart").expect("names") + } + + /// [typediagram.canonical.names]: a case nothing else claims keeps its own + /// name, exactly as typeDiagram's emitters write it. + #[test] + fn a_case_keeps_the_name_the_diagram_gave_it() { + let chosen = names("union Shape { Circle { r: Float } Square { s: Float } }"); + assert_eq!(chosen.case("Shape", "Circle"), "Circle"); + assert_eq!(chosen.case("Shape", "Square"), "Square"); + } + + /// [typediagram.canonical.names]: two unions with a case of the same name + /// both qualify, so neither is renamed by the accident of coming second. + #[test] + fn a_name_two_unions_share_qualifies_on_both_sides() { + let chosen = names("union Result { Ok { v: Int } }\nunion Outcome { Ok { v: Int } }"); + assert_eq!(chosen.case("Result", "Ok"), "ResultOk"); + assert_eq!(chosen.case("Outcome", "Ok"), "OutcomeOk"); + } + + /// [typediagram.canonical.names]: a case that would shadow a record, or a + /// Dart name the file writes itself, qualifies; its siblings do not. + #[test] + fn a_taken_name_qualifies_and_leaves_its_siblings_alone() { + let chosen = names( + "type Circle { r: Float }\nunion Shape { Circle { r: Float } Square { s: Float } String { s: String } }", + ); + assert_eq!(chosen.case("Shape", "Circle"), "ShapeCircle"); + assert_eq!(chosen.case("Shape", "String"), "ShapeString"); + assert_eq!(chosen.case("Shape", "Square"), "Square"); + } + + /// [typediagram.canonical.names]: a declaration excluded from this target + /// takes no name with it. + #[test] + fn a_declaration_another_target_owns_claims_nothing() { + let chosen = + names("@targets(rust)\ntype Circle { r: Float }\nunion Shape { Circle { r: Float } }"); + assert_eq!(chosen.case("Shape", "Circle"), "Circle"); + } + + /// [typediagram.canonical.names]: when both names are taken the definition + /// is refused rather than generated as Dart that will not compile. + #[test] + fn a_case_with_no_name_left_is_refused() { + let model = Model::resolve( + parse("type Circle { r: Float }\ntype ShapeCircle { r: Float }\nunion Shape { Circle { r: Float } }") + .expect("parse"), + ) + .expect("resolve"); + let error = format!("{:#}", Names::of(&model, "dart").expect_err("no name left")); + assert!(error.contains("DMX8010"), "{error}"); + assert!(error.contains("`ShapeCircle`"), "{error}"); + } + + /// A case nobody declared answers with the name that collides with + /// nothing. + #[test] + fn an_undeclared_case_falls_back_to_its_qualified_name() { + assert_eq!(Names::default().case("Shape", "Circle"), "ShapeCircle"); + } +} diff --git a/src/dmx/src/typediagram/prepared.rs b/src/dmx/src/typediagram/prepared.rs new file mode 100644 index 0000000..98964f4 --- /dev/null +++ b/src/dmx/src/typediagram/prepared.rs @@ -0,0 +1,102 @@ +//! The little prepared values every context object is built out of +//! [context.discipline]. +//! +//! Casings, generic lists, constructor fragments, and the first/last markers +//! that let a template lay out a list without arithmetic. None of it knows +//! what a declaration is; all of it is what stops a template counting. +//! +//! A separate file only because [`super::context`] is at the 500-line ceiling. + +use serde_json::{Map, Value}; + +use crate::casing; + +/// Adds one prepared value to a context object. +/// +/// `Map::insert` returns whatever it displaced, which is never anything here +/// and which `unused_results` obliges every caller to discard. Written out, the +/// builders below would be `let _ =` noise wrapped around the one thing that +/// matters — the name and the value. +pub(super) fn put(out: &mut Map, name: &str, value: impl Into) { + drop(out.insert(name.to_owned(), value.into())); +} + +/// A name in every casing a template might place it in +/// [context.helpers]. +pub(super) fn named(name: &str) -> Map { + let mut out = Map::new(); + put(&mut out, "name", name); + put(&mut out, "camelName", casing::camel(name)); + put(&mut out, "pascalName", casing::pascal(name)); + put(&mut out, "snakeName", casing::snake(name)); + put( + &mut out, + "screamingSnakeName", + casing::screaming_snake(name), + ); + put(&mut out, "label", casing::label(name)); + out +} + +/// ``, or the empty string when there are no parameters. +pub(super) fn generic_list(generics: &[String]) -> String { + if generics.is_empty() { + return String::new(); + } + format!("<{}>", generics.join(", ")) +} + +/// The named-parameter list a constructor takes, braces included, or the empty +/// string when there is nothing to take. +pub(super) fn constructor_parameters(fields: &[Map]) -> String { + let parts: Vec<&str> = fields + .iter() + .filter_map(|field| field.get("parameter").and_then(Value::as_str)) + .collect(); + if parts.is_empty() { + return String::new(); + } + format!("{{{}}}", parts.join(", ")) +} + +/// One constructor parameter. An optional member has a default of `null` +/// already, so requiring it would only make callers write it. +pub(super) fn parameter(name: &str, optional: bool) -> String { + if optional { + return format!("this.{name}"); + } + format!("required this.{name}") +} + +/// The positional parameter list a free function takes. +pub(super) fn parameter_list(params: &[Map]) -> String { + params + .iter() + .filter_map(|param| { + Some(format!( + "{} {}", + param.get("targetType")?.as_str()?, + param.get("name")?.as_str()? + )) + }) + .collect::>() + .join(", ") +} + +/// Stamps `first`, `last`, and `comma` onto every member of a list, so a +/// template lays out separators without counting [context.discipline]. +pub(super) fn positioned(items: Vec>) -> Vec { + let last = items.len().saturating_sub(1); + items + .into_iter() + .enumerate() + .map(|(index, mut item)| { + let final_item = index == last; + put(&mut item, "first", index == 0); + put(&mut item, "last", final_item); + put(&mut item, "index", index); + put(&mut item, "comma", if final_item { "" } else { "," }); + Value::Object(item) + }) + .collect() +} diff --git a/src/dmx/src/typediagram/run.rs b/src/dmx/src/typediagram/run.rs new file mode 100644 index 0000000..9ad7ba2 --- /dev/null +++ b/src/dmx/src/typediagram/run.rs @@ -0,0 +1,129 @@ +//! Every generation group through the pipeline, however it was bound +//! [typediagram.execution]. +//! +//! Resolve → invoke the built-in macro → check the paths → emit. The sources +//! are never rewritten: the definition and the template are the truth, and dmx +//! only ever reads them [typediagram.output]. +//! +//! [`report`] walks the same path and stops before emission, printing what the +//! templates will actually see. It is the template author's only tool, so it +//! prints the exact context rather than a summary of it. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use serde_json::json; + +use super::binding::{self, Group}; +use super::{Invocation, context, emit, resolve, target}; +use crate::{Options, Outcome, macros}; + +/// Generates every group `document` declares, writing what changed +/// [typediagram.execution]. +/// +/// `roots` is the scope this pass was asked to manage, and therefore the scope +/// stale outputs are collected from: an output that a removed template used to +/// produce is found by its ownership marker among the files dmx already walks. +/// +/// # Errors +/// +/// Fails when binding, resolution, rendering, validation, or path safety +/// refuses the work, or on I/O. +pub fn generate( + document: &str, + root: &Path, + groups: &[Group], + roots: &[PathBuf], + opts: &Options, +) -> Result { + binding::refuse_duplicate_outputs(document, groups)?; + let outputs = render(document, root, groups)?; + let candidates = crate::sources::collect_outputs(roots)?; + let changed = emit::emit(document, root, &outputs, &candidates, opts.check)?; + Ok(if changed { + Outcome::Updated + } else { + Outcome::Unchanged + }) +} + +/// Every output these groups declare, rendered and validated but not written. +fn render(document: &str, root: &Path, groups: &[Group]) -> Result> { + let mut outputs = Vec::new(); + for group in groups { + let model = resolve(document, group)?; + let files = macros::expand_group(&Invocation { + document, + group, + model: &model, + })?; + // The macro renders one file per bound template, in binding order, so + // a path fault can name the template that declared it. + for (template, file) in group.templates.iter().zip(files) { + let located = || format!("in {}", template.located(document)); + emit::refuse_self_overwrite(document, &file.name).with_context(located)?; + let path = emit::resolve_output(root, &file.name).with_context(located)?; + outputs.push((path, file.text)); + } + } + Ok(outputs) +} + +/// What `dmx explain` prints for one set of groups [typediagram.execution]. +/// +/// Nothing is rendered and nothing is written: this is the input side of the +/// pipeline, laid out so a template author can see the names they may place +/// before they place them. +/// +/// # Errors +/// +/// Fails when binding or resolution refuses the work — the same failures +/// generation would report. +pub fn report(document: &str, root: &Path, groups: &[Group]) -> Result { + binding::refuse_duplicate_outputs(document, groups)?; + let mut out = format!( + "{document}: {} generation group(s), outputs under {}\n", + groups.len(), + root.display() + ); + for group in groups { + let model = resolve(document, group)?; + writeln!( + out, + "\ngroup {} — {}, {} declaration(s), digest {}", + group.ordinal, + group.heading(), + model.decls().len(), + super::digest(&group.definition.body), + ) + .map_err(report_fault)?; + for template in &group.templates { + let target = target::find(&template.target)?; + writeln!( + out, + " -> {} (target {}, {}, digest {})", + template.output, + target.name, + template.heading(), + super::digest(&template.fence.body), + ) + .map_err(report_fault)?; + let ctx = context::build(document, group, template, &model, target)?; + writeln!( + out, + "{}", + serde_json::to_string_pretty(&json!({ "context": ctx })) + .context("DMX2000: internal error — the context is not serializable")? + ) + .map_err(report_fault)?; + } + } + Ok(out) +} + +/// A `String` that cannot be written to is not a condition this program can +/// act on, and saying so is better than a panic that says less. +fn report_fault(error: std::fmt::Error) -> anyhow::Error { + anyhow::anyhow!("DMX2000: internal error — cannot format the explain report: {error}") +} diff --git a/src/dmx/src/typediagram/scratch.rs b/src/dmx/src/typediagram/scratch.rs new file mode 100644 index 0000000..1f6c2cf --- /dev/null +++ b/src/dmx/src/typediagram/scratch.rs @@ -0,0 +1,60 @@ +//! One scratch workspace, entered by one test at a time. +//! +//! The working directory is process-wide state, and both front ends generate +//! against it: an output path is workspace-relative, and the root it resolves +//! under is found by walking up from the source. Tests that need a real tree +//! therefore have to enter one, and two of them entering different trees at +//! once is a race that shows up as a file another test already deleted. +//! +//! The lock lives here rather than in either front end's tests because a lock +//! per module locks nothing: the thing being shared is the process. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The one lock every test that enters a workspace holds. +static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Runs `body` in a fresh workspace holding `files`, with the process working +/// directory pointed at it. +/// +/// Each entry is a workspace-relative path and its contents; parent +/// directories are created. The workspace is removed afterwards whatever +/// `body` did. +/// +/// # Panics +/// +/// Panics when the workspace cannot be created, written, entered, or left. A +/// test that cannot get a directory has not failed at what it was testing, and +/// unwinding here names the problem better than any `Result` plumbing would. +pub fn in_workspace(files: &[(&str, &str)], body: impl FnOnce(&Path) -> T) -> T { + let guard = LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let directory = directory(); + for (name, content) in files { + let path = directory.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent directory"); + } + fs::write(&path, content).expect("fixture"); + } + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&directory).expect("enter workspace"); + let outcome = body(&directory); + std::env::set_current_dir(previous).expect("leave workspace"); + drop(fs::remove_dir_all(&directory)); + drop(guard); + outcome +} + +/// A directory nobody else holds. +fn directory() -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let path = std::env::temp_dir().join(format!("dmx-td-{}-{unique}", std::process::id())); + fs::create_dir_all(&path).expect("scratch directory"); + path +} diff --git a/src/dmx/src/typediagram/semantics.rs b/src/dmx/src/typediagram/semantics.rs new file mode 100644 index 0000000..834c349 --- /dev/null +++ b/src/dmx/src/typediagram/semantics.rs @@ -0,0 +1,313 @@ +//! Value semantics and the JSON codec one class gets [typediagram.canonical]. +//! +//! A record declared in a diagram is an immutable value, and a value that +//! cannot be compared is not one. This module finishes every `==`, `hashCode`, +//! `toString`, `copyWith`, decode and encode expression a generated class +//! needs, in Rust, exactly as `@dmx('model')` finishes them for a class +//! somebody wrote by hand — the same functions, called with a different +//! [`Runtime`], so the two can never say different things about the same type +//! [authoring.intelligence]. +//! +//! Two things differ, and both follow from the file being written whole rather +//! than spliced into one somebody else owns. +//! +//! The runtime import is this generator's to write, so it is written prefixed: +//! a diagram is free to declare a type called `Result`, a local declaration +//! hides an imported name, and `dmx.Result` cannot be hidden by anything. +//! +//! The JSON members go on a `Json` extension rather than into the class, +//! so the class stays what the diagram said it was — a constructor, its fields, +//! and value semantics — and serialization is something added to it. +//! +//! Not every declaration can have a codec. A type parameter, a generic +//! declaration, an untagged union, `Unit`, and a map keyed by anything but a +//! string all refuse one [typediagram.canonical], and a declaration that +//! contains one of them keeps its class and its value semantics and simply has +//! no JSON extension. + +use anyhow::{Result, bail}; +use serde_json::{Map, Value}; + +use super::ast::Field; +use super::model::Model; +use super::prepared::put; +use super::target::Target; +use crate::casing; +use crate::macros::{self, model as datamodel}; +use crate::types::{DartType, JSON_EXTENSION, Runtime}; + +/// The import whole-file generation writes to reach the runtime +/// [typediagram.canonical]. +pub const RUNTIME_IMPORT: &str = "import 'package:dmx/dmx.dart' as dmx;"; + +/// How generated code in a file dmx wrote whole reaches the runtime. +const RUNTIME: Runtime = Runtime::PREFIXED; + +/// The Dart type a `toJson` returns. `Object?` rather than `dynamic`, because +/// generated code never needs the one thing `dynamic` adds. +const JSON_MAP: &str = "Map"; + +/// `Object.hash` takes at most 20 positional components, and `runtimeType` is +/// the first of them. +const HASH_ARITY: usize = 19; + +/// One class the canonical model template writes out +/// [typediagram.canonical]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Class<'a> { + /// Its Dart name — a record's own, or a variant's owner-qualified one. + pub name: String, + /// Its Dart type, type parameters included. + pub ty: String, + /// Whether it is generic, which is what stops it having a codec. + pub generic: bool, + /// Its members, in declaration order. + pub fields: &'a [Field], +} + +/// Places everything the canonical template needs onto one class and its +/// members [typediagram.canonical]. +/// +/// `members` are the context objects [`super::context`] already built for +/// `class.fields`, in the same order, and they are finished in place. +/// +/// # Errors +/// +/// Fails when a member reached here without the name or the target type the +/// context builder puts on every one of them, which is a bug in this crate. +pub fn place( + out: &mut Map, + members: &mut [Map], + class: &Class<'_>, + model: &Model, + target: &Target, +) -> Result<()> { + let names = members.iter().map(text).collect::>>()?; + let other = macros::fresh_name(&["other", "that", "operand"], &names).to_owned(); + + let mut codecs = Vec::new(); + let mut values = 0usize; + let mut opaque = false; + for (member, field) in members.iter_mut().zip(class.fields) { + let name = text(member)?; + let declared = DartType::parse(&declared_type(member)?)?; + // Dart's `void` is not a value: it cannot be compared, interpolated, or + // passed on, so a member of that type takes part in nothing. + let is_value = declared.name != "void"; + put(member, "isValue", is_value); + put(member, "isLastValue", false); + if is_value { + values = values.saturating_add(1); + put( + member, + "equalsExpr", + datamodel::comparison(&declared, &other, &name, true, RUNTIME), + ); + put( + member, + "hashExpr", + datamodel::hash_component(&declared, &name, RUNTIME), + ); + put( + member, + "copyParam", + datamodel::copy_param(&declared, &name, RUNTIME), + ); + put( + member, + "copyArg", + datamodel::copy_arg(&declared, &name, RUNTIME), + ); + put(member, "toStringExpr", format!("{name}: ${name}")); + } else { + opaque = true; + } + codecs.push(codec(&name, field, model, target)); + } + + let has_json = !class.generic && codecs.iter().all(Result::is_ok); + let mut complex = 0; + if has_json { + complex = place_codecs(members, codecs)?; + } else { + put(out, "jsonRefusals", refusals(class, codecs)); + } + + // `toString` separates the members that carry a value, which is not the + // same list as the members [context.discipline] already marked `last`. + if let Some(member) = members.iter_mut().rev().find(|m| flag(m, "isValue")) { + put(member, "isLastValue", true); + } + + // A `void` member cannot be handed back to the constructor, so a class + // holding one has nothing to copy into. + let can_copy = !class.fields.is_empty() && !opaque; + let wide = values > HASH_ARITY; + put(out, "otherParam", other); + put( + out, + "hashCombiner", + if wide { + "Object.hashAll" + } else { + "Object.hash" + }, + ); + put(out, "useHashAll", wide); + put(out, "hasValues", values > 0); + put(out, "canCopy", can_copy); + put(out, "hasComplex", complex > 0); + put( + out, + "hasPattern", + members.iter().any(|member| flag(member, "inPattern")), + ); + codec_names(out, has_json, &class.name, &class.ty, members.is_empty()); + // Exactly the expressions the canonical template will actually render: an + // import this file does not use is an analyzer error, not a stray line. + let copies = can_copy && touches(members, &["copyParam", "copyArg"]); + put( + out, + "usesRuntime", + has_json || copies || touches(members, &["equalsExpr", "hashExpr"]), + ); + Ok(()) +} + +/// The names a codec is written in terms of, for a class or a union. +/// +/// Prepared rather than composed by the template, because every one of them +/// names something in the runtime and the prefix that reaches it is this +/// module's business, not a template author's [context.discipline]. +pub fn codec_names( + out: &mut Map, + has_json: bool, + name: &str, + ty: &str, + empty: bool, +) { + put(out, "className", name.to_owned()); + put(out, "classType", ty.to_owned()); + put(out, "hasJson", has_json); + put(out, "jsonExtension", format!("{name}{JSON_EXTENSION}")); + put(out, "jsonMap", JSON_MAP); + put( + out, + "decodeResult", + format!( + "{}<{ty}, {}>", + RUNTIME.name("Result"), + RUNTIME.name("DecodeError") + ), + ); + put(out, "decodeOk", RUNTIME.name("Ok")); + put( + out, + "decodeFailure", + format!( + "{}({}(path, '{name}', json))", + RUNTIME.name("Err"), + RUNTIME.name("DecodeError") + ), + ); + put(out, "decodeErr", format!("{}(e)", RUNTIME.name("Err"))); + // A class with no members has nothing to read out of the map it matched, + // and a binding nothing uses is an analyzer error. + put( + out, + "jsonShape", + if empty { + format!("{JSON_MAP}()") + } else { + format!("final {JSON_MAP} json") + }, + ); +} + +/// Places one member's codec on it, and reports how many of them decode +/// through a `Result`. +fn place_codecs( + members: &mut [Map], + codecs: Vec>, +) -> Result { + for (member, built) in members.iter_mut().zip(codecs) { + let built = built?; + put(member, "bind", built.bind); + put(member, "ctorExpr", built.ctor_expr); + put(member, "jsonKey", built.json_key); + put(member, "patternType", built.pattern_type); + put(member, "inPattern", built.in_pattern); + put(member, "isComplex", built.is_complex); + put(member, "resultExpr", built.result_expr); + put(member, "encodeExpr", built.encode_expr); + } + // The record pattern that selects each failing member, binding the error it + // carries: `(_, dmx.Err(error: final e), _)`. + let complex = members.iter().filter(|m| flag(m, "isComplex")).count(); + let mut patterns = macros::error_patterns(complex, RUNTIME).into_iter(); + for member in members.iter_mut().filter(|m| flag(m, "isComplex")) { + put(member, "errPattern", patterns.next().unwrap_or_default()); + } + Ok(complex) +} + +/// One member's codec, in the terms whole-file generation writes it in. +fn codec(name: &str, field: &Field, model: &Model, target: &Target) -> Result { + let text = (target.codec_text)(&field.ty, model)?; + let ty = DartType::parse(&text)?; + datamodel::codec(name, &ty, casing::dart_string(name), RUNTIME) +} + +/// Why a class has no JSON extension, one reason per member that refused one. +/// +/// A missing codec is a deliberate outcome rather than a failure — the class +/// and its value semantics are generated either way — but it is never silent: +/// `dmx explain` prints `hasJson` beside these, so a reader who expected a +/// codec is told which member decided otherwise. +fn refusals(class: &Class<'_>, codecs: Vec>) -> Vec { + let mut out: Vec = codecs + .into_iter() + .filter_map(Result::err) + .map(|refusal| Value::String(refusal.to_string())) + .collect(); + if class.generic { + out.push(Value::String(format!( + "DMX8009 [typediagram.canonical]: `{}` is generic, and a codec for a \ + type parameter is not known until it is applied", + class.ty + ))); + } + out +} + +/// Whether any of `keys` on any member reaches the runtime, which is what +/// decides whether the file imports it at all [typediagram.canonical]. +fn touches(members: &[Map], keys: &[&str]) -> bool { + members.iter().any(|member| { + keys.iter() + .filter_map(|key| member.get(*key)) + .filter_map(Value::as_str) + .any(|value| value.contains(RUNTIME.prefix)) + }) +} + +/// One member's `name`. +fn text(member: &Map) -> Result { + match member.get("name").and_then(Value::as_str) { + Some(name) => Ok(name.to_owned()), + None => bail!("DMX2000: internal error — a member reached the canonical builder unnamed"), + } +} + +/// One member's target type text. +fn declared_type(member: &Map) -> Result { + match member.get("dartType").and_then(Value::as_str) { + Some(text) => Ok(text.to_owned()), + None => bail!("DMX2000: internal error — a member reached the canonical builder untyped"), + } +} + +/// One boolean a member carries, absent reading as false. +fn flag(member: &Map, name: &str) -> bool { + member.get(name).and_then(Value::as_bool).unwrap_or(false) +} diff --git a/src/dmx/src/typediagram/standalone.rs b/src/dmx/src/typediagram/standalone.rs new file mode 100644 index 0000000..60b575d --- /dev/null +++ b/src/dmx/src/typediagram/standalone.rs @@ -0,0 +1,273 @@ +//! A typeDiagram definition file and the Mustache files beside it +//! [typediagram.standalone]. +//! +//! Files and no wrapper: +//! +//! ```text +//! models/shipping.td the definition — pure typeDiagram +//! models/shipping.wire.mustache a template — pure Mustache +//! lib/shipping.dart the canonical output +//! lib/shipping_wire.dart the template's output +//! ``` +//! +//! Nothing is extracted from anything. The `.td` file is byte-for-byte what +//! typeDiagram's own tooling reads, a `.mustache` file is byte-for-byte what +//! any Mustache engine renders, and the binding between them is their names. +//! A definition with nothing beside it renders through the canonical model +//! template [typediagram.canonical]; `shipping.mustache` would take that +//! template's place, and `shipping.wire.mustache` renders the same definition +//! a second way into a second file. A template that wants a different +//! destination says so in a leading Mustache comment, which is the one place a +//! template can carry metadata without ceasing to be a template. +//! +//! This is a front end and nothing else: bind the files, hand the group to +//! [`super::run`], and let the shared pipeline do the rest. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result, bail}; + +use super::binding::{self, BoundTemplate, Fence, Group, Metadata, Origin, Source}; +use super::{emit, run, target}; +use crate::{Options, Outcome, casing}; + +/// The extension that makes a file a typeDiagram definition +/// [typediagram.standalone]. +pub const DEFINITION_EXTENSION: &str = "td"; + +/// The extension that makes a file a Mustache template. +pub const TEMPLATE_EXTENSION: &str = "mustache"; + +/// The word that makes a leading Mustache comment dmx's to read. +const MARKER: &str = "dmx"; + +/// The spelling a reader copies when their template metadata is refused. +const EXAMPLE: &str = "{{! dmx output=lib/models/shipping.dart }}"; + +/// Generates every template bound to the definition file `path`, writing what +/// changed [typediagram.standalone]. +/// +/// `roots` is the scope this pass was asked to manage, and therefore the scope +/// stale outputs are collected from. +/// +/// # Errors +/// +/// Fails when the definition or one of its templates cannot be read, when +/// binding, resolution, rendering, validation, or path safety refuses the +/// work, or on I/O. +pub fn process(path: &Path, roots: &[PathBuf], opts: &Options) -> Result { + let (definition, root, groups) = bind(path)?; + run::generate(&definition, &root, &groups, roots, opts) +} + +/// What `dmx explain` prints for a definition file [typediagram.execution]. +/// +/// # Errors +/// +/// Fails for the same reasons generation would. +pub fn explain(path: &Path) -> Result { + let (definition, root, groups) = bind(path)?; + run::report(&definition, &root, &groups) +} + +/// Whether `path` is a typeDiagram definition file [typediagram.standalone]. +#[must_use] +pub fn is_definition(path: &Path) -> bool { + has_extension(path, DEFINITION_EXTENSION) +} + +/// Whether `path` is a Mustache template file. +#[must_use] +pub fn is_template(path: &Path) -> bool { + has_extension(path, TEMPLATE_EXTENSION) +} + +/// The definition file `template` renders, when there is one +/// [typediagram.standalone]. +/// +/// A template belongs to the most specific definition beside it: with both +/// `shipping.td` and `shipping.wire.td` present, `shipping.wire.mustache` +/// renders the second, because a name that matches two definitions matches the +/// longer one first. That single rule is what binds a definition to its +/// templates and what tells the watcher which definition to re-run when a +/// template changes — one rule, so the two can never disagree. +#[must_use] +pub fn definition_of(template: &Path) -> Option { + if !is_template(template) { + return None; + } + let directory = template.parent().unwrap_or_else(|| Path::new(".")); + let mut stem = template.file_stem()?.to_str()?; + loop { + let candidate = directory.join(format!("{stem}.{DEFINITION_EXTENSION}")); + if candidate.is_file() { + return Some(candidate); + } + stem = stem.rsplit_once('.')?.0; + } +} + +/// The definition's name, the root its outputs resolve against, and the one +/// group its templates form. +/// +/// A definition always renders: with nothing beside it, it renders through the +/// canonical model template [typediagram.canonical], and a `.mustache` +/// beside it replaces that one. Every other template beside it — the +/// `..mustache` files — is an output of its own. +fn bind(path: &Path) -> Result<(String, PathBuf, Vec)> { + let body = fs::read_to_string(path) + .with_context(|| format!("DMX1002: cannot read {}", path.display()))?; + let workspace = std::env::current_dir().context("DMX1002: cannot resolve the workspace")?; + let root = emit::output_root(&workspace, path); + let definition = emit::document_name(&root, path); + let files = templates_beside(path)?; + let mut templates = if files.iter().any(|file| replaces_canonical(file, path)) { + Vec::new() + } else { + vec![canonical(&output_name(path)?)?] + }; + for file in &files { + let text = fs::read_to_string(file) + .with_context(|| format!("DMX1002: cannot read {}", file.display()))?; + let source = emit::document_name(&root, file); + let name = output_name(file)?; + let fence = Fence { + // A file's body starts on line one, so a position inside it is + // already a position in the file the author is editing. + ordinal: templates.len().saturating_add(1), + line: 0, + body: text, + }; + let at = Metadata { + located: format!("the template {source}"), + example: EXAMPLE, + }; + let declared = metadata(&fence.body).to_owned(); + templates.push(binding::in_file(&declared, fence, source, &name, &at)?); + } + let groups = vec![Group { + origin: Origin::Files, + ordinal: 1, + definition: Fence { + ordinal: 1, + line: 0, + body, + }, + templates, + }]; + Ok((definition, root, groups)) +} + +/// The binding a definition gets from the canonical model template +/// [typediagram.canonical]. +/// +/// It lands where the convention puts any unnamed output — the target's source +/// root, under the definition's own name — because that is the file a reader +/// looking for `shipping.td`'s Dart would open. +fn canonical(name: &str) -> Result { + let target = target::find(binding::DEFAULT_TARGET)?; + Ok(BoundTemplate { + fence: Fence { + ordinal: 1, + line: 0, + body: target.canonical.to_owned(), + }, + output: format!("{}/{name}.{}", target.source_root, target.extension), + target: binding::DEFAULT_TARGET.to_owned(), + source: Source::Canonical, + }) +} + +/// Whether this template takes the canonical one's place — the one whose name +/// is the definition's own, with nothing between them [typediagram.canonical]. +fn replaces_canonical(template: &Path, definition: &Path) -> bool { + template.file_stem() == definition.file_stem() +} + +/// Every template file bound to `definition`, in the order their names sort. +/// +/// Sorted rather than however the filesystem happened to return them: the +/// order is the order outputs are rendered and reported in, and a generation +/// that depended on directory iteration order would not be reproducible. +fn templates_beside(definition: &Path) -> Result> { + let directory = definition.parent().unwrap_or_else(|| Path::new(".")); + let entries = fs::read_dir(directory) + .with_context(|| format!("DMX1002: cannot read {}", directory.display()))?; + let mut found: Vec = Vec::new(); + for entry in entries { + let path = entry + .with_context(|| format!("DMX1002: cannot read {}", directory.display()))? + .path(); + if definition_of(&path).is_some_and(|found| same_file(&found, definition)) { + found.push(path); + } + } + found.sort(); + Ok(found) +} + +/// The base name a template's output takes when the template names none. +/// +/// `shipping.mustache` generates `shipping`, and `shipping.wire.mustache` +/// generates `shipping_wire`: the dot that separates a template from its +/// definition is a word boundary, and the result is spelled the way the +/// target's own sources are [context.helpers]. +fn output_name(template: &Path) -> Result { + let Some(stem) = template.file_stem().and_then(std::ffi::OsStr::to_str) else { + bail!( + "DMX8005 [typediagram.standalone]: {} has no name an output could be derived from", + template.display() + ); + }; + Ok(casing::snake(&stem.replace('.', "_"))) +} + +/// The dmx settings a template's first line carries, or `""` when it carries +/// none [typediagram.standalone]. +/// +/// A leading `{{! … }}` is a Mustache comment: every engine renders it to +/// nothing, so a template that carries one is still an ordinary template. dmx +/// reads what is inside it and otherwise leaves it exactly where it is — the +/// text handed to the renderer is the whole file, comment included, which is +/// what keeps the digest on the output's marker line a digest of the file the +/// author actually edits. +/// +/// The settings are `key=value` rather than the JSON a fence's info string +/// carries, and that is not a style choice: a Mustache comment cannot contain +/// a `}` at all — the engine reads the first one as the start of the closing +/// braces and refuses the template — so an object could never survive here. +/// What the keys mean is decided in one place either way [typediagram.binding]. +fn metadata(body: &str) -> &str { + let first = body.lines().next().unwrap_or_default().trim(); + let Some(inside) = first + .strip_prefix("{{!") + .and_then(|rest| rest.strip_suffix("}}")) + else { + return ""; + }; + match inside.trim().split_once(char::is_whitespace) { + Some((MARKER, settings)) => settings.trim(), + // Somebody else's comment, or `{{! dmx }}` with nothing after it. + _ => "", + } +} + +/// Whether two paths name one file, however each of them was spelled. +fn same_file(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +/// Whether `path` carries `extension`, however it is cased. +fn has_extension(path: &Path, extension: &str) -> bool { + path.extension() + .is_some_and(|found| found.eq_ignore_ascii_case(extension)) +} + +// A separate file only because standalone.rs is near the 500-line ceiling. +#[cfg(test)] +#[path = "standalone_tests.rs"] +mod tests; diff --git a/src/dmx/src/typediagram/standalone_tests.rs b/src/dmx/src/typediagram/standalone_tests.rs new file mode 100644 index 0000000..7ff510b --- /dev/null +++ b/src/dmx/src/typediagram/standalone_tests.rs @@ -0,0 +1,427 @@ +//! Three files and no wrapper [typediagram.standalone]. +//! +//! Every assertion here is about the *binding*: which template renders which +//! definition, where the render lands, and what happens when a name matches +//! more than one definition. The pipeline behind it is [`super::super::run`], +//! which the Markdown front end proves just as hard. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::super::scratch::in_workspace; +use super::{definition_of, is_definition, is_template, output_name}; +use crate::{Options, Outcome}; + +/// The definition every fixture below renders. +const DEFINITION: &str = "type Product {\n id: Uuid\n name: String\n}\n"; + +/// The template every fixture below renders it through. +const TEMPLATE: &str = "{{#declarations}}\nfinal class {{name}} {\n const \ + {{name}}({{{constructorParameters}}});\n{{#fields}}\n final \ + {{{dartType}}} {{name}};\n{{/fields}}\n}\n{{/declarations}}\n"; + +/// The pipeline options for a real build. +fn build() -> Options { + Options { + insert_regions: false, + check: false, + } +} + +/// The one root a build of these fixtures manages. +fn roots() -> Vec { + vec![PathBuf::from("lib")] +} + +/// [typediagram.standalone]: a `.td` file and the `.mustache` beside it +/// generate a Dart file, with no document anywhere and no metadata anywhere. +#[test] +fn a_definition_and_the_template_beside_it_generate_dart() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + assert_eq!( + super::process(&definition, &roots(), &build()).expect("build"), + Outcome::Updated + ); + let generated = + fs::read_to_string(directory.join("lib").join("product.dart")).expect("output"); + assert!( + generated.starts_with("// dmx: generated from models/product.td — do not edit.\n"), + "{generated}" + ); + assert!( + generated.contains("// dmx: rendered through models/product.mustache, definition "), + "{generated}" + ); + assert!(generated.contains("final class Product {"), "{generated}"); + assert!( + generated.contains("const Product({required this.id, required this.name});"), + "{generated}" + ); + assert!(generated.contains("final String id;"), "{generated}"); + + // Idempotent, and neither source is ever rewritten. + assert_eq!( + super::process(&definition, &roots(), &build()).expect("second build"), + Outcome::Unchanged + ); + assert_eq!( + fs::read_to_string(&definition).expect("definition"), + DEFINITION + ); + assert_eq!( + fs::read_to_string(directory.join("models").join("product.mustache")) + .expect("template"), + TEMPLATE + ); + }, + ); +} + +/// [typediagram.standalone]: a second template beside the same definition is a +/// second output, named after the template rather than after the definition. +#[test] +fn a_second_template_is_a_second_output() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ( + "models/product.wire.mustache", + "{{#declarations}}\nconst productWireNames = [{{#fields}}'{{snakeName}}', \ + {{/fields}}];\n{{/declarations}}\n", + ), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + assert_eq!( + super::process(&definition, &roots(), &build()).expect("build"), + Outcome::Updated + ); + let wire = fs::read_to_string(directory.join("lib").join("product_wire.dart")) + .expect("second output"); + assert!(wire.contains("const productWireNames"), "{wire}"); + assert!(wire.contains("'id', 'name',"), "{wire}"); + assert!( + wire.contains("rendered through models/product.wire.mustache"), + "{wire}" + ); + assert!(directory.join("lib").join("product.dart").is_file()); + }, + ); +} + +/// [typediagram.standalone]: a leading Mustache comment moves the output, and +/// stays in the template — it is a comment, so it renders to nothing. +#[test] +fn a_leading_comment_moves_the_output() { + let template = format!("{{{{! dmx output=lib/models/product.dart }}}}\n{TEMPLATE}"); + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", &template), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + assert_eq!( + super::process(&definition, &roots(), &build()).expect("build"), + Outcome::Updated + ); + assert!(!directory.join("lib").join("product.dart").exists()); + let generated = + fs::read_to_string(directory.join("lib").join("models").join("product.dart")) + .expect("output"); + assert!(generated.contains("final class Product {"), "{generated}"); + assert!(!generated.contains("output="), "{generated}"); + }, + ); +} + +/// [typediagram.canonical]: a definition with nothing beside it renders +/// through the canonical model template, and the class it writes is a value — +/// `==`, `hashCode`, `toString`, `copyWith` — with its JSON on an extension +/// rather than on the class. +#[test] +fn a_definition_alone_renders_through_the_canonical_template() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + assert_eq!( + super::process(&definition, &roots(), &build()).expect("build"), + Outcome::Updated + ); + let generated = + fs::read_to_string(directory.join("lib").join("product.dart")).expect("output"); + assert!( + generated.contains("// dmx: rendered through the canonical model template, "), + "{generated}" + ); + for expected in [ + "final class Product {", + "bool operator ==(Object other) =>", + "int get hashCode => Object.hash(", + "String toString() => 'Product(id: $id, name: $name)';", + "Product copyWith({", + "extension ProductJson on Product {", + "static dmx.Result fromJson(", + "Map toJson() => {", + ] { + assert!( + generated.contains(expected), + "missing `{expected}`:\n{generated}" + ); + } + let class = generated + .split_once("final class Product {") + .and_then(|(_, rest)| rest.split_once("\n}\n")) + .map(|(body, _)| body.to_owned()) + .expect("the class body"); + assert!( + !class.contains("Json"), + "JSON reached the class body:\n{class}" + ); + let report = super::explain(&definition).expect("explain"); + assert!(report.contains("the canonical model template"), "{report}"); + }, + ); +} + +/// [typediagram.canonical]: a `.mustache` beside the definition takes the +/// canonical template's place rather than adding a second output. +#[test] +fn a_template_of_its_own_replaces_the_canonical_one() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + let _ = super::process(&definition, &roots(), &build()).expect("build"); + let generated = + fs::read_to_string(directory.join("lib").join("product.dart")).expect("output"); + assert!( + generated.contains("rendered through models/product.mustache"), + "{generated}" + ); + assert!(!generated.contains("operator =="), "{generated}"); + let report = super::explain(&definition).expect("explain"); + assert!(report.contains("1 generation group(s)"), "{report}"); + assert!(!report.contains("the canonical model template"), "{report}"); + }, + ); +} + +/// [typediagram.standalone]: a template whose name matches two definitions +/// binds to the longer one, and the shorter one never claims it. +#[test] +fn a_template_binds_to_the_most_specific_definition() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.wire.td", "type Wire {\n at: DateTime\n}\n"), + ("models/product.mustache", TEMPLATE), + ("models/product.wire.mustache", TEMPLATE), + ], + |directory| { + let models = directory.join("models"); + assert_eq!( + definition_of(&models.join("product.wire.mustache")), + Some(models.join("product.wire.td")) + ); + assert_eq!( + definition_of(&models.join("product.mustache")), + Some(models.join("product.td")) + ); + + for name in ["product.td", "product.wire.td"] { + assert_eq!( + super::process(&models.join(name), &roots(), &build()).expect(name), + Outcome::Updated + ); + } + let wire = fs::read_to_string(directory.join("lib").join("product_wire.dart")) + .expect("wire output"); + assert!(wire.contains("final class Wire {"), "{wire}"); + assert!(!wire.contains("final class Product {"), "{wire}"); + }, + ); +} + +/// [typediagram.standalone]: a removed template takes its output with it. +/// +/// The template removed here is the second one. Removing the *first* would +/// hand `lib/product.dart` back to the canonical template rather than collect +/// it [typediagram.canonical] — a definition always renders. +#[test] +fn a_removed_template_collects_its_output() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ("models/product.wire.mustache", TEMPLATE), + ], + |directory| { + let definition = directory.join("models").join("product.td"); + let _ = super::process(&definition, &roots(), &build()).expect("build"); + assert!(directory.join("lib").join("product_wire.dart").is_file()); + + fs::remove_file(directory.join("models").join("product.wire.mustache")) + .expect("remove template"); + assert_eq!( + super::process(&definition, &roots(), &build()).expect("second build"), + Outcome::Updated + ); + assert!( + !directory.join("lib").join("product_wire.dart").exists(), + "a dropped template means a dropped file" + ); + assert!( + directory.join("lib").join("product.dart").is_file(), + "the first template's output is untouched" + ); + }, + ); +} + +/// [typediagram.standalone]: an output that exists without dmx's marker is a +/// hand-written file and is never overwritten. +#[test] +fn a_hand_written_output_is_refused() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ("lib/product.dart", "// mine\n"), + ], + |directory| { + let error = format!( + "{:#}", + super::process( + &directory.join("models").join("product.td"), + &roots(), + &build() + ) + .expect_err("hand-written file") + ); + assert!(error.contains("DMX8006"), "{error}"); + assert_eq!( + fs::read_to_string(directory.join("lib").join("product.dart")).expect("untouched"), + "// mine\n" + ); + }, + ); +} + +/// [typediagram.diagnostics]: a fault in a definition file is reported at the +/// line the author's editor shows, with no fence anywhere in the sentence. +#[test] +fn a_definition_fault_is_reported_in_file_lines() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", "type A { x: Int }\ntype B { y }\n"), + ("models/product.mustache", TEMPLATE), + ], + |directory| { + let error = format!( + "{:#}", + super::process( + &directory.join("models").join("product.td"), + &roots(), + &build() + ) + .expect_err("bad definition") + ); + assert!(error.contains("DMX8004"), "{error}"); + assert!( + error.contains("in models/product.td is not valid"), + "{error}" + ); + assert!(error.contains("line 2, column 12"), "{error}"); + assert!(!error.contains("fence"), "{error}"); + }, + ); +} + +/// [typediagram.execution]: `dmx explain` names the files rather than fences, +/// and prints the exact context a template author will place. +#[test] +fn explain_names_the_files() { + in_workspace( + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/product.td", DEFINITION), + ("models/product.mustache", TEMPLATE), + ], + |directory| { + let report = + super::explain(&directory.join("models").join("product.td")).expect("explain"); + assert!( + report.contains("models/product.td: 1 generation group(s)"), + "{report}" + ); + assert!( + report.contains( + "-> lib/product.dart (target dart, template models/product.mustache, digest " + ), + "{report}" + ); + assert!(report.contains("group 1 — the definition file"), "{report}"); + assert!( + report.contains("\"template\": \"models/product.mustache\""), + "{report}" + ); + assert!(report.contains("\"dartType\": \"String\""), "{report}"); + assert!(!directory.join("lib").exists()); + }, + ); +} + +/// [typediagram.standalone]: the two predicates and the name convention, on +/// the spellings a real tree contains. +#[test] +fn the_conventions_are_what_they_say_they_are() { + for (path, definition, template) in [ + ("models/a.td", true, false), + ("models/a.TD", true, false), + ("models/a.mustache", false, true), + ("models/a.wire.mustache", false, true), + ("lib/a.dart", false, false), + ("docs/a.dmx.md", false, false), + ] { + assert_eq!(is_definition(Path::new(path)), definition, "{path}"); + assert_eq!(is_template(Path::new(path)), template, "{path}"); + } + for (template, name) in [ + ("models/product.mustache", "product"), + ("models/product.wire.mustache", "product_wire"), + ("models/Product.WireNames.mustache", "product_wire_names"), + ] { + assert_eq!( + output_name(Path::new(template)).expect(template), + name, + "{template}" + ); + } + // A Mustache file with no definition beside it is somebody else's — the + // catalogue's previews are exactly that — and binds to nothing. + assert_eq!(definition_of(Path::new("templates/model.mustache")), None); + assert_eq!(definition_of(Path::new("models/a.td")), None); +} diff --git a/src/dmx/src/typediagram/target.rs b/src/dmx/src/typediagram/target.rs index 29265a5..d2981a5 100644 --- a/src/dmx/src/typediagram/target.rs +++ b/src/dmx/src/typediagram/target.rs @@ -12,7 +12,7 @@ use anyhow::{Result, bail}; -use super::ast::TypeRef; +use super::ast::{Decl, TypeRef}; use super::model::{Model, Resolution}; /// Everything the pipeline needs to know about one output language. @@ -21,14 +21,25 @@ pub struct Target { pub name: &'static str, /// The extension every output it generates must carry, without the dot. pub extension: &'static str, + /// The directory this language keeps its sources in, relative to the + /// project root — where a standalone template's output lands when the + /// template names no path of its own [typediagram.standalone]. + pub source_root: &'static str, /// The file that marks a project root in this language, which is what an /// output path is resolved against [typediagram.output]. pub project_marker: &'static str, /// This language's text for one resolved reference. pub type_text: fn(&TypeRef, &Model) -> Result, + /// This language's text for a reference the JSON codec table has to work + /// in, or a refusal when the reference has no codec [typediagram.canonical]. + pub codec_text: fn(&TypeRef, &Model) -> Result, /// Refuses a finished file that does not parse, or that generated code is /// not allowed to contain [hygiene]. pub validate: fn(&str, &str) -> Result<()>, + /// The canonical model template for this language: what a definition + /// renders through when no template beside it says otherwise + /// [typediagram.canonical]. + pub canonical: &'static str, } /// A target is mostly function pointers, which carry nothing a diagnostic @@ -46,9 +57,12 @@ impl std::fmt::Debug for Target { const TARGETS: &[Target] = &[Target { name: "dart", extension: "dart", + source_root: "lib", project_marker: "pubspec.yaml", type_text: dart_type, + codec_text: dart_codec_type, validate: validate_dart, + canonical: include_str!("../../templates/diagram_model.mustache"), }]; /// Every file that marks a project root, for any target this build carries @@ -125,6 +139,65 @@ fn dart_type(reference: &TypeRef, model: &Model) -> Result { } } +/// The Dart text the JSON codec table works in [typediagram.canonical]. +/// +/// The same mapping as [`dart_type`] with two differences, both of them about +/// what a codec can actually be built for. +/// +/// An alias is followed to what it stands for. `alias Email = String` makes +/// `Email` a Dart typedef, and a typedef is not a name the codec table can look +/// up — it has to see the `String` underneath. A record or a union keeps its +/// own name, because that name is exactly what its codec is filed under. +/// +/// Everything else is refused rather than guessed. A type parameter has no +/// codec because the diagram never says what it will be; a generic declaration +/// has none for the same reason; an untagged union has none because nothing in +/// the payload says which case it is; and `Unit` is Dart's `void`, which is not +/// a value at all. A refusal here costs the declaration its JSON extension and +/// nothing else — the class, its value semantics, and `copyWith` are unaffected. +/// +/// # Errors +/// +/// Fails when the reference has no JSON codec, naming what it was. +fn dart_codec_type(reference: &TypeRef, model: &Model) -> Result { + let args = reference + .args + .iter() + .map(|arg| dart_codec_type(arg, model)) + .collect::>>()?; + let no_codec = |what: &str| { + bail!( + "DMX8009 [typediagram.canonical]: `{}` has no JSON codec: {what}", + reference.canonical() + ) + }; + match model.resolution(reference) { + Resolution::TypeParam => no_codec("a type parameter is not known until it is applied"), + Resolution::Declared(name) => match model.declaration(name) { + Some(Decl::Alias(alias)) if alias.generics.is_empty() => { + dart_codec_type(&alias.target, model) + } + Some(Decl::Record(record)) if record.generics.is_empty() => Ok(applied(name, &args)), + Some(Decl::Union(union)) if union.generics.is_empty() && !union.untagged => { + Ok(applied(name, &args)) + } + Some(Decl::Union(union)) if union.untagged => { + let _ = union; + no_codec("an untagged union carries nothing that says which case a payload is") + } + Some(Decl::Function(_)) => no_codec("a function is not data"), + // A generic alias, record, or union, or a name this model does not + // declare at all — which resolution already proved it does. + _ => no_codec("a generic declaration has no codec until it is applied"), + }, + Resolution::Primitive => match primitive(&reference.name) { + "void" => no_codec("`Unit` is Dart's `void`, which is not a value"), + text => Ok(text.to_owned()), + }, + Resolution::External => container(reference, &args), + } +} + /// Dart's name for one typeDiagram scalar. /// /// `Uuid` and `Decimal` have no native Dart type, so they carry the string diff --git a/src/dmx/src/types.rs b/src/dmx/src/types.rs index 062f917..c662a7d 100644 --- a/src/dmx/src/types.rs +++ b/src/dmx/src/types.rs @@ -35,6 +35,86 @@ use anyhow::{Result, bail}; +/// The suffix that names a declaration's JSON extension [typediagram.canonical]. +/// +/// `User` decodes and encodes through `UserJson`. One constant, so the name the +/// codec table calls and the name a template declares can never drift apart. +pub const JSON_EXTENSION: &str = "Json"; + +/// Where a declared type keeps its decoder [model.json-codec]. +/// +/// `Address.fromJson` and `AddressJson.fromJson` decode the same value; which +/// one exists depends on where the members were written. The inline backend +/// generates into the class body, so the decoder is a static on the class +/// itself [emission.inline-backend]. Whole-file generation writes the class as +/// a pure data declaration and puts its codec on the `Json` extension +/// beside it [typediagram.canonical], so the same call has to name the +/// extension. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Decoders { + /// `Address.fromJson` — the declaration carries its own decoder. + #[default] + OnTheType, + /// `AddressJson.fromJson` — the decoder lives on the type's JSON extension. + OnTheExtension, +} + +/// How generated code reaches the dmx runtime [model.json-codec]. +/// +/// Every expression the codec table builds names something the runtime exports +/// — `Ok`, `Err`, `DecodeError`, `dmxList` — and what those names resolve to +/// depends on how the file that holds them imported the runtime. The inline +/// backend generates into a file somebody else wrote, whose import it does not +/// control, so it spells the names bare. Whole-file generation writes the +/// import itself and prefixes it [typediagram.canonical]: a diagram is free to +/// declare a type called `Result`, and a local declaration hides an imported +/// name, so bare names would quietly resolve to the wrong type. +/// +/// One value threaded through the table, so no caller ever spells a runtime +/// name of its own. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct Runtime { + /// What the runtime import was bound to, trailing dot included, or `""` + /// when it was imported without a prefix. + pub prefix: &'static str, + /// Where a declared type keeps its decoder. + pub decoders: Decoders, +} + +impl Runtime { + /// The inline backend's: an unprefixed import somebody else wrote, and a + /// decoder on the class [emission.inline-backend]. + pub const IN_CLASS: Self = Self { + prefix: "", + decoders: Decoders::OnTheType, + }; + + /// Whole-file generation's: a prefixed import this generator writes, and a + /// decoder on the type's JSON extension [typediagram.canonical]. The + /// prefix is the package's own name, which is what the generated import + /// binds it to. + pub const PREFIXED: Self = Self { + prefix: "dmx.", + decoders: Decoders::OnTheExtension, + }; + + /// `name`, as generated code has to spell it to reach the runtime. + #[must_use] + pub fn name(self, name: &str) -> String { + format!("{}{name}", self.prefix) + } + + /// What generated code writes to reach `name`'s decoder. The declaration + /// and its extension are both local, so neither takes the prefix. + #[must_use] + pub fn callee(self, name: &str) -> String { + match self.decoders { + Decoders::OnTheType => name.to_owned(), + Decoders::OnTheExtension => format!("{name}{JSON_EXTENSION}"), + } + } +} + /// A parsed Dart type: `Map>?` and friends. #[derive(Debug, Clone)] pub struct DartType { @@ -214,32 +294,41 @@ pub fn json_shape(ty: &DartType) -> String { /// /// Fails when the type has the wrong number of type arguments, a map key that /// is not a `String`, or no codec at all. -pub fn decode_bound(ty: &DartType, value: &str, path: &str, indent: usize) -> Result { +pub fn decode_bound( + ty: &DartType, + value: &str, + path: &str, + indent: usize, + runtime: Runtime, +) -> Result { if let Some(expr) = pure_transform(ty, value) { - return Ok(format!("Ok({expr})")); + return Ok(format!("{}({expr})", runtime.name("Ok"))); } Ok(match ty.name.as_str() { // Explicit type arguments: without them the arms' least upper bound // widens to `Object` and the enclosing record stops being exhaustive. "DateTime" | "Uri" | "BigInt" => format!( - "switch ({}.tryParse({value})) {{ \ - final {0} parsed => Ok<{0}, DecodeError>(parsed), \ - null => Err<{0}, DecodeError>(DecodeError({path}, '{0}', {value})) }}", - ty.name + "switch ({name}.tryParse({value})) {{ \ + final {name} parsed => {ok}<{name}, {error}>(parsed), \ + null => {err}<{name}, {error}>({error}({path}, '{name}', {value})) }}", + name = ty.name, + ok = runtime.name("Ok"), + err = runtime.name("Err"), + error = runtime.name("DecodeError"), ), "List" | "Set" | "Iterable" => { let [elem] = ty.args.as_slice() else { bail!("DMX2102: `{}` needs exactly one type argument", ty.source); }; - let combinator = if ty.name == "Set" { + let combinator = runtime.name(if ty.name == "Set" { "dmxSet" } else { "dmxList" - }; + }); format!( "{combinator}<{}>({value}, {path}, {})", elem.source, - decoder(elem, indent)? + decoder(elem, indent, runtime)? ) } "Map" => { @@ -250,13 +339,16 @@ pub fn decode_bound(ty: &DartType, value: &str, path: &str, indent: usize) -> Re bail!("DMX2101: map key type `{}` is not String", k.source); } format!( - "dmxMap<{}>({value}, {path}, {})", + "{}<{}>({value}, {path}, {})", + runtime.name("dmxMap"), v.source, - decoder(v, indent)? + decoder(v, indent, runtime)? ) } // Name-level resolution: an unrecognized simple type decodes itself. - _ if ty.is_declared() => format!("{}.fromJson({value}, {path})", ty.name), + _ if ty.is_declared() => { + format!("{}.fromJson({value}, {path})", runtime.callee(&ty.name)) + } _ => bail!("DMX2102: cannot build a codec for `{}`", ty.source), }) } @@ -275,26 +367,29 @@ pub fn decode_bound(ty: &DartType, value: &str, path: &str, indent: usize) -> Re /// # Errors /// /// Fails when the element type has no codec. -pub fn decoder(ty: &DartType, indent: usize) -> Result { +pub fn decoder(ty: &DartType, indent: usize, runtime: Runtime) -> Result { if ty.nullable { let inner = ty.non_null(); return Ok(format!( - "(value, path) => dmxNullable<{}>(value, path, {})", + "(value, path) => {}<{}>(value, path, {})", + runtime.name("dmxNullable"), inner.source, - decoder(&inner, indent)? + decoder(&inner, indent, runtime)? )); } if ty.is_declared() { - return Ok(format!("{}.fromJson", ty.name)); + return Ok(format!("{}.fromJson", runtime.callee(&ty.name))); } let pad = " ".repeat(indent); Ok(format!( "(value, path) => switch (value) {{\n\ {pad} final {} value => {},\n\ - {pad} _ => Err(DecodeError(path, '{}', value)),\n\ + {pad} _ => {}({}(path, '{}', value)),\n\ {pad}}}", json_shape(ty), - decode_bound(ty, "value", "path", indent.saturating_add(2))?, + decode_bound(ty, "value", "path", indent.saturating_add(2), runtime)?, + runtime.name("Err"), + runtime.name("DecodeError"), ty.source )) } @@ -341,108 +436,5 @@ pub fn encode(ty: &DartType, expr: &str, depth: usize) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(ty: &str) -> DartType { - DartType::parse(ty).unwrap() - } - - /// [model.json-codec]: decoding is total — no `throw`, no `as`, no `!`. - #[test] - fn decoding_never_throws_or_casts() { - for ty in [ - "int", - "String", - "DateTime", - "List", - "Set", - "Address", - ] { - let out = decode_bound(&parse(ty), "value", "'$path.f'", 0).unwrap(); - for forbidden in ["throw", " as ", "!"] { - assert!( - !out.contains(forbidden), - "`{forbidden}` in decode of {ty}: {out}" - ); - } - } - for ty in ["String?", "List?", "Map?"] { - let out = decoder(&parse(ty), 0).unwrap(); - for forbidden in ["throw", " as ", "!"] { - assert!( - !out.contains(forbidden), - "`{forbidden}` in decoder for {ty}" - ); - } - } - } - - #[test] - fn decode_shapes() { - assert_eq!( - decode_bound(&parse("int"), "age", "'$path.age'", 0).unwrap(), - "Ok(age)" - ); - assert_eq!( - decode_bound(&parse("DateTime"), "at", "'$path.at'", 0).unwrap(), - "switch (DateTime.tryParse(at)) { \ - final DateTime parsed => Ok(parsed), \ - null => Err(DecodeError('$path.at', 'DateTime', at)) }" - ); - assert_eq!( - decode_bound(&parse("Address"), "a", "'$path.a'", 0).unwrap(), - "Address.fromJson(a, '$path.a')" - ); - assert_eq!( - decode_bound(&parse("List"), "tags", "'$path.tags'", 0).unwrap(), - "dmxList(tags, '$path.tags', (value, path) => switch (value) {\n\ - \x20 final String value => Ok(value),\n\ - \x20 _ => Err(DecodeError(path, 'String', value)),\n\ - })" - ); - // Nested nullability composes through dmxNullable. - assert!( - decoder(&parse("List"), 0) - .unwrap() - .contains("dmxNullable(value, path,") - ); - } - - /// A declared type is its own decoder, whatever kind of declaration it is. - #[test] - fn declared_types_decode_themselves() { - assert_eq!(decoder(&parse("Address"), 0).unwrap(), "Address.fromJson"); - assert_eq!(json_shape(&parse("Address")), "Object?"); - assert_eq!( - decode_bound(&parse("List"), "s", "'$path.s'", 0).unwrap(), - "dmxList(s, '$path.s', Status.fromJson)" - ); - } - - /// The JSON shape a map pattern must bind before decoding [model.json-codec]. - #[test] - fn json_shapes() { - assert_eq!(json_shape(&parse("DateTime")), "String"); - assert_eq!(json_shape(&parse("List
")), "List"); - assert_eq!( - json_shape(&parse("Map")), - "Map" - ); - assert_eq!(json_shape(&parse("double")), "num"); - assert_eq!(json_shape(&parse("String")), "String"); - } - - #[test] - fn encode_expressions() { - let encode_of = |ty: &str, e: &str| encode(&parse(ty), e, 0); - assert_eq!(encode_of("List", "tags"), "tags"); - assert_eq!(encode_of("Set", "ids"), "ids.toList()"); - assert_eq!(encode_of("Address?", "home"), "home?.toJson()"); - assert_eq!(encode_of("DateTime", "at"), "at.toIso8601String()"); - assert_eq!( - encode_of("List
", "stops"), - "stops.map((e0) => e0.toJson()).toList()" - ); - } -} +#[path = "types_tests.rs"] +mod tests; diff --git a/src/dmx/src/types_tests.rs b/src/dmx/src/types_tests.rs new file mode 100644 index 0000000..4e69ca5 --- /dev/null +++ b/src/dmx/src/types_tests.rs @@ -0,0 +1,145 @@ +//! The codec table, held to the Dart it must produce [model.json-codec]. +//! +//! A separate file only because types.rs is at the 500-line ceiling. + +use super::*; + +fn parse(ty: &str) -> DartType { + DartType::parse(ty).unwrap() +} + +/// [model.json-codec]: decoding is total — no `throw`, no `as`, no `!`. +#[test] +fn decoding_never_throws_or_casts() { + for ty in [ + "int", + "String", + "DateTime", + "List", + "Set", + "Address", + ] { + let out = decode_bound(&parse(ty), "value", "'$path.f'", 0, Runtime::IN_CLASS).unwrap(); + for forbidden in ["throw", " as ", "!"] { + assert!( + !out.contains(forbidden), + "`{forbidden}` in decode of {ty}: {out}" + ); + } + } + for ty in ["String?", "List?", "Map?"] { + let out = decoder(&parse(ty), 0, Runtime::IN_CLASS).unwrap(); + for forbidden in ["throw", " as ", "!"] { + assert!( + !out.contains(forbidden), + "`{forbidden}` in decoder for {ty}" + ); + } + } +} + +#[test] +fn decode_shapes() { + assert_eq!( + decode_bound(&parse("int"), "age", "'$path.age'", 0, Runtime::IN_CLASS).unwrap(), + "Ok(age)" + ); + assert_eq!( + decode_bound(&parse("DateTime"), "at", "'$path.at'", 0, Runtime::IN_CLASS).unwrap(), + "switch (DateTime.tryParse(at)) { \ + final DateTime parsed => Ok(parsed), \ + null => Err(DecodeError('$path.at', 'DateTime', at)) }" + ); + assert_eq!( + decode_bound(&parse("Address"), "a", "'$path.a'", 0, Runtime::IN_CLASS).unwrap(), + "Address.fromJson(a, '$path.a')" + ); + assert_eq!( + decode_bound( + &parse("List"), + "tags", + "'$path.tags'", + 0, + Runtime::IN_CLASS + ) + .unwrap(), + "dmxList(tags, '$path.tags', (value, path) => switch (value) {\n\ + \x20 final String value => Ok(value),\n\ + \x20 _ => Err(DecodeError(path, 'String', value)),\n\ + })" + ); + // Nested nullability composes through dmxNullable. + assert!( + decoder(&parse("List"), 0, Runtime::IN_CLASS) + .unwrap() + .contains("dmxNullable(value, path,") + ); +} + +/// A declared type is its own decoder, whatever kind of declaration it is. +#[test] +fn declared_types_decode_themselves() { + assert_eq!( + decoder(&parse("Address"), 0, Runtime::IN_CLASS).unwrap(), + "Address.fromJson" + ); + // [typediagram.canonical]: a class that keeps its codec on an extension + // is reached through the extension, everywhere the table names it. + assert_eq!( + decoder(&parse("Address"), 0, Runtime::PREFIXED).unwrap(), + "AddressJson.fromJson" + ); + assert_eq!( + decode_bound(&parse("Address"), "a", "'$path.a'", 0, Runtime::PREFIXED).unwrap(), + "AddressJson.fromJson(a, '$path.a')" + ); + assert_eq!( + decode_bound( + &parse("List
"), + "xs", + "'$path.xs'", + 0, + Runtime::PREFIXED + ) + .unwrap(), + "dmx.dmxList
(xs, '$path.xs', AddressJson.fromJson)" + ); + assert_eq!(json_shape(&parse("Address")), "Object?"); + assert_eq!( + decode_bound( + &parse("List"), + "s", + "'$path.s'", + 0, + Runtime::IN_CLASS + ) + .unwrap(), + "dmxList(s, '$path.s', Status.fromJson)" + ); +} + +/// The JSON shape a map pattern must bind before decoding [model.json-codec]. +#[test] +fn json_shapes() { + assert_eq!(json_shape(&parse("DateTime")), "String"); + assert_eq!(json_shape(&parse("List
")), "List"); + assert_eq!( + json_shape(&parse("Map")), + "Map" + ); + assert_eq!(json_shape(&parse("double")), "num"); + assert_eq!(json_shape(&parse("String")), "String"); +} + +#[test] +fn encode_expressions() { + let encode_of = |ty: &str, e: &str| encode(&parse(ty), e, 0); + assert_eq!(encode_of("List", "tags"), "tags"); + assert_eq!(encode_of("Set", "ids"), "ids.toList()"); + assert_eq!(encode_of("Address?", "home"), "home?.toJson()"); + assert_eq!(encode_of("DateTime", "at"), "at.toIso8601String()"); + assert_eq!( + encode_of("List
", "stops"), + "stops.map((e0) => e0.toJson()).toList()" + ); +} diff --git a/src/dmx/src/watch.rs b/src/dmx/src/watch.rs index 7e0790c..aa4f186 100644 --- a/src/dmx/src/watch.rs +++ b/src/dmx/src/watch.rs @@ -1,12 +1,15 @@ //! Debounced, incremental file watching for `dmx watch` [execution.modes]. +//! +//! What counts as a source, and where one is found, is [`crate::sources`]. +//! This module is the loop: register the scopes, batch the events a burst +//! produces, decide what each event stands for, and regenerate. use anyhow::{Context as _, Result, bail}; use lspkit::{EngineApi as _, Progress, RescanScope}; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsStr; use std::io::{self, Write as _}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::time::{Duration, Instant}; use tokio::runtime::Runtime; @@ -14,237 +17,11 @@ use tokio_util::sync::CancellationToken; use crate::Options; use crate::engine::{Engine, FileOutcome, Pass, Query}; +use crate::sources::{Scope, Sweep, collect_path}; /// How long a save burst is allowed to keep arriving before it is answered. const DEBOUNCE: Duration = Duration::from_millis(150); -#[derive(Clone, Debug, Eq, PartialEq)] -/// What one watch argument turned out to be. -enum Scope { - /// A directory, watched recursively. - Directory(PathBuf), - /// One Dart source, watched through its parent directory. - File(PathBuf), -} - -impl Scope { - /// Resolves one command-line path, refusing what cannot be watched. - fn from_path(path: &Path) -> Result { - let absolute = path - .canonicalize() - .with_context(|| format!("DMX1002 [cli]: cannot watch {}", path.display()))?; - match (absolute.is_dir(), absolute.is_file()) { - (true, false) => Ok(Self::Directory(absolute)), - (false, true) if Sweep::Sources.wants_named(&absolute) => Ok(Self::File(absolute)), - (false, true) => bail!( - "DMX1002 [cli]: watch target is not a Dart source or a Markdown document: {}", - path.display() - ), - _ => bail!( - "DMX1002 [cli]: watch target is not a file or directory: {}", - path.display() - ), - } - } - - /// Whether this scope's tree contains `path`, by name alone. - /// - /// Nothing here touches the filesystem: it answers where a path sits, and - /// the callers below add what it has to BE. - fn contains(&self, path: &Path) -> bool { - match self { - Self::File(file) => path == file, - Self::Directory(directory) => path - .strip_prefix(directory) - .is_ok_and(|relative| relative.components().all(visible_component)), - } - } - - /// Whether an event about this path is one this scope wants. - fn accepts(&self, path: &Path) -> bool { - let named = matches!(self, Self::File(file) if file == path); - // Recursive discovery takes `*.dmx.md`; a Markdown file named directly - // is watched whatever it is called [typediagram.documents]. - let wanted = if named { - Sweep::Sources.wants_named(path) - } else { - Sweep::Sources.wants(path) - }; - !path.is_symlink() && path.is_file() && wanted && self.contains(path) - } - - /// Whether `path` is a directory inside this scope's tree. - /// - /// A directory that appears inside a watched tree can already hold sources - /// whose own creation events never arrive. A recursive watch on Linux is - /// one inotify registration per directory, added when the directory is - /// seen, so anything written into a new directory before that registration - /// lands is never announced. macOS reports a whole tree from a single - /// registration and never shows this, which is why it has to be handled - /// here rather than left to whichever platform notices first - /// [execution.modes]. - fn covers_directory(&self, path: &Path) -> bool { - matches!(self, Self::Directory(_)) - && !path.is_symlink() - && path.is_dir() - && self.contains(path) - } - - /// The canonical path this scope covers, which is what the engine rescans. - fn root(&self) -> PathBuf { - match self { - Self::Directory(path) | Self::File(path) => path.clone(), - } - } - - /// The path to register with the watcher, and how deeply. - fn registration(&self) -> Result<(PathBuf, RecursiveMode)> { - match self { - Self::Directory(path) => Ok((path.clone(), RecursiveMode::Recursive)), - Self::File(path) => path - .parent() - .map(|parent| (parent.to_owned(), RecursiveMode::NonRecursive)) - .ok_or_else(|| { - anyhow::anyhow!("DMX1002 [cli]: {} has no parent directory", path.display()) - }), - } - } -} - -/// What one sweep of the tree is looking for. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Sweep { - /// Everything dmx generates from: Dart files and Markdown documents. - Sources, - /// Anything carrying an extension some generation target writes — the - /// candidates a generated output could be hiding among when a pass - /// collects what it no longer produces [typediagram.output]. - Outputs, -} - -impl Sweep { - /// Whether a file *recursive discovery* found is one this sweep wants. - fn wants(self, path: &Path) -> bool { - match self { - Self::Sources => is_dart_source(path) || crate::typediagram::is_document(path), - Self::Outputs => crate::typediagram::target::extensions() - .any(|extension| has_extension(path, extension)), - } - } - - /// Whether a file *named directly* is one this sweep wants. - /// - /// The two differ in exactly one place: recursive discovery takes - /// `*.dmx.md` and nothing else, and naming a Markdown file is how any - /// other one is generated from [typediagram.documents]. - fn wants_named(self, path: &Path) -> bool { - self.wants(path) || (self == Self::Sources && crate::typediagram::is_markdown(path)) - } -} - -/// Every source dmx generates from at or under `paths` — Dart files and -/// Markdown documents alike [surface.zero-config], [typediagram.documents]. -/// -/// # Errors -/// -/// Fails when a directory cannot be read. -pub fn collect_sources(paths: &[PathBuf]) -> Result> { - collect(paths, Sweep::Sources) -} - -/// Every file at or under `paths` that some generation target could have -/// written [typediagram.output]. -/// -/// # Errors -/// -/// Fails when a directory cannot be read. -pub fn collect_outputs(paths: &[PathBuf]) -> Result> { - collect(paths, Sweep::Outputs) -} - -/// Every file `sweep` accepts at or under `paths`, deduplicated and ordered. -fn collect(paths: &[PathBuf], sweep: Sweep) -> Result> { - paths - .iter() - .map(|path| collect_path(path, sweep, Sweep::wants_named)) - .collect::>>() - .map(|groups| { - groups - .into_iter() - .flatten() - .collect::>() - .into_iter() - .collect() - }) -} - -/// Every source at or under one path, with `accept` deciding what a *file* -/// there has to be — which differs between a path somebody named and one -/// discovery walked into. -fn collect_path( - path: &Path, - sweep: Sweep, - accept: fn(Sweep, &Path) -> bool, -) -> Result> { - match (path.is_symlink(), path.is_dir(), path.is_file()) { - (false, true, _) => collect_directory(path, sweep), - (false, false, true) if accept(sweep, path) => Ok(vec![path.to_owned()]), - // A symlink is never followed [surface.zero-config], and anything that - // is not a source is not dmx's to read. - _ => Ok(Vec::new()), - } -} - -/// Every source under one directory, hidden entries excluded. -fn collect_directory(directory: &Path, sweep: Sweep) -> Result> { - std::fs::read_dir(directory) - .with_context(|| { - format!( - "DMX1002 [surface.zero-config]: cannot read {}", - directory.display() - ) - })? - .filter_map(|entry| match entry { - Ok(entry) if visible_name(&entry.file_name()) => { - Some(collect_path(&entry.path(), sweep, Sweep::wants)) - } - Ok(_) => None, - Err(error) => Some(Err(anyhow::Error::from(error).context(format!( - "DMX1002 [surface.zero-config]: cannot inspect {}", - directory.display() - )))), - }) - .collect::>>() - .map(|groups| groups.into_iter().flatten().collect()) -} - -/// A Dart source dmx owns — not a `.g.dart` somebody else generates. -fn is_dart_source(path: &Path) -> bool { - has_extension(path, "dart") - && path - .file_name() - .is_some_and(|name| !name.to_string_lossy().ends_with(".g.dart")) -} - -/// Whether `path` carries `extension`, however it is cased. -fn has_extension(path: &Path, extension: &str) -> bool { - path.extension() - .is_some_and(|found| found.eq_ignore_ascii_case(extension)) -} - -/// Whether a directory entry is one the zero-config rules look at. -fn visible_name(name: &OsStr) -> bool { - !name.to_string_lossy().starts_with('.') -} - -/// The same rule, applied to one component of a relative path. -fn visible_component(component: Component<'_>) -> bool { - match component { - Component::Normal(name) => visible_name(name), - _ => true, - } -} - /// Runs the debounced, incremental watch execution mode [execution.modes], [cli]. /// /// # Errors @@ -484,6 +261,10 @@ fn resolve(path: &Path) -> Option { /// its first line names that seed [dartmacros.files]. Editing generated code /// re-runs what generates it, which is the only way an edit there can be /// answered — the generated file has no annotation of its own. +/// +/// A Mustache template stands for its definition and NOT for itself: nothing +/// is ever generated from a `.mustache` file, so a pass that named one would +/// report writing a file it did not write [typediagram.standalone]. fn claim(path: &Path, scopes: &[Scope]) -> Batch { let named = BTreeSet::from([path.to_owned()]); match ( @@ -491,10 +272,13 @@ fn claim(path: &Path, scopes: &[Scope]) -> Batch { scopes.iter().any(|scope| scope.covers_directory(path)), ) { (true, _) => Batch { - sources: named - .into_iter() - .chain(crate::emit::seed_of(path)) - .collect(), + sources: match crate::typediagram::definition_of(path) { + Some(definition) => BTreeSet::from([definition]), + None => named + .into_iter() + .chain(crate::emit::seed_of(path)) + .collect(), + }, ..Batch::default() }, (false, true) => Batch { @@ -514,7 +298,7 @@ fn claim(path: &Path, scopes: &[Scope]) -> Batch { /// A missing source against the watched directory it was in. fn vanished_in(path: &Path, scopes: &[Scope]) -> Option<(PathBuf, PathBuf)> { let parent = Sweep::Sources - .wants(path) + .watches(path) .then(|| path.parent()) .flatten()?; scopes @@ -561,7 +345,7 @@ fn announce(pass: &Pass) { } } -// A separate file only because watch.rs is near the 500-line ceiling. +// A separate file only because the loop and its tests together are long. #[cfg(test)] #[path = "watch_tests.rs"] mod tests; diff --git a/src/dmx/templates/diagram_model.mustache b/src/dmx/templates/diagram_model.mustache new file mode 100644 index 0000000..795f9a2 --- /dev/null +++ b/src/dmx/templates/diagram_model.mustache @@ -0,0 +1,185 @@ +{{! The canonical model template [typediagram.canonical]. }} +// Generated from {{source.path}}. Edit the definition, not this file. +{{#needsRuntime}} + +{{{runtimeImport}}} +{{/needsRuntime}} +{{#declarations}} +{{#isAlias}} + +/// `{{name}}` as the diagram declares it. +typedef {{name}}{{{genericDeclaration}}} = {{{dartType}}}; +{{/isAlias}} +{{#isFunction}} +{{#signatures}} + +/// Signature {{index}} of `{{name}}`, as the diagram declares it. +typedef {{pascalName}}{{#isOverload}}{{index}}{{/isOverload}}{{{genericDeclaration}}} = {{#isAsync}}Future<{{{returnType}}}>{{/isAsync}}{{^isAsync}}{{{returnType}}}{{/isAsync}} Function({{{parameterList}}}); +{{/signatures}} +{{/isFunction}} +{{#isUnion}} + +/// {{label}} — exactly one of the cases below{{#untagged}}, told apart by shape +/// rather than by a tag{{/untagged}}. +sealed class {{name}}{{{genericDeclaration}}} { + /// The shared constructor every case delegates to. + const {{name}}(); +} +{{#hasJson}} + +/// JSON for [{{name}}]. +extension {{jsonExtension}} on {{{classType}}} { + /// Decodes whichever case the payload's {{{discriminator}}} names. + static {{{decodeResult}}} fromJson(Object? json, [String path = '{{name}}']) => + switch (json) { + { + {{{discriminator}}}: final String type, + } => + switch (type) { +{{#variants}} + {{{tag}}} => {{jsonExtension}}.fromJson(json, path), +{{/variants}} + _ => {{{decodeFailure}}}, + }, + _ => {{{decodeFailure}}}, + }; + + /// This value as a JSON map, tagged with the case it is. + {{{jsonMap}}} toJson() => switch (this) { +{{#variants}} + final {{{classType}}} value => { + {{{discriminator}}}: {{{tag}}}, + ...value.toJson(), + }, +{{/variants}} + }; +} +{{/hasJson}} +{{/isUnion}} +{{#classes}} + +{{#owner}} +/// The `{{name}}` case of {{owner}}, as an immutable value. +{{/owner}} +{{^owner}} +/// {{label}} — an immutable value from the diagram. +{{/owner}} +final class {{{classType}}}{{{superClause}}} { + /// Every field, in the order the diagram declares them. + const {{className}}({{{constructorParameters}}}){{{superCall}}}; +{{#hasDiscriminant}} + + /// The discriminant the diagram gives this case. + static const int discriminant = {{discriminant}}; +{{/hasDiscriminant}} +{{#fields}} + + /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. + final {{{dartType}}} {{name}}; +{{/fields}} + + @override + bool operator ==(Object {{otherParam}}) => + identical(this, {{otherParam}}) || + ({{otherParam}} is {{{classType}}}{{#fields}}{{#isValue}} && + {{{equalsExpr}}}{{/isValue}}{{/fields}}); + +{{#hasValues}} + @override + int get hashCode => {{hashCombiner}}({{#useHashAll}}[{{/useHashAll}} + runtimeType, +{{#fields}} +{{#isValue}} + {{{hashExpr}}}, +{{/isValue}} +{{/fields}} + {{#useHashAll}}]{{/useHashAll}}); +{{/hasValues}} +{{^hasValues}} + @override + int get hashCode => runtimeType.hashCode; +{{/hasValues}} + + @override + String toString() => '{{className}}({{#fields}}{{#isValue}}{{{toStringExpr}}}{{^isLastValue}}, {{/isLastValue}}{{/isValue}}{{/fields}})'; +{{#canCopy}} + + /// A copy of this value with the named fields replaced. + {{{classType}}} copyWith({ +{{#fields}} + {{{copyParam}}}, +{{/fields}} + }) => + {{className}}( +{{#fields}} + {{{copyArg}}}, +{{/fields}} + ); +{{/canCopy}} +} +{{#hasJson}} + +/// JSON for [{{className}}]. +extension {{jsonExtension}} on {{{classType}}} { + /// Decodes a `{{className}}` from a JSON value, or says why it could not. + static {{{decodeResult}}} fromJson(Object? json, [String path = '{{className}}']) => + switch (json) { +{{#hasPattern}} + { +{{#fields}} +{{#inPattern}} + {{{jsonKey}}}: final {{{patternType}}} {{bind}}, +{{/inPattern}} +{{/fields}} + } => +{{/hasPattern}} +{{^hasPattern}} + {{{jsonShape}}} => +{{/hasPattern}} +{{#hasComplex}} + switch (( +{{#fields}} +{{#isComplex}} + {{{resultExpr}}}, +{{/isComplex}} +{{/fields}} + )) { + ( +{{#fields}} +{{#isComplex}} + {{{decodeOk}}}(value: final {{bind}}), +{{/isComplex}} +{{/fields}} + ) => + {{{decodeOk}}}({{className}}( +{{#fields}} + {{name}}: {{{ctorExpr}}}, +{{/fields}} + )), +{{#fields}} +{{#isComplex}} + {{{errPattern}}} => {{{decodeErr}}}, +{{/isComplex}} +{{/fields}} + }, +{{/hasComplex}} +{{^hasComplex}} + {{{decodeOk}}}({{className}}( +{{#fields}} + {{name}}: {{{ctorExpr}}}, +{{/fields}} + )), +{{/hasComplex}} + _ => {{{decodeFailure}}}, + }; + + /// This value as a JSON map. + {{{jsonMap}}} toJson() => { +{{#fields}} + {{{jsonKey}}}: {{{encodeExpr}}}, +{{/fields}} + }; +} +{{/hasJson}} +{{/classes}} +{{/declarations}} diff --git a/src/dmx/tests/support/mod.rs b/src/dmx/tests/support/mod.rs index 8a99dcd..34c51e2 100644 --- a/src/dmx/tests/support/mod.rs +++ b/src/dmx/tests/support/mod.rs @@ -1,6 +1,9 @@ //! A scratch directory that cleans itself up, shared by the test binaries that //! need real files on a real filesystem — which is all of them, because the -//! thing under test is a program that reads and writes Dart sources. +//! thing under test is a program that reads and writes source. +//! +//! The suites that also need a running `dmx watch` take `support/watch.rs` +//! beside this, so no binary compiles a process harness it never spawns. // [TEST-RULES] admits `expect` in a test: a fixture that cannot be built is a // broken test, and unwinding at the point of failure names it better than any diff --git a/src/dmx/tests/support/watch.rs b/src/dmx/tests/support/watch.rs new file mode 100644 index 0000000..76ce1d2 --- /dev/null +++ b/src/dmx/tests/support/watch.rs @@ -0,0 +1,269 @@ +//! `dmx watch` as a test drives it: the real binary, piped output, and the +//! lines it prints [execution.modes], [cli]. +//! +//! Shared rather than private to one suite because more than one thing is +//! watched — Dart sources, Markdown documents, and standalone typeDiagram +//! definitions — and a second copy of a process harness is a second set of +//! timeouts to get wrong. It is included with `#[path]` by the suites that +//! spawn a watcher, so no other binary compiles it. + +// [TEST-RULES] admits `expect` in a test, and every waiter here is only useful +// to some of its callers: a harness carries what a watcher can be asked, not +// what one suite happens to ask it. +#![cfg_attr(test, allow(dead_code, clippy::expect_used, clippy::panic))] + +use std::io::{self, BufRead, BufReader, Read}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +/// How long the watcher has to announce the paths it is watching. +pub const READY_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long one save has to produce the line it should produce. +pub const REGENERATION_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct WatchProcess { + /// The running `dmx watch`. + child: Child, + /// Lines the two reader threads have handed over. + logs: Receiver, + /// Every line either stream has produced so far, in arrival order. + pub observed: Vec, +} + +impl WatchProcess { + pub fn spawn_ready(path: &Path) -> io::Result { + let mut watcher = Self::spawn(path)?; + watcher.wait_until_ready(1)?; + Ok(watcher) + } + + pub fn spawn(path: &Path) -> io::Result { + Self::spawn_args(None, &[path.as_os_str()]) + } + + /// A watcher started *inside* `directory`, watching the relative paths + /// `args` names. + /// + /// A Markdown document's outputs are workspace-relative + /// [typediagram.output], so where the watcher runs is part of what it does + /// — which is the one thing `spawn` cannot express. + pub fn spawn_ready_in(directory: &Path, args: &[&str]) -> io::Result { + let owned: Vec<&std::ffi::OsStr> = args.iter().map(std::ffi::OsStr::new).collect(); + let mut watcher = Self::spawn_args(Some(directory), &owned)?; + watcher.wait_until_ready(args.len())?; + Ok(watcher) + } + + pub fn spawn_args(directory: Option<&Path>, args: &[&std::ffi::OsStr]) -> io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_dmx")); + let _ = command.arg("watch").args(args); + if let Some(directory) = directory { + let _ = command.current_dir(directory); + } + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("watch stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("watch stderr was not piped"))?; + let (sender, logs) = mpsc::channel(); + spawn_line_reader("stdout", stdout, sender.clone()); + spawn_line_reader("stderr", stderr, sender); + Ok(Self { + child, + logs, + observed: Vec::new(), + }) + } + + pub fn wait_until_ready(&mut self, root_count: usize) -> io::Result<()> { + let expected = format!("stdout: dmx: watching {root_count} path(s)"); + self.wait_for_log(READY_TIMEOUT, |line| line == expected, &expected) + } + + /// Waits for a line on `stream` carrying `needle`. + /// + /// The exact-match waiters below spell out a whole line because a Dart + /// source's write line is one path and nothing else. A document is named + /// by both its write line and its diagnostics, so what identifies which + /// one arrived is the stream it arrived on. + pub fn wait_for_line_on(&mut self, stream: &str, needle: &str) -> io::Result<()> { + let prefix = stream.to_owned(); + let expected = format!("{stream}…{needle}"); + self.wait_for_log( + REGENERATION_TIMEOUT, + move |line| line.starts_with(&prefix) && line.contains(needle), + &expected, + ) + } + + pub fn wait_for_write(&mut self, path: &Path) -> io::Result<()> { + let expected = write_log(path)?; + self.wait_for_log(REGENERATION_TIMEOUT, |line| line == expected, &expected) + } + + pub fn wait_for_error(&mut self, path: &Path, diagnostic: &str) -> io::Result<()> { + let expected = error_log(path, diagnostic)?; + self.wait_for_log( + REGENERATION_TIMEOUT, + |line| line.starts_with(&expected), + &expected, + ) + } + + pub fn wait_for_log( + &mut self, + timeout: Duration, + matches: impl Fn(&str) -> bool, + expected: &str, + ) -> io::Result<()> { + let baseline = self.observed.len(); + self.wait_for_observed(timeout, expected, |lines| { + lines[baseline..].iter().any(|line| matches(line)) + }) + } + + pub fn wait_for_error_and_write( + &mut self, + invalid_path: &Path, + diagnostic: &str, + valid_path: &Path, + ) -> io::Result<()> { + let error = error_log(invalid_path, diagnostic)?; + let write = write_log(valid_path)?; + let expected = format!("`{error}…` and `{write}`"); + let baseline = self.observed.len(); + self.wait_for_observed(REGENERATION_TIMEOUT, &expected, |lines| { + lines[baseline..] + .iter() + .any(|line| line.starts_with(&error)) + && lines[baseline..].iter().any(|line| line == &write) + }) + } + + pub fn wait_for_observed( + &mut self, + timeout: Duration, + expected: &str, + complete: impl Fn(&[String]) -> bool, + ) -> io::Result<()> { + let deadline = Instant::now() + timeout; + loop { + if complete(&self.observed) { + return Ok(()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + match self.logs.recv_timeout(remaining) { + Ok(line) => self.observed.push(line), + Err(RecvTimeoutError::Timeout) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "watcher never emitted `{expected}`; output:\n{}", + self.output() + ), + )); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + format!( + "watcher exited before emitting `{expected}`; output:\n{}", + self.output() + ), + )); + } + } + } + } + + pub fn observe_for(&mut self, duration: Duration) { + let deadline = Instant::now() + duration; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match self.logs.recv_timeout(remaining) { + Ok(line) => self.observed.push(line), + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break, + } + } + } + + pub fn writes(&self) -> Vec<&str> { + self.observed + .iter() + .map(String::as_str) + .filter(|line| line.starts_with("stdout: wrote: ")) + .collect() + } + + pub fn output(&self) -> String { + self.observed.join("\n") + } + + pub fn is_running(&mut self) -> io::Result { + self.child.try_wait().map(|status| status.is_none()) + } +} + +impl Drop for WatchProcess { + fn drop(&mut self) { + // Still running, so end it; already gone or unknowable, so nothing to + // do — a test fixture cannot report a failure from `drop` anyway. + if let Ok(None) = self.child.try_wait() { + let _ = self.child.kill(); + } + let _ = self.child.wait(); + } +} + +/// The exact line the watcher prints when it writes `path`. +/// +/// # Errors +/// +/// Fails when `path` cannot be canonicalized, which means it is not there. +pub fn write_log(path: &Path) -> io::Result { + Ok(format!("stdout: wrote: {}", path.canonicalize()?.display())) +} + +/// The exact prefix the watcher prints when `path` is refused. +/// +/// # Errors +/// +/// Fails when `path` cannot be canonicalized, which means it is not there. +pub fn error_log(path: &Path, diagnostic: &str) -> io::Result { + Ok(format!( + "stderr: error: {}: {diagnostic}", + path.canonicalize()?.display() + )) +} + +fn spawn_line_reader( + stream_name: &'static str, + stream: impl Read + Send + 'static, + sender: Sender, +) { + drop(thread::spawn(move || { + for result in BufReader::new(stream).lines() { + let line = match result { + Ok(line) => line, + Err(error) => format!("could not read {stream_name}: {error}"), + }; + if sender.send(format!("{stream_name}: {line}")).is_err() { + break; + } + } + })); +} diff --git a/src/dmx/tests/support/workspace.rs b/src/dmx/tests/support/workspace.rs new file mode 100644 index 0000000..897d88a --- /dev/null +++ b/src/dmx/tests/support/workspace.rs @@ -0,0 +1,113 @@ +//! A scratch package the real `dmx` binary is driven over [typediagram]. +//! +//! Shared rather than private to one suite because more than one front end +//! generates into a package — a Markdown document and a standalone `.td` +//! definition — and both are driven the same way: write files into a scratch +//! directory, run `dmx build` from inside it, and read what came back. What +//! differs between them is the seed files and the arguments `build` takes, +//! which is what [`Workspace::create`] takes. +//! +//! It is included with `#[path]` by the suites that need one, so no other +//! binary compiles it. + +// [TEST-RULES] admits `expect` in a test, and a fixture carries what a +// workspace can be asked, not what one suite happens to ask it. +#![cfg_attr(test, allow(dead_code, clippy::expect_used, clippy::panic))] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use crate::support::TempDirectory; + +pub struct Workspace { + /// The scratch directory, removed when the test ends. + pub directory: TempDirectory, + /// The arguments [`Workspace::build`] runs `dmx` with. + build: Vec, +} + +impl Workspace { + /// A package with `lib/` in it, whatever `files` names, and `build` + /// remembered as what a build of it runs. + pub fn create(prefix: &str, build: &[&str], files: &[(&str, &str)]) -> Self { + let directory = TempDirectory::create(prefix).expect("scratch directory"); + fs::create_dir_all(directory.at("lib")).expect("lib directory"); + let workspace = Self { + directory, + build: build + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + }; + for (name, contents) in files { + workspace.write(name, contents); + } + workspace + } + + /// The workspace root. + pub fn root(&self) -> &Path { + &self.directory.path + } + + /// One path inside it. + pub fn path(&self, relative: &str) -> PathBuf { + self.directory.at(relative) + } + + /// The contents of one file inside it. + pub fn read(&self, relative: &str) -> String { + fs::read_to_string(self.path(relative)) + .unwrap_or_else(|error| panic!("cannot read {relative}: {error}")) + } + + /// Whether one path inside it exists. + pub fn exists(&self, relative: &str) -> bool { + self.path(relative).exists() + } + + /// Writes one file inside it, creating the directories it needs. + pub fn write(&self, relative: &str, contents: &str) { + let _ = self.directory.write(relative, contents).expect(relative); + } + + /// Runs `dmx` from the workspace root, as a shell in it would. + pub fn dmx(&self, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_dmx")) + .args(args) + .current_dir(self.root()) + .output() + .expect("run dmx") + } + + /// A build over this workspace, which must succeed. Its stdout is what a + /// pass reports. + pub fn build(&self) -> String { + let output = self.run(); + assert!( + output.status.success(), + "dmx build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + + /// The refusal a build produced, which it must have produced. + pub fn build_failure(&self) -> String { + let output = self.run(); + assert!( + !output.status.success(), + "dmx build succeeded when it should have refused\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + String::from_utf8_lossy(&output.stderr).into_owned() + } + + /// This workspace's build, however it ended. + fn run(&self) -> Output { + let arguments: Vec<&str> = self.build.iter().map(String::as_str).collect(); + self.dmx(&arguments) + } +} diff --git a/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart b/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart deleted file mode 100644 index c84f298..0000000 --- a/src/dmx/tests/typediagram/golden/lib/aliases-and-functions.dart +++ /dev/null @@ -1,55 +0,0 @@ -// dmx: generated from docs/aliases-and-functions.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition fc1a006cd8bfa5cd, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. - -// Generated from docs/aliases-and-functions.dmx.md. Edit the diagram, not this file. - -/// `Email` as the diagram declares it. -typedef Email = String; - -/// `UserId` as the diagram declares it. -typedef UserId = String; - -/// `Callback` as the diagram declares it. -typedef Callback = String?; - -/// `Index` as the diagram declares it. -typedef Index = Map>; - -/// Signature 0 of `fetch`, as the diagram declares it. -typedef Fetch = Response Function(Request request, T? fallback); - -/// Signature 0 of `store`, as the diagram declares it. -typedef Store = Future Function(Request item); - -/// Signature 0 of `read`, as the diagram declares it. -typedef Read0 = List Function(String path); - -/// Signature 1 of `read`, as the diagram declares it. -typedef Read1 = Future> Function(String path, double timeout); - -/// Signature 0 of `drain`, as the diagram declares it. -typedef Drain0 = void Function(); - -/// Signature 1 of `drain`, as the diagram declares it. -typedef Drain1 = Future Function(int limit); - -/// Signature 0 of `nothing`, as the diagram declares it. -typedef Nothing = void Function(); - -/// Request — a record from the diagram. -final class Request { - /// Every field, in the order the diagram declares them. - const Request({required this.url}); - - /// The `url` field, declared as `String`. - final String url; -} - -/// Response — a record from the diagram. -final class Response { - /// Every field, in the order the diagram declares them. - const Response({required this.status}); - - /// The `status` field, declared as `Int`. - final int status; -} diff --git a/src/dmx/tests/typediagram/golden/lib/aliases_and_functions.dart b/src/dmx/tests/typediagram/golden/lib/aliases_and_functions.dart new file mode 100644 index 0000000..862c22e --- /dev/null +++ b/src/dmx/tests/typediagram/golden/lib/aliases_and_functions.dart @@ -0,0 +1,143 @@ +// dmx: generated from models/aliases-and-functions.td — do not edit. +// dmx: rendered through the canonical model template, definition fc1a006cd8bfa5cd, template 5fba7c04728545cb, context v1, dmx 0.0.0. + +// Generated from models/aliases-and-functions.td. Edit the definition, not this file. + +import 'package:dmx/dmx.dart' as dmx; + +/// `Email` as the diagram declares it. +typedef Email = String; + +/// `UserId` as the diagram declares it. +typedef UserId = String; + +/// `Callback` as the diagram declares it. +typedef Callback = String?; + +/// `Index` as the diagram declares it. +typedef Index = Map>; + +/// Signature 0 of `fetch`, as the diagram declares it. +typedef Fetch = Response Function(Request request, T? fallback); + +/// Signature 0 of `store`, as the diagram declares it. +typedef Store = Future Function(Request item); + +/// Signature 0 of `read`, as the diagram declares it. +typedef Read0 = List Function(String path); + +/// Signature 1 of `read`, as the diagram declares it. +typedef Read1 = Future> Function(String path, double timeout); + +/// Signature 0 of `drain`, as the diagram declares it. +typedef Drain0 = void Function(); + +/// Signature 1 of `drain`, as the diagram declares it. +typedef Drain1 = Future Function(int limit); + +/// Signature 0 of `nothing`, as the diagram declares it. +typedef Nothing = void Function(); + +/// Request — an immutable value from the diagram. +final class Request { + /// Every field, in the order the diagram declares them. + const Request({required this.url}); + + /// The `url` field, declared as `String`. + final String url; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Request && + other.url == url); + + @override + int get hashCode => Object.hash( + runtimeType, + url, + ); + + @override + String toString() => 'Request(url: $url)'; + + /// A copy of this value with the named fields replaced. + Request copyWith({ + String? url, + }) => + Request( + url: url ?? this.url, + ); +} + +/// JSON for [Request]. +extension RequestJson on Request { + /// Decodes a `Request` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Request']) => + switch (json) { + { + 'url': final String url, + } => + dmx.Ok(Request( + url: url, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Request', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'url': url, + }; +} + +/// Response — an immutable value from the diagram. +final class Response { + /// Every field, in the order the diagram declares them. + const Response({required this.status}); + + /// The `status` field, declared as `Int`. + final int status; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Response && + other.status == status); + + @override + int get hashCode => Object.hash( + runtimeType, + status, + ); + + @override + String toString() => 'Response(status: $status)'; + + /// A copy of this value with the named fields replaced. + Response copyWith({ + int? status, + }) => + Response( + status: status ?? this.status, + ); +} + +/// JSON for [Response]. +extension ResponseJson on Response { + /// Decodes a `Response` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Response']) => + switch (json) { + { + 'status': final int status, + } => + dmx.Ok(Response( + status: status, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Response', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'status': status, + }; +} diff --git a/src/dmx/tests/typediagram/golden/lib/records.dart b/src/dmx/tests/typediagram/golden/lib/records.dart index d56cd72..18f2685 100644 --- a/src/dmx/tests/typediagram/golden/lib/records.dart +++ b/src/dmx/tests/typediagram/golden/lib/records.dart @@ -1,9 +1,11 @@ -// dmx: generated from docs/records.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition 564eca654d0cbefc, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. +// dmx: generated from models/records.td — do not edit. +// dmx: rendered through the canonical model template, definition 564eca654d0cbefc, template 5fba7c04728545cb, context v1, dmx 0.0.0. -// Generated from docs/records.dmx.md. Edit the diagram, not this file. +// Generated from models/records.td. Edit the definition, not this file. -/// User — a record from the diagram. +import 'package:dmx/dmx.dart' as dmx; + +/// User — an immutable value from the diagram. final class User { /// Every field, in the order the diagram declares them. const User({required this.id, required this.name, this.email, required this.roles, required this.address}); @@ -22,9 +24,93 @@ final class User { /// The `address` field, declared as `Address`. final Address address; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is User && + other.id == id && + other.name == name && + other.email == email && + dmx.dmxDeepEquals(other.roles, roles) && + other.address == address); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + name, + email, + dmx.dmxDeepHash(roles), + address, + ); + + @override + String toString() => 'User(id: $id, name: $name, email: $email, roles: $roles, address: $address)'; + + /// A copy of this value with the named fields replaced. + User copyWith({ + String? id, + String? name, + dmx.DmxPatch email = const dmx.DmxKeep(), + List? roles, + Address? address, + }) => + User( + id: id ?? this.id, + name: name ?? this.name, + email: switch (email) { dmx.DmxKeep() => this.email, dmx.DmxTo(value: final value) => value }, + roles: roles ?? this.roles, + address: address ?? this.address, + ); } -/// Pair — a record from the diagram. +/// JSON for [User]. +extension UserJson on User { + /// Decodes a `User` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'User']) => + switch (json) { + { + 'id': final String id, + 'name': final String name, + 'roles': final List roles, + 'address': final Object? address, + } => + switch (( + dmx.dmxNullable(dmx.dmxKey(json, 'email'), '$path.email', EmailJson.fromJson), + dmx.dmxList(roles, '$path.roles', RoleJson.fromJson), + AddressJson.fromJson(address, '$path.address'), + )) { + ( + dmx.Ok(value: final email), + dmx.Ok(value: final roles), + dmx.Ok(value: final address), + ) => + dmx.Ok(User( + id: id, + name: name, + email: email, + roles: roles, + address: address, + )), + (dmx.Err(error: final e), _, _) => dmx.Err(e), + (_, dmx.Err(error: final e), _) => dmx.Err(e), + (_, _, dmx.Err(error: final e)) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'User', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'id': id, + 'name': name, + 'email': email?.toJson(), + 'roles': roles.map((e0) => e0.toJson()).toList(), + 'address': address.toJson(), + }; +} + +/// Pair — an immutable value from the diagram. final class Pair { /// Every field, in the order the diagram declares them. const Pair({required this.first, required this.second}); @@ -34,24 +120,101 @@ final class Pair { /// The `second` field, declared as `B`. final B second; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Pair && + other.first == first && + other.second == second); + + @override + int get hashCode => Object.hash( + runtimeType, + first, + second, + ); + + @override + String toString() => 'Pair(first: $first, second: $second)'; + + /// A copy of this value with the named fields replaced. + Pair copyWith({ + A? first, + B? second, + }) => + Pair( + first: first ?? this.first, + second: second ?? this.second, + ); } -/// Box — a record from the diagram. +/// Box — an immutable value from the diagram. final class Box { /// Every field, in the order the diagram declares them. const Box({required this.value}); /// The `value` field, declared as `T`. final T value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Box && + other.value == value); + + @override + int get hashCode => Object.hash( + runtimeType, + value, + ); + + @override + String toString() => 'Box(value: $value)'; + + /// A copy of this value with the named fields replaced. + Box copyWith({ + T? value, + }) => + Box( + value: value ?? this.value, + ); } -/// Empty — a record from the diagram. +/// Empty — an immutable value from the diagram. final class Empty { /// Every field, in the order the diagram declares them. const Empty(); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Empty); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'Empty()'; } -/// Separators — a record from the diagram. +/// JSON for [Empty]. +extension EmptyJson on Empty { + /// Decodes a `Empty` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Empty']) => + switch (json) { + Map() => + dmx.Ok(Empty( + )), + _ => dmx.Err(dmx.DecodeError(path, 'Empty', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; +} + +/// Separators — an immutable value from the diagram. final class Separators { /// Every field, in the order the diagram declares them. const Separators({required this.a, required this.b, required this.c}); @@ -64,31 +227,217 @@ final class Separators { /// The `c` field, declared as `Int`. final int c; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Separators && + other.a == a && + other.b == b && + other.c == c); + + @override + int get hashCode => Object.hash( + runtimeType, + a, + b, + c, + ); + + @override + String toString() => 'Separators(a: $a, b: $b, c: $c)'; + + /// A copy of this value with the named fields replaced. + Separators copyWith({ + int? a, + int? b, + int? c, + }) => + Separators( + a: a ?? this.a, + b: b ?? this.b, + c: c ?? this.c, + ); +} + +/// JSON for [Separators]. +extension SeparatorsJson on Separators { + /// Decodes a `Separators` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Separators']) => + switch (json) { + { + 'a': final int a, + 'b': final int b, + 'c': final int c, + } => + dmx.Ok(Separators( + a: a, + b: b, + c: c, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Separators', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'a': a, + 'b': b, + 'c': c, + }; } -/// Email — a record from the diagram. +/// Email — an immutable value from the diagram. final class Email { /// Every field, in the order the diagram declares them. const Email({required this.text}); /// The `text` field, declared as `String`. final String text; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Email && + other.text == text); + + @override + int get hashCode => Object.hash( + runtimeType, + text, + ); + + @override + String toString() => 'Email(text: $text)'; + + /// A copy of this value with the named fields replaced. + Email copyWith({ + String? text, + }) => + Email( + text: text ?? this.text, + ); +} + +/// JSON for [Email]. +extension EmailJson on Email { + /// Decodes a `Email` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Email']) => + switch (json) { + { + 'text': final String text, + } => + dmx.Ok(Email( + text: text, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Email', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'text': text, + }; } -/// Role — a record from the diagram. +/// Role — an immutable value from the diagram. final class Role { /// Every field, in the order the diagram declares them. const Role({required this.name}); /// The `name` field, declared as `String`. final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Role && + other.name == name); + + @override + int get hashCode => Object.hash( + runtimeType, + name, + ); + + @override + String toString() => 'Role(name: $name)'; + + /// A copy of this value with the named fields replaced. + Role copyWith({ + String? name, + }) => + Role( + name: name ?? this.name, + ); +} + +/// JSON for [Role]. +extension RoleJson on Role { + /// Decodes a `Role` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Role']) => + switch (json) { + { + 'name': final String name, + } => + dmx.Ok(Role( + name: name, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Role', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'name': name, + }; } -/// Address — a record from the diagram. +/// Address — an immutable value from the diagram. final class Address { /// Every field, in the order the diagram declares them. const Address({required this.line}); /// The `line` field, declared as `String`. final String line; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Address && + other.line == line); + + @override + int get hashCode => Object.hash( + runtimeType, + line, + ); + + @override + String toString() => 'Address(line: $line)'; + + /// A copy of this value with the named fields replaced. + Address copyWith({ + String? line, + }) => + Address( + line: line ?? this.line, + ); +} + +/// JSON for [Address]. +extension AddressJson on Address { + /// Decodes a `Address` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Address']) => + switch (json) { + { + 'line': final String line, + } => + dmx.Ok(Address( + line: line, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Address', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'line': line, + }; } diff --git a/src/dmx/tests/typediagram/golden/lib/scalars.dart b/src/dmx/tests/typediagram/golden/lib/scalars.dart index 099acf5..3f6cfc6 100644 --- a/src/dmx/tests/typediagram/golden/lib/scalars.dart +++ b/src/dmx/tests/typediagram/golden/lib/scalars.dart @@ -1,9 +1,11 @@ -// dmx: generated from docs/scalars.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition 7db716b44e16128d, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. +// dmx: generated from models/scalars.td — do not edit. +// dmx: rendered through the canonical model template, definition 7db716b44e16128d, template 5fba7c04728545cb, context v1, dmx 0.0.0. -// Generated from docs/scalars.dmx.md. Edit the diagram, not this file. +// Generated from models/scalars.td. Edit the definition, not this file. -/// Scalars — a record from the diagram. +import 'package:dmx/dmx.dart' as dmx; + +/// Scalars — an immutable value from the diagram. final class Scalars { /// Every field, in the order the diagram declares them. const Scalars({required this.flag, required this.count, required this.ratio, required this.text, required this.blob, required this.nothing, required this.at, required this.id, required this.amount, required this.tags, required this.index, this.maybe, required this.anything, this.deep}); @@ -49,6 +51,45 @@ final class Scalars { /// The `deep` field, declared as `Option>>>`. final Map>? deep; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Scalars && + other.flag == flag && + other.count == count && + other.ratio == ratio && + other.text == text && + dmx.dmxDeepEquals(other.blob, blob) && + other.at == at && + other.id == id && + other.amount == amount && + dmx.dmxDeepEquals(other.tags, tags) && + dmx.dmxDeepEquals(other.index, index) && + other.maybe == maybe && + other.anything == anything && + dmx.dmxDeepEquals(other.deep, deep)); + + @override + int get hashCode => Object.hash( + runtimeType, + flag, + count, + ratio, + text, + dmx.dmxDeepHash(blob), + at, + id, + amount, + dmx.dmxDeepHash(tags), + dmx.dmxDeepHash(index), + maybe, + anything, + dmx.dmxDeepHash(deep), + ); + + @override + String toString() => 'Scalars(flag: $flag, count: $count, ratio: $ratio, text: $text, blob: $blob, at: $at, id: $id, amount: $amount, tags: $tags, index: $index, maybe: $maybe, anything: $anything, deep: $deep)'; } /// `Uuid` as the diagram declares it. diff --git a/src/dmx/tests/typediagram/golden/lib/targeting.dart b/src/dmx/tests/typediagram/golden/lib/targeting.dart index 5b3c16c..aa30024 100644 --- a/src/dmx/tests/typediagram/golden/lib/targeting.dart +++ b/src/dmx/tests/typediagram/golden/lib/targeting.dart @@ -1,24 +1,112 @@ -// dmx: generated from docs/targeting.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition 6070c6f7e26a9e98, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. +// dmx: generated from models/targeting.td — do not edit. +// dmx: rendered through the canonical model template, definition 6070c6f7e26a9e98, template 5fba7c04728545cb, context v1, dmx 0.0.0. -// Generated from docs/targeting.dmx.md. Edit the diagram, not this file. +// Generated from models/targeting.td. Edit the definition, not this file. -/// Only dart and rust — a record from the diagram. +import 'package:dmx/dmx.dart' as dmx; + +/// Only dart and rust — an immutable value from the diagram. final class OnlyDartAndRust { /// Every field, in the order the diagram declares them. const OnlyDartAndRust({required this.a}); /// The `a` field, declared as `Int`. final int a; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OnlyDartAndRust && + other.a == a); + + @override + int get hashCode => Object.hash( + runtimeType, + a, + ); + + @override + String toString() => 'OnlyDartAndRust(a: $a)'; + + /// A copy of this value with the named fields replaced. + OnlyDartAndRust copyWith({ + int? a, + }) => + OnlyDartAndRust( + a: a ?? this.a, + ); } -/// Not go — a record from the diagram. +/// JSON for [OnlyDartAndRust]. +extension OnlyDartAndRustJson on OnlyDartAndRust { + /// Decodes a `OnlyDartAndRust` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'OnlyDartAndRust']) => + switch (json) { + { + 'a': final int a, + } => + dmx.Ok(OnlyDartAndRust( + a: a, + )), + _ => dmx.Err(dmx.DecodeError(path, 'OnlyDartAndRust', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'a': a, + }; +} + +/// Not go — an immutable value from the diagram. final class NotGo { /// Every field, in the order the diagram declares them. const NotGo({required this.b}); /// The `b` field, declared as `String`. final String b; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NotGo && + other.b == b); + + @override + int get hashCode => Object.hash( + runtimeType, + b, + ); + + @override + String toString() => 'NotGo(b: $b)'; + + /// A copy of this value with the named fields replaced. + NotGo copyWith({ + String? b, + }) => + NotGo( + b: b ?? this.b, + ); +} + +/// JSON for [NotGo]. +extension NotGoJson on NotGo { + /// Decodes a `NotGo` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'NotGo']) => + switch (json) { + { + 'b': final String b, + } => + dmx.Ok(NotGo( + b: b, + )), + _ => dmx.Err(dmx.DecodeError(path, 'NotGo', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'b': b, + }; } /// Both — exactly one of the cases below. @@ -27,27 +115,118 @@ sealed class Both { const Both(); } -/// The `One` case of Both. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class BothOne extends Both { - /// This case's payload, in diagram order. - const BothOne() : super(); +/// JSON for [Both]. +extension BothJson on Both { + /// Decodes whichever case the payload's 'type' names. + static dmx.Result fromJson(Object? json, [String path = 'Both']) => + switch (json) { + { + 'type': final String type, + } => + switch (type) { + 'one' => OneJson.fromJson(json, path), + 'two' => TwoJson.fromJson(json, path), + _ => dmx.Err(dmx.DecodeError(path, 'Both', json)), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Both', json)), + }; + + /// This value as a JSON map, tagged with the case it is. + Map toJson() => switch (this) { + final One value => { + 'type': 'one', + ...value.toJson(), + }, + final Two value => { + 'type': 'two', + ...value.toJson(), + }, + }; } -/// The `Two` case of Both. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class BothTwo extends Both { - /// This case's payload, in diagram order. - const BothTwo({required this.x}) : super(); +/// The `One` case of Both, as an immutable value. +final class One extends Both { + /// Every field, in the order the diagram declares them. + const One() : super(); - /// The `x` member, declared as `Int`. + @override + bool operator ==(Object other) => + identical(this, other) || + (other is One); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'One()'; +} + +/// JSON for [One]. +extension OneJson on One { + /// Decodes a `One` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'One']) => + switch (json) { + Map() => + dmx.Ok(One( + )), + _ => dmx.Err(dmx.DecodeError(path, 'One', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; +} + +/// The `Two` case of Both, as an immutable value. +final class Two extends Both { + /// Every field, in the order the diagram declares them. + const Two({required this.x}) : super(); + + /// The `x` field, declared as `Int`. final int x; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Two && + other.x == x); + + @override + int get hashCode => Object.hash( + runtimeType, + x, + ); + + @override + String toString() => 'Two(x: $x)'; + + /// A copy of this value with the named fields replaced. + Two copyWith({ + int? x, + }) => + Two( + x: x ?? this.x, + ); +} + +/// JSON for [Two]. +extension TwoJson on Two { + /// Decodes a `Two` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Two']) => + switch (json) { + { + 'x': final int x, + } => + dmx.Ok(Two( + x: x, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Two', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'x': x, + }; } /// `Plain` as the diagram declares it. diff --git a/src/dmx/tests/typediagram/golden/lib/unions.dart b/src/dmx/tests/typediagram/golden/lib/unions.dart index 686cab3..90ee249 100644 --- a/src/dmx/tests/typediagram/golden/lib/unions.dart +++ b/src/dmx/tests/typediagram/golden/lib/unions.dart @@ -1,7 +1,9 @@ -// dmx: generated from docs/unions.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition 5214cc1d7a2b8b4d, template ebcc1789a3d0fa99, context v1, dmx 0.0.0. +// dmx: generated from models/unions.td — do not edit. +// dmx: rendered through the canonical model template, definition 5214cc1d7a2b8b4d, template 5fba7c04728545cb, context v1, dmx 0.0.0. -// Generated from docs/unions.dmx.md. Edit the diagram, not this file. +// Generated from models/unions.td. Edit the definition, not this file. + +import 'package:dmx/dmx.dart' as dmx; /// Shape — exactly one of the cases below. sealed class Shape { @@ -9,62 +11,262 @@ sealed class Shape { const Shape(); } -/// The `Circle` case of Shape. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ShapeCircle extends Shape { - /// This case's payload, in diagram order. - const ShapeCircle({required this.radius}) : super(); +/// JSON for [Shape]. +extension ShapeJson on Shape { + /// Decodes whichever case the payload's 'type' names. + static dmx.Result fromJson(Object? json, [String path = 'Shape']) => + switch (json) { + { + 'type': final String type, + } => + switch (type) { + 'circle' => CircleJson.fromJson(json, path), + 'rectangle' => RectangleJson.fromJson(json, path), + 'triangle' => TriangleJson.fromJson(json, path), + 'point' => PointJson.fromJson(json, path), + _ => dmx.Err(dmx.DecodeError(path, 'Shape', json)), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Shape', json)), + }; + + /// This value as a JSON map, tagged with the case it is. + Map toJson() => switch (this) { + final Circle value => { + 'type': 'circle', + ...value.toJson(), + }, + final Rectangle value => { + 'type': 'rectangle', + ...value.toJson(), + }, + final Triangle value => { + 'type': 'triangle', + ...value.toJson(), + }, + final Point value => { + 'type': 'point', + ...value.toJson(), + }, + }; +} + +/// The `Circle` case of Shape, as an immutable value. +final class Circle extends Shape { + /// Every field, in the order the diagram declares them. + const Circle({required this.radius}) : super(); - /// The `radius` member, declared as `Float`. + /// The `radius` field, declared as `Float`. final double radius; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Circle && + other.radius == radius); + + @override + int get hashCode => Object.hash( + runtimeType, + radius, + ); + + @override + String toString() => 'Circle(radius: $radius)'; + + /// A copy of this value with the named fields replaced. + Circle copyWith({ + double? radius, + }) => + Circle( + radius: radius ?? this.radius, + ); } -/// The `Rectangle` case of Shape. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ShapeRectangle extends Shape { - /// This case's payload, in diagram order. - const ShapeRectangle({required this.width, required this.height}) : super(); +/// JSON for [Circle]. +extension CircleJson on Circle { + /// Decodes a `Circle` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Circle']) => + switch (json) { + { + 'radius': final num radius, + } => + dmx.Ok(Circle( + radius: radius.toDouble(), + )), + _ => dmx.Err(dmx.DecodeError(path, 'Circle', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'radius': radius, + }; +} - /// The `width` member, declared as `Float`. +/// The `Rectangle` case of Shape, as an immutable value. +final class Rectangle extends Shape { + /// Every field, in the order the diagram declares them. + const Rectangle({required this.width, required this.height}) : super(); + + /// The `width` field, declared as `Float`. final double width; - /// The `height` member, declared as `Float`. + /// The `height` field, declared as `Float`. final double height; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Rectangle && + other.width == width && + other.height == height); + + @override + int get hashCode => Object.hash( + runtimeType, + width, + height, + ); + + @override + String toString() => 'Rectangle(width: $width, height: $height)'; + + /// A copy of this value with the named fields replaced. + Rectangle copyWith({ + double? width, + double? height, + }) => + Rectangle( + width: width ?? this.width, + height: height ?? this.height, + ); +} + +/// JSON for [Rectangle]. +extension RectangleJson on Rectangle { + /// Decodes a `Rectangle` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Rectangle']) => + switch (json) { + { + 'width': final num width, + 'height': final num height, + } => + dmx.Ok(Rectangle( + width: width.toDouble(), + height: height.toDouble(), + )), + _ => dmx.Err(dmx.DecodeError(path, 'Rectangle', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'width': width, + 'height': height, + }; } -/// The `Triangle` case of Shape. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ShapeTriangle extends Shape { - /// This case's payload, in diagram order. - const ShapeTriangle({required this.a, required this.b, required this.c}) : super(); +/// The `Triangle` case of Shape, as an immutable value. +final class Triangle extends Shape { + /// Every field, in the order the diagram declares them. + const Triangle({required this.a, required this.b, required this.c}) : super(); - /// The `a` member, declared as `Float`. + /// The `a` field, declared as `Float`. final double a; - /// The `b` member, declared as `Float`. + /// The `b` field, declared as `Float`. final double b; - /// The `c` member, declared as `Float`. + /// The `c` field, declared as `Float`. final double c; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Triangle && + other.a == a && + other.b == b && + other.c == c); + + @override + int get hashCode => Object.hash( + runtimeType, + a, + b, + c, + ); + + @override + String toString() => 'Triangle(a: $a, b: $b, c: $c)'; + + /// A copy of this value with the named fields replaced. + Triangle copyWith({ + double? a, + double? b, + double? c, + }) => + Triangle( + a: a ?? this.a, + b: b ?? this.b, + c: c ?? this.c, + ); +} + +/// JSON for [Triangle]. +extension TriangleJson on Triangle { + /// Decodes a `Triangle` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Triangle']) => + switch (json) { + { + 'a': final num a, + 'b': final num b, + 'c': final num c, + } => + dmx.Ok(Triangle( + a: a.toDouble(), + b: b.toDouble(), + c: c.toDouble(), + )), + _ => dmx.Err(dmx.DecodeError(path, 'Triangle', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'a': a, + 'b': b, + 'c': c, + }; +} + +/// The `Point` case of Shape, as an immutable value. +final class Point extends Shape { + /// Every field, in the order the diagram declares them. + const Point() : super(); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Point); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'Point()'; } -/// The `Point` case of Shape. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ShapePoint extends Shape { - /// This case's payload, in diagram order. - const ShapePoint() : super(); +/// JSON for [Point]. +extension PointJson on Point { + /// Decodes a `Point` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Point']) => + switch (json) { + Map() => + dmx.Ok(Point( + )), + _ => dmx.Err(dmx.DecodeError(path, 'Point', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; } /// Error code — exactly one of the cases below. @@ -73,69 +275,228 @@ sealed class ErrorCode { const ErrorCode(); } -/// The `ParseError` case of ErrorCode. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ErrorCodeParseError extends ErrorCode { - /// This case's payload, in diagram order. - const ErrorCodeParseError() : super(); +/// JSON for [ErrorCode]. +extension ErrorCodeJson on ErrorCode { + /// Decodes whichever case the payload's 'type' names. + static dmx.Result fromJson(Object? json, [String path = 'ErrorCode']) => + switch (json) { + { + 'type': final String type, + } => + switch (type) { + 'parseError' => ParseErrorJson.fromJson(json, path), + 'invalidRequest' => InvalidRequestJson.fromJson(json, path), + 'methodNotFound' => MethodNotFoundJson.fromJson(json, path), + 'ok' => ErrorCodeOkJson.fromJson(json, path), + 'grouped' => GroupedJson.fromJson(json, path), + _ => dmx.Err(dmx.DecodeError(path, 'ErrorCode', json)), + }, + _ => dmx.Err(dmx.DecodeError(path, 'ErrorCode', json)), + }; + + /// This value as a JSON map, tagged with the case it is. + Map toJson() => switch (this) { + final ParseError value => { + 'type': 'parseError', + ...value.toJson(), + }, + final InvalidRequest value => { + 'type': 'invalidRequest', + ...value.toJson(), + }, + final MethodNotFound value => { + 'type': 'methodNotFound', + ...value.toJson(), + }, + final ErrorCodeOk value => { + 'type': 'ok', + ...value.toJson(), + }, + final Grouped value => { + 'type': 'grouped', + ...value.toJson(), + }, + }; +} + +/// The `ParseError` case of ErrorCode, as an immutable value. +final class ParseError extends ErrorCode { + /// Every field, in the order the diagram declares them. + const ParseError() : super(); /// The discriminant the diagram gives this case. static const int discriminant = -32700; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ParseError); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'ParseError()'; } -/// The `InvalidRequest` case of ErrorCode. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ErrorCodeInvalidRequest extends ErrorCode { - /// This case's payload, in diagram order. - const ErrorCodeInvalidRequest() : super(); +/// JSON for [ParseError]. +extension ParseErrorJson on ParseError { + /// Decodes a `ParseError` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'ParseError']) => + switch (json) { + Map() => + dmx.Ok(ParseError( + )), + _ => dmx.Err(dmx.DecodeError(path, 'ParseError', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; +} + +/// The `InvalidRequest` case of ErrorCode, as an immutable value. +final class InvalidRequest extends ErrorCode { + /// Every field, in the order the diagram declares them. + const InvalidRequest() : super(); /// The discriminant the diagram gives this case. static const int discriminant = -32600; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is InvalidRequest); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'InvalidRequest()'; +} + +/// JSON for [InvalidRequest]. +extension InvalidRequestJson on InvalidRequest { + /// Decodes a `InvalidRequest` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'InvalidRequest']) => + switch (json) { + Map() => + dmx.Ok(InvalidRequest( + )), + _ => dmx.Err(dmx.DecodeError(path, 'InvalidRequest', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; } -/// The `MethodNotFound` case of ErrorCode. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ErrorCodeMethodNotFound extends ErrorCode { - /// This case's payload, in diagram order. - const ErrorCodeMethodNotFound() : super(); +/// The `MethodNotFound` case of ErrorCode, as an immutable value. +final class MethodNotFound extends ErrorCode { + /// Every field, in the order the diagram declares them. + const MethodNotFound() : super(); /// The discriminant the diagram gives this case. static const int discriminant = -32601; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MethodNotFound); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'MethodNotFound()'; +} + +/// JSON for [MethodNotFound]. +extension MethodNotFoundJson on MethodNotFound { + /// Decodes a `MethodNotFound` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'MethodNotFound']) => + switch (json) { + Map() => + dmx.Ok(MethodNotFound( + )), + _ => dmx.Err(dmx.DecodeError(path, 'MethodNotFound', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; } -/// The `Ok` case of ErrorCode. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. +/// The `Ok` case of ErrorCode, as an immutable value. final class ErrorCodeOk extends ErrorCode { - /// This case's payload, in diagram order. + /// Every field, in the order the diagram declares them. const ErrorCodeOk() : super(); /// The discriminant the diagram gives this case. static const int discriminant = 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ErrorCodeOk); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'ErrorCodeOk()'; +} + +/// JSON for [ErrorCodeOk]. +extension ErrorCodeOkJson on ErrorCodeOk { + /// Decodes a `ErrorCodeOk` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'ErrorCodeOk']) => + switch (json) { + Map() => + dmx.Ok(ErrorCodeOk( + )), + _ => dmx.Err(dmx.DecodeError(path, 'ErrorCodeOk', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; } -/// The `Grouped` case of ErrorCode. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ErrorCodeGrouped extends ErrorCode { - /// This case's payload, in diagram order. - const ErrorCodeGrouped() : super(); +/// The `Grouped` case of ErrorCode, as an immutable value. +final class Grouped extends ErrorCode { + /// Every field, in the order the diagram declares them. + const Grouped() : super(); /// The discriminant the diagram gives this case. static const int discriminant = 1_000; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Grouped); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'Grouped()'; +} + +/// JSON for [Grouped]. +extension GroupedJson on Grouped { + /// Decodes a `Grouped` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Grouped']) => + switch (json) { + Map() => + dmx.Ok(Grouped( + )), + _ => dmx.Err(dmx.DecodeError(path, 'Grouped', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + }; } /// Option — exactly one of the cases below. @@ -144,27 +505,53 @@ sealed class Option { const Option(); } -/// The `Some` case of Option. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class OptionSome extends Option { - /// This case's payload, in diagram order. - const OptionSome({required this.value}) : super(); +/// The `Some` case of Option, as an immutable value. +final class Some extends Option { + /// Every field, in the order the diagram declares them. + const Some({required this.value}) : super(); - /// The `value` member, declared as `T`. + /// The `value` field, declared as `T`. final T value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Some && + other.value == value); + + @override + int get hashCode => Object.hash( + runtimeType, + value, + ); + + @override + String toString() => 'Some(value: $value)'; + + /// A copy of this value with the named fields replaced. + Some copyWith({ + T? value, + }) => + Some( + value: value ?? this.value, + ); } -/// The `None` case of Option. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class OptionNone extends Option { - /// This case's payload, in diagram order. - const OptionNone() : super(); +/// The `None` case of Option, as an immutable value. +final class None extends Option { + /// Every field, in the order the diagram declares them. + const None() : super(); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is None); + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'None()'; } /// Result — exactly one of the cases below. @@ -173,30 +560,68 @@ sealed class Result { const Result(); } -/// The `Ok` case of Result. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. +/// The `Ok` case of Result, as an immutable value. final class ResultOk extends Result { - /// This case's payload, in diagram order. + /// Every field, in the order the diagram declares them. const ResultOk({required this.value}) : super(); - /// The `value` member, declared as `T`. + /// The `value` field, declared as `T`. final T value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ResultOk && + other.value == value); + + @override + int get hashCode => Object.hash( + runtimeType, + value, + ); + + @override + String toString() => 'ResultOk(value: $value)'; + + /// A copy of this value with the named fields replaced. + ResultOk copyWith({ + T? value, + }) => + ResultOk( + value: value ?? this.value, + ); } -/// The `Err` case of Result. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class ResultErr extends Result { - /// This case's payload, in diagram order. - const ResultErr({required this.error}) : super(); +/// The `Err` case of Result, as an immutable value. +final class Err extends Result { + /// Every field, in the order the diagram declares them. + const Err({required this.error}) : super(); - /// The `error` member, declared as `E`. + /// The `error` field, declared as `E`. final E error; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Err && + other.error == error); + + @override + int get hashCode => Object.hash( + runtimeType, + error, + ); + + @override + String toString() => 'Err(error: $error)'; + + /// A copy of this value with the named fields replaced. + Err copyWith({ + E? error, + }) => + Err( + error: error ?? this.error, + ); } /// Request id — exactly one of the cases below. @@ -205,49 +630,225 @@ sealed class RequestId { const RequestId(); } -/// The `Number` case of RequestId. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class RequestIdNumber extends RequestId { - /// This case's payload, in diagram order. - const RequestIdNumber({required this.value1}) : super(); +/// JSON for [RequestId]. +extension RequestIdJson on RequestId { + /// Decodes whichever case the payload's 'type' names. + static dmx.Result fromJson(Object? json, [String path = 'RequestId']) => + switch (json) { + { + 'type': final String type, + } => + switch (type) { + 'number' => NumberJson.fromJson(json, path), + 'string' => RequestIdStringJson.fromJson(json, path), + 'triple' => TripleJson.fromJson(json, path), + _ => dmx.Err(dmx.DecodeError(path, 'RequestId', json)), + }, + _ => dmx.Err(dmx.DecodeError(path, 'RequestId', json)), + }; + + /// This value as a JSON map, tagged with the case it is. + Map toJson() => switch (this) { + final Number value => { + 'type': 'number', + ...value.toJson(), + }, + final RequestIdString value => { + 'type': 'string', + ...value.toJson(), + }, + final Triple value => { + 'type': 'triple', + ...value.toJson(), + }, + }; +} - /// The `value1` member, declared as `Int`. +/// The `Number` case of RequestId, as an immutable value. +final class Number extends RequestId { + /// Every field, in the order the diagram declares them. + const Number({required this.value1}) : super(); + + /// The `value1` field, declared as `Int`. final int value1; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Number && + other.value1 == value1); + + @override + int get hashCode => Object.hash( + runtimeType, + value1, + ); + + @override + String toString() => 'Number(value1: $value1)'; + + /// A copy of this value with the named fields replaced. + Number copyWith({ + int? value1, + }) => + Number( + value1: value1 ?? this.value1, + ); +} + +/// JSON for [Number]. +extension NumberJson on Number { + /// Decodes a `Number` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Number']) => + switch (json) { + { + 'value1': final int value1, + } => + dmx.Ok(Number( + value1: value1, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Number', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'value1': value1, + }; } -/// The `String` case of RequestId. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. +/// The `String` case of RequestId, as an immutable value. final class RequestIdString extends RequestId { - /// This case's payload, in diagram order. + /// Every field, in the order the diagram declares them. const RequestIdString({required this.value1}) : super(); - /// The `value1` member, declared as `String`. + /// The `value1` field, declared as `String`. final String value1; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RequestIdString && + other.value1 == value1); + + @override + int get hashCode => Object.hash( + runtimeType, + value1, + ); + + @override + String toString() => 'RequestIdString(value1: $value1)'; + + /// A copy of this value with the named fields replaced. + RequestIdString copyWith({ + String? value1, + }) => + RequestIdString( + value1: value1 ?? this.value1, + ); +} + +/// JSON for [RequestIdString]. +extension RequestIdStringJson on RequestIdString { + /// Decodes a `RequestIdString` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'RequestIdString']) => + switch (json) { + { + 'value1': final String value1, + } => + dmx.Ok(RequestIdString( + value1: value1, + )), + _ => dmx.Err(dmx.DecodeError(path, 'RequestIdString', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'value1': value1, + }; } -/// The `Triple` case of RequestId. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class RequestIdTriple extends RequestId { - /// This case's payload, in diagram order. - const RequestIdTriple({required this.value1, required this.value2, required this.value3}) : super(); +/// The `Triple` case of RequestId, as an immutable value. +final class Triple extends RequestId { + /// Every field, in the order the diagram declares them. + const Triple({required this.value1, required this.value2, required this.value3}) : super(); - /// The `value1` member, declared as `Int`. + /// The `value1` field, declared as `Int`. final int value1; - /// The `value2` member, declared as `String`. + /// The `value2` field, declared as `String`. final String value2; - /// The `value3` member, declared as `List`. + /// The `value3` field, declared as `List`. final List value3; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Triple && + other.value1 == value1 && + other.value2 == value2 && + dmx.dmxDeepEquals(other.value3, value3)); + + @override + int get hashCode => Object.hash( + runtimeType, + value1, + value2, + dmx.dmxDeepHash(value3), + ); + + @override + String toString() => 'Triple(value1: $value1, value2: $value2, value3: $value3)'; + + /// A copy of this value with the named fields replaced. + Triple copyWith({ + int? value1, + String? value2, + List? value3, + }) => + Triple( + value1: value1 ?? this.value1, + value2: value2 ?? this.value2, + value3: value3 ?? this.value3, + ); +} + +/// JSON for [Triple]. +extension TripleJson on Triple { + /// Decodes a `Triple` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Triple']) => + switch (json) { + { + 'value1': final int value1, + 'value2': final String value2, + 'value3': final List value3, + } => + switch (( + dmx.dmxList(value3, '$path.value3', (value, path) => switch (value) { + final bool value => dmx.Ok(value), + _ => dmx.Err(dmx.DecodeError(path, 'bool', value)), + }), + )) { + ( + dmx.Ok(value: final value3), + ) => + dmx.Ok(Triple( + value1: value1, + value2: value2, + value3: value3, + )), + (dmx.Err(error: final e),) => dmx.Err(e), + }, + _ => dmx.Err(dmx.DecodeError(path, 'Triple', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'value1': value1, + 'value2': value2, + 'value3': value3, + }; } /// Loose — exactly one of the cases below, told apart by shape @@ -257,28 +858,106 @@ sealed class Loose { const Loose(); } -/// The `Left` case of Loose. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class LooseLeft extends Loose { - /// This case's payload, in diagram order. - const LooseLeft({required this.value}) : super(); +/// The `Left` case of Loose, as an immutable value. +final class Left extends Loose { + /// Every field, in the order the diagram declares them. + const Left({required this.value}) : super(); - /// The `value` member, declared as `Int`. + /// The `value` field, declared as `Int`. final int value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Left && + other.value == value); + + @override + int get hashCode => Object.hash( + runtimeType, + value, + ); + + @override + String toString() => 'Left(value: $value)'; + + /// A copy of this value with the named fields replaced. + Left copyWith({ + int? value, + }) => + Left( + value: value ?? this.value, + ); +} + +/// JSON for [Left]. +extension LeftJson on Left { + /// Decodes a `Left` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Left']) => + switch (json) { + { + 'value': final int value, + } => + dmx.Ok(Left( + value: value, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Left', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'value': value, + }; } -/// The `Right` case of Loose. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class LooseRight extends Loose { - /// This case's payload, in diagram order. - const LooseRight({required this.value}) : super(); +/// The `Right` case of Loose, as an immutable value. +final class Right extends Loose { + /// Every field, in the order the diagram declares them. + const Right({required this.value}) : super(); - /// The `value` member, declared as `String`. + /// The `value` field, declared as `String`. final String value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Right && + other.value == value); + + @override + int get hashCode => Object.hash( + runtimeType, + value, + ); + + @override + String toString() => 'Right(value: $value)'; + + /// A copy of this value with the named fields replaced. + Right copyWith({ + String? value, + }) => + Right( + value: value ?? this.value, + ); +} + +/// JSON for [Right]. +extension RightJson on Right { + /// Decodes a `Right` from a JSON value, or says why it could not. + static dmx.Result fromJson(Object? json, [String path = 'Right']) => + switch (json) { + { + 'value': final String value, + } => + dmx.Ok(Right( + value: value, + )), + _ => dmx.Err(dmx.DecodeError(path, 'Right', json)), + }; + + /// This value as a JSON map. + Map toJson() => { + 'value': value, + }; } diff --git a/src/dmx/tests/typediagram/golden/pubspec.yaml b/src/dmx/tests/typediagram/golden/pubspec.yaml index 9eb3921..5c14372 100644 --- a/src/dmx/tests/typediagram/golden/pubspec.yaml +++ b/src/dmx/tests/typediagram/golden/pubspec.yaml @@ -2,3 +2,5 @@ name: dmx_typediagram_golden publish_to: none environment: sdk: ^3.6.0 +dependencies: + dmx: ^0.3.0 diff --git a/src/dmx/tests/typediagram/golden/template.mustache b/src/dmx/tests/typediagram/golden/template.mustache deleted file mode 100644 index 38bfa92..0000000 --- a/src/dmx/tests/typediagram/golden/template.mustache +++ /dev/null @@ -1,59 +0,0 @@ -// Generated from {{source.path}}. Edit the diagram, not this file. -{{#declarations}} -{{#isAlias}} - -/// `{{name}}` as the diagram declares it. -typedef {{name}}{{{genericDeclaration}}} = {{{dartType}}}; -{{/isAlias}} -{{#isRecord}} - -/// {{label}} — a record from the diagram. -final class {{name}}{{{genericDeclaration}}} { - /// Every field, in the order the diagram declares them. - const {{name}}({{{constructorParameters}}}); -{{#fields}} - - /// The `{{name}}` field, declared as `{{{typeDiagram}}}`. - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/isRecord}} -{{#isUnion}} - -/// {{label}} — exactly one of the cases below{{#untagged}}, told apart by shape -/// rather than by a tag{{/untagged}}. -sealed class {{name}}{{{genericDeclaration}}} { - /// The shared constructor every case delegates to. - const {{name}}(); -} -{{#variants}} - -/// The `{{name}}` case of {{owner}}. -/// -/// The class carries its union's name because variant names collide across -/// unions in one library — `Ok` belongs to two of them here — and a template, -/// not the generator, decides what a case is called. -final class {{owner}}{{name}}{{{ownerGenericDeclaration}}} extends {{owner}}{{{ownerGenericDeclaration}}} { - /// This case's payload, in diagram order. - const {{owner}}{{name}}({{{constructorParameters}}}) : super(); -{{#hasDiscriminant}} - - /// The discriminant the diagram gives this case. - static const int discriminant = {{discriminant}}; -{{/hasDiscriminant}} -{{#fields}} - - /// The `{{name}}` member, declared as `{{{typeDiagram}}}`. - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/variants}} -{{/isUnion}} -{{#isFunction}} -{{#signatures}} - -/// Signature {{index}} of `{{name}}`, as the diagram declares it. -typedef {{pascalName}}{{#isOverload}}{{index}}{{/isOverload}}{{{genericDeclaration}}} = {{#isAsync}}Future<{{{returnType}}}>{{/isAsync}}{{^isAsync}}{{{returnType}}}{{/isAsync}} Function({{{parameterList}}}); -{{/signatures}} -{{/isFunction}} -{{/declarations}} diff --git a/src/dmx/tests/typediagram_cli.rs b/src/dmx/tests/typediagram_cli.rs index e6d1853..bb01b04 100644 --- a/src/dmx/tests/typediagram_cli.rs +++ b/src/dmx/tests/typediagram_cli.rs @@ -22,11 +22,13 @@ mod support; +#[path = "support/workspace.rs"] +mod workspace; + use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::process::Command; -use support::TempDirectory; +use workspace::Workspace; /// A record definition and one template over it — the canonical document. const STORE: &str = r#"# Store models @@ -90,86 +92,20 @@ const declaredNames = [ That is the whole document. "#; -/// A workspace with `docs/store.dmx.md` in it, plus whatever else a test adds. -struct Workspace { - /// The scratch directory, removed when the test ends. - directory: TempDirectory, -} - -impl Workspace { - /// A workspace holding one document at `docs/store.dmx.md`. - fn with(document: &str) -> Self { - let directory = TempDirectory::create("dmx-typediagram").expect("scratch directory"); - fs::create_dir_all(directory.at("lib")).expect("lib directory"); - let _ = directory - .write("docs/store.dmx.md", document) - .expect("write the document"); - Self { directory } - } - - /// The workspace root. - fn root(&self) -> &Path { - &self.directory.path - } - - /// One path inside it. - fn path(&self, relative: &str) -> PathBuf { - self.directory.at(relative) - } - - /// The contents of one file inside it. - fn read(&self, relative: &str) -> String { - fs::read_to_string(self.path(relative)) - .unwrap_or_else(|e| panic!("cannot read {relative}: {e}")) - } - - /// Whether one path inside it exists. - fn exists(&self, relative: &str) -> bool { - self.path(relative).exists() - } - - /// Writes one file inside it, creating the directories it needs. - fn write(&self, relative: &str, contents: &str) { - let _ = self.directory.write(relative, contents).expect("write"); - } - - /// Runs `dmx` from the workspace root, as a shell in it would. - fn dmx(&self, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_dmx")) - .args(args) - .current_dir(self.root()) - .output() - .expect("run dmx") - } - - /// Runs `dmx build docs lib` and requires it to succeed. - fn build(&self) -> String { - let output = self.dmx(&["build", "docs", "lib"]); - assert!( - output.status.success(), - "build failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).into_owned() - } - - /// Runs `dmx build docs lib` and requires it to fail, returning stderr. - fn build_failure(&self) -> String { - let output = self.dmx(&["build", "docs", "lib"]); - assert!( - !output.status.success(), - "build should have failed; stdout:\n{}", - String::from_utf8_lossy(&output.stdout) - ); - String::from_utf8_lossy(&output.stderr).into_owned() - } +/// A workspace holding one document at `docs/store.dmx.md`. +fn document_workspace(document: &str) -> Workspace { + Workspace::create( + "dmx-typediagram", + &["build", "docs", "lib"], + &[("docs/store.dmx.md", document)], + ) } /// [typediagram.execution]: one document, two templates, two owned files — /// and a second build that writes nothing. #[test] fn a_document_generates_every_bound_template_once() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let first = workspace.build(); assert!(first.contains("wrote: docs/store.dmx.md"), "{first}"); @@ -214,9 +150,9 @@ fn a_document_generates_every_bound_template_once() { /// produces the same bytes from a clean workspace every time. #[test] fn generation_is_byte_identical_across_workspaces() { - let first = Workspace::with(STORE); + let first = document_workspace(STORE); let _ = first.build(); - let second = Workspace::with(STORE); + let second = document_workspace(STORE); let _ = second.build(); assert_eq!( first.read("lib/models.dart"), @@ -229,7 +165,7 @@ fn generation_is_byte_identical_across_workspaces() { /// document still is not rewritten. #[test] fn a_crlf_document_generates_the_same_model() { - let workspace = Workspace::with(&STORE.replace('\n', "\r\n")); + let workspace = document_workspace(&STORE.replace('\n', "\r\n")); let _ = workspace.build(); assert!( workspace @@ -243,7 +179,7 @@ fn a_crlf_document_generates_the_same_model() { /// nothing; once the outputs are current it exits 0. #[test] fn check_reports_drift_and_writes_nothing() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let drift = workspace.dmx(&["build", "docs", "lib", "--check"]); assert_eq!(drift.status.code(), Some(2), "drift must exit 2"); @@ -263,7 +199,7 @@ fn check_reports_drift_and_writes_nothing() { /// Markdown file is documentation until somebody names it. #[test] fn other_markdown_is_documentation_until_it_is_named() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); workspace.write( "docs/notes.md", "```typeDiagram\ntype Note { body: String }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/notes.dart\"}}\n// {{#declarations}}{{name}}{{/declarations}}\n```\n", @@ -288,7 +224,7 @@ fn other_markdown_is_documentation_until_it_is_named() { /// no dmx metadata, and an unrelated fence all generate nothing. #[test] fn documentation_only_content_generates_nothing() { - let workspace = Workspace::with( + let workspace = document_workspace( "# Notes\n\n```typeDiagram\ntype A { x: Int }\n```\n\n```mustache\n{{name}}\n```\n\n```dart\nclass A {}\n```\n", ); let output = workspace.build(); @@ -300,7 +236,7 @@ fn documentation_only_content_generates_nothing() { /// build fails rather than proceeding. #[test] fn a_hand_written_output_is_never_overwritten() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); workspace.write("lib/models.dart", "// mine, by hand\n"); let error = workspace.build_failure(); @@ -311,7 +247,7 @@ fn a_hand_written_output_is_never_overwritten() { /// [typediagram.output]: a dropped template drops its file. #[test] fn a_removed_template_collects_its_output() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let _ = workspace.build(); assert!(workspace.exists("lib/names.dart")); @@ -350,7 +286,7 @@ fn every_refusal_is_coded_and_located() { ), ( "DMX8003", - "lines 5 and 9", + "on line 5 and the Mustache template in docs/store.dmx.md on line 9", "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\na\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nb\n```\n", ), ( @@ -379,7 +315,7 @@ fn every_refusal_is_coded_and_located() { "```typeDiagram\ntype A { x: Int }\n```\n\n```mustache {\"dmx\":{\"output\":\"lib/a.dart\"}}\nint probe() => throw StateError('{{#declarations}}{{name}}{{/declarations}}');\n```\n", ), ] { - let workspace = Workspace::with(document); + let workspace = document_workspace(document); let error = workspace.build_failure(); assert!(error.contains(code), "expected {code}:\n{error}"); assert!( @@ -405,7 +341,7 @@ fn every_refusal_is_coded_and_located() { /// their outputs, and the exact context — and generates nothing. #[test] fn explain_prints_the_context_and_writes_nothing() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let output = workspace.dmx(&["explain", "docs/store.dmx.md"]); assert!( output.status.success(), @@ -444,7 +380,10 @@ fn explain_prints_the_context_and_writes_nothing() { for (args, needle) in [ (vec!["explain"], "takes exactly one file"), (vec!["explain", "docs", "lib"], "takes exactly one file"), - (vec!["explain", "lib/models.dart"], "Markdown documents"), + ( + vec!["explain", "lib/models.dart"], + "a typeDiagram definition (`.td`)", + ), ] { let refused = workspace.dmx(&args); assert!( @@ -465,7 +404,7 @@ fn explain_prints_the_context_and_writes_nothing() { /// output, and a definition change is. #[test] fn only_the_group_is_a_dependency_of_its_output() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let _ = workspace.build(); let before = workspace.read("lib/models.dart"); @@ -500,7 +439,7 @@ fn only_the_group_is_a_dependency_of_its_output() { /// annotation may not claim it and a Dart file may not be generated by it. #[test] fn the_builtin_name_is_not_an_annotation() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); workspace.write( "lib/hand.dart", "@dmx('typeDiagram')\nclass Hand {\n final int a = 0;\n}\n", @@ -515,7 +454,7 @@ fn the_builtin_name_is_not_an_annotation() { /// same place however dmx was launched. #[test] fn outputs_land_in_the_package_the_document_belongs_to() { - let workspace = Workspace::with("# empty\n"); + let workspace = document_workspace("# empty\n"); workspace.write("packages/store/pubspec.yaml", "name: store\n"); workspace.write("packages/store/docs/models.dmx.md", STORE); @@ -560,7 +499,7 @@ fn outputs_land_in_the_package_the_document_belongs_to() { /// but a renamed document takes its own outputs with it. #[test] fn one_output_has_one_live_source() { - let workspace = Workspace::with(STORE); + let workspace = document_workspace(STORE); let _ = workspace.build(); assert!(workspace.exists("lib/models.dart")); diff --git a/src/dmx/tests/typediagram_golden.rs b/src/dmx/tests/typediagram_golden.rs index 3d42d63..8544c87 100644 --- a/src/dmx/tests/typediagram_golden.rs +++ b/src/dmx/tests/typediagram_golden.rs @@ -6,15 +6,19 @@ //! holds that emitting Dart which does not compile is the worst failure //! available to it. //! -//! So each fixture is wrapped in a real `*.dmx.md` document over one shared -//! template, run through the real `dmx` binary, and compared byte for byte with +//! So the whole corpus is laid out as standalone `models/.td` files +//! [typediagram.standalone] with nothing beside them, run through the real +//! `dmx` binary in one `dmx build`, and compared byte for byte with //! `tests/typediagram/golden/lib/.dart`. Those files are committed, and //! `make corpus` runs `dart analyze --fatal-infos` over the package holding //! them — so the corpus is checked as source, not just as JSON. //! -//! The definitions are never copied. The document is assembled from the `.td` -//! file at test time, so the parity corpus stays the one place a definition is -//! written and the two suites can never drift apart. +//! Nothing is wrapped, assembled, or extracted, and no template is written +//! here at all: the `.td` files are copied out of the parity corpus byte for +//! byte and render through the canonical model template dmx ships +//! [typediagram.canonical]. That makes this suite the canonical template's +//! own gate — every shape typeDiagram can express, held to Dart the analyzer +//! accepts. //! //! Hygiene is not re-asserted here. The binary refuses to write source //! carrying `throw`, an `as` cast or a `!` assertion at all, and @@ -45,6 +49,7 @@ mod support; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -77,6 +82,12 @@ fn corpus_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/typediagram/corpus") } +/// The Dart file name a fixture generates, which is its own name spelled the +/// way Dart spells a source file [typediagram.standalone]. +fn dart_name(name: &str) -> String { + name.replace('-', "_") +} + fn read(path: &Path) -> String { fs::read_to_string(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) } @@ -89,67 +100,63 @@ fn normalised(source: &str) -> String { ) } -/// The `*.dmx.md` document one fixture is generated from. +/// Runs the binary over a throwaway package holding the whole corpus as +/// standalone definitions, and returns the Dart it wrote for each fixture. /// -/// The definition is the `.td` file verbatim and the template is -/// `golden/template.mustache` verbatim, so neither is written twice. -fn document(name: &str, definition: &str, template: &str) -> String { - format!( - "# {name}\n\nGenerated from the parity corpus fixture of the same name.\n\n\ - ```typeDiagram\n{definition}```\n\n\ - ```mustache {{\"dmx\":{{\"output\":\"lib/{name}.dart\"}}}}\n{template}```\n" - ) -} - -/// Runs the binary over a throwaway package holding one fixture's document and -/// returns the Dart it wrote. -fn generate(name: &str, template: &str) -> String { +/// One package and one invocation: every `.td` is found by the same recursive +/// sweep a real project gets, and every one of them renders through the +/// canonical model template, because nothing sits beside it +/// [typediagram.canonical]. +fn generate() -> BTreeMap<&'static str, String> { let workspace = TempDirectory::create("dmx-td-golden").expect("scratch directory"); let _ = workspace - .write( - "pubspec.yaml", - "name: dmx_typediagram_golden\npublish_to: none\nenvironment:\n sdk: ^3.6.0\n", - ) + .write("pubspec.yaml", &read(&golden_dir().join("pubspec.yaml"))) .expect("pubspec"); - let definition = read(&corpus_dir().join(format!("{name}.td"))); - let _ = workspace - .write( - &format!("docs/{name}.dmx.md"), - &document(name, &definition, template), - ) - .expect("document"); + for name in FIXTURES { + let _ = workspace + .write( + &format!("models/{name}.td"), + &read(&corpus_dir().join(format!("{name}.td"))), + ) + .expect("definition"); + } let output = Command::new(env!("CARGO_BIN_EXE_dmx")) - .args(["build", "docs", "lib"]) + .args(["build", "models", "lib"]) .current_dir(&workspace.path) .output() .expect("run dmx"); assert!( output.status.success(), - "{name}: dmx build failed\nstdout:\n{}\nstderr:\n{}", + "dmx build failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let written = workspace.at(&format!("lib/{name}.dart")); - assert!( - written.exists(), - "{name}: nothing was written to lib/{name}.dart\nstdout:\n{}", - String::from_utf8_lossy(&output.stdout) - ); - normalised(&read(&written)) + FIXTURES + .iter() + .map(|name| { + let written = workspace.at(&format!("lib/{}.dart", dart_name(name))); + assert!( + written.exists(), + "{name}: nothing was written to lib/{}.dart\nstdout:\n{}", + dart_name(name), + String::from_utf8_lossy(&output.stdout) + ); + (*name, normalised(&read(&written))) + }) + .collect() } /// [typediagram.output]: every corpus definition renders to the committed Dart, /// byte for byte, through the shipped binary. #[test] fn every_corpus_fixture_generates_its_golden_dart() { - let template = read(&golden_dir().join("template.mustache")); let updating = std::env::var_os("UPDATE_GOLDEN").is_some(); - for name in FIXTURES { - let actual = generate(name, &template); - let expected_path = golden_dir().join(format!("lib/{name}.dart")); + for (name, actual) in generate() { + let file = format!("lib/{}.dart", dart_name(name)); + let expected_path = golden_dir().join(&file); if updating { fs::write(&expected_path, &actual).expect("write golden"); @@ -159,7 +166,7 @@ fn every_corpus_fixture_generates_its_golden_dart() { let expected = read(&expected_path); assert_eq!( actual, expected, - "{name}: generated Dart no longer matches tests/typediagram/golden/lib/{name}.dart. \ + "{name}: generated Dart no longer matches tests/typediagram/golden/{file}. \ Re-run with UPDATE_GOLDEN=1 if the change is deliberate." ); } @@ -171,97 +178,237 @@ fn every_corpus_fixture_generates_its_golden_dart() { /// The byte comparison above proves the output is *stable*; it cannot notice /// that a section stopped matching and quietly rendered nothing. These are the /// constructs no other suite in the repo generates. +/// Every construct the corpus exists to cover: the golden that must carry it, +/// what it is, and the text that proves it survived. A row that stops matching +/// names the construct that went missing rather than a line number. +const CONSTRUCTS: &[(&str, &str, &[&str])] = &[ + ( + "lib/unions.dart", + "a tuple variant, under a name Dart can compile", + &["const Triple({required this.value1, required this.value2, required this.value3})"], + ), + ( + "lib/unions.dart", + "a generic union, with its cases parameterised by the union's own list", + &[ + "final class Some extends Option", + "final class Err extends Result", + ], + ), + ( + "lib/unions.dart", + "explicit discriminants, the digit-separated one included", + &[ + "static const int discriminant = -32700;", + "static const int discriminant = 1_000;", + ], + ), + ( + "lib/unions.dart", + "the untagged union, marked as told apart by shape", + &["told apart by shape"], + ), + ( + "lib/unions.dart", + "cases named as the diagram names them [typediagram.canonical.names]", + &[ + "final class Circle extends Shape {", + "final class Left extends Loose {", + "final class Number extends RequestId {", + ], + ), + ( + "lib/unions.dart", + "a colliding case name qualified by its union — `Ok` belongs to two \ + unions here, and `String` is Dart's own", + &[ + "final class ErrorCodeOk extends ErrorCode {", + "final class ResultOk extends Result {", + "final class RequestIdString extends RequestId {", + ], + ), + ( + "lib/aliases_and_functions.dart", + "the generic function typedef", + &["typedef Fetch = Response Function(Request request, T? fallback);"], + ), + ( + "lib/aliases_and_functions.dart", + "overloads, written out one typedef each", + &[ + "typedef Read0 = List Function(String path);", + "typedef Read1 = Future> Function(String path, double timeout);", + ], + ), + ( + "lib/aliases_and_functions.dart", + "an async single-signature function, which is a Future", + &["typedef Store = Future Function(Request item);"], + ), + ( + "lib/aliases_and_functions.dart", + "the generic alias", + &["typedef Index = Map>;"], + ), + ( + "lib/scalars.dart", + "the scalar mapping table, field by field", + &[ + "final bool flag;", + "final int count;", + "final double ratio;", + "final List blob;", + "final void nothing;", + "final DateTime at;", + "final Object anything;", + "final Map> index;", + "final Map>? deep;", + ], + ), + ( + "lib/scalars.dart", + "a declaration shadowing a primitive, with the field on the declared name", + &["typedef Uuid = String;", "final Uuid id;"], + ), + ( + "lib/records.dart", + "an empty record, which takes no parameter list", + &["const Empty();"], + ), + ( + "lib/records.dart", + "generic records", + &["final class Pair {"], + ), + ( + "lib/targeting.dart", + "every declaration the dart target selects", + &["class OnlyDartAndRust", "class NotGo", "sealed class Both"], + ), +]; + +/// The shapes that must never appear: typeDiagram's positional member names +/// are not Dart identifiers, and a case qualifies only on a collision. +const REFUSED: &[(&str, &str, &str)] = &[ + ( + "lib/unions.dart", + "this._0", + "a private member reached Dart", + ), + ( + "lib/unions.dart", + "ShapeCircle", + "a case was qualified for no reason", + ), +]; + #[test] fn the_goldens_cover_the_shapes_the_corpus_exists_for() { - let unions = read(&golden_dir().join("lib/unions.dart")); - // A tuple variant, under a name Dart can compile — see - // `tuple_members_are_named_for_the_target_not_for_the_model`. - assert!( - unions.contains("const RequestIdTriple({required this.value1, required this.value2, required this.value3})"), - "tuple variants missing from unions.dart" - ); - assert!(!unions.contains("this._0"), "a private member reached Dart"); - // A generic union, with its cases parameterised by the union's own list. - assert!( - unions.contains("final class OptionSome extends Option"), - "generic union cases missing from unions.dart" - ); - assert!( - unions.contains("final class ResultErr extends Result"), - "multi-parameter generic union cases missing from unions.dart" - ); - // Explicit discriminants, including the digit-separated one. - assert!( - unions.contains("static const int discriminant = -32700;") - && unions.contains("static const int discriminant = 1_000;"), - "discriminants missing from unions.dart" - ); - assert!( - unions.contains("told apart by shape"), - "the untagged union is not marked in unions.dart" - ); + for (file, construct, needles) in CONSTRUCTS { + let source = read(&golden_dir().join(file)); + for needle in *needles { + assert!( + source.contains(needle), + "{file} lost {construct}: no `{needle}`" + ); + } + } - let functions = read(&golden_dir().join("lib/aliases-and-functions.dart")); - assert!( - functions.contains("typedef Fetch = Response Function(Request request, T? fallback);"), - "the generic function typedef is missing" - ); - assert!( - functions.contains("typedef Read0 = List Function(String path);") - && functions.contains( - "typedef Read1 = Future> Function(String path, double timeout);" - ), - "overloads are not written out one typedef each" - ); - assert!( - functions.contains("typedef Store = Future Function(Request item);"), - "an async single-signature function is not a Future" - ); - assert!( - functions.contains("typedef Index = Map>;"), - "the generic alias is missing" - ); + for (file, refused, why) in REFUSED { + assert!( + !read(&golden_dir().join(file)).contains(refused), + "{file}: {why}" + ); + } +} - let scalars = read(&golden_dir().join("lib/scalars.dart")); +/// [typediagram.canonical]: the classes the canonical template writes are +/// values, and their JSON is beside them rather than in them. +/// +/// This is the whole point of there being one model template. A record and a +/// union case are the same kind of thing — an immutable value — so both get +/// `==`, `hashCode`, `toString` and `copyWith`; and neither carries a codec, +/// because a class the diagram described should read as what the diagram said +/// and nothing else. +#[test] +fn every_generated_class_is_a_value_with_its_json_beside_it() { + let records = read(&golden_dir().join("lib/records.dart")); for expected in [ - "final bool flag;", - "final int count;", - "final double ratio;", - "final List blob;", - "final void nothing;", - "final DateTime at;", - "final Object anything;", - "final Map> index;", - "final Map>? deep;", + "bool operator ==(Object other) =>", + " dmx.dmxDeepEquals(other.roles, roles) &&", + "int get hashCode => Object.hash(", + " dmx.dmxDeepHash(roles),", + "String toString() => 'User(id: $id, name: $name, email: $email, roles: $roles, \ + address: $address)';", + " User copyWith({", + "extension UserJson on User {", + " static dmx.Result fromJson(Object? json, [String path = 'User']) =>", + " Map toJson() => {", + // The nested decode reaches the *extension*, not the class. + "AddressJson.fromJson(address, '$path.address')", + ] { + assert!( + records.contains(expected), + "records.dart is missing `{expected}`" + ); + } + for (file, class) in [ + ("lib/records.dart", "final class User {"), + ("lib/unions.dart", "final class Circle extends Shape {"), + ("lib/targeting.dart", "final class OnlyDartAndRust {"), ] { + let body = class_body(&read(&golden_dir().join(file)), class); assert!( - scalars.contains(expected), - "scalars.dart is missing `{expected}`" + !body.contains("Json") && !body.contains("toJson"), + "{file}: `{class}` carries JSON members:\n{body}" ); } - // A declaration shadows a primitive, and the field takes the declared name. + + // A case decodes by its tag, and the union it belongs to dispatches on one. + let unions = read(&golden_dir().join("lib/unions.dart")); assert!( - scalars.contains("typedef Uuid = String;") && scalars.contains("final Uuid id;"), - "the shadowing alias is not honoured in scalars.dart" + unions.contains("extension ShapeJson on Shape {") + && unions.contains("'circle' => CircleJson.fromJson(json, path),") + && unions.contains(" 'type': 'circle',"), + "the union's own codec is missing from unions.dart" + ); + // The diagram declares `Result`, `Ok` and `Err` itself. A prefixed import + // is what stops the codec resolving to them [typediagram.canonical]. + assert!( + unions.contains("import 'package:dmx/dmx.dart' as dmx;") + && unions.contains("sealed class Result {") + && unions.contains("dmx.Result"), + "unions.dart does not keep the runtime and the diagram's own names apart" ); - let records = read(&golden_dir().join("lib/records.dart")); + // `Unit` is Dart's `void`, which is not a value: it takes part in no + // comparison, no `toString`, no `copyWith`, and no codec. + let scalars = read(&golden_dir().join("lib/scalars.dart")); assert!( - records.contains("const Empty();"), - "an empty record must take no parameter list" + scalars.contains("final void nothing;") + && !scalars.contains("nothing: $nothing") + && !scalars.contains("extension"), + "scalars.dart tried to give `void` value semantics" ); + + // A generic declaration has no codec, because a codec for `T` is not known + // until `T` is. It is still a value. assert!( - records.contains("final class Pair {"), - "generic records are missing" + records.contains("(other is Pair &&") && !records.contains("extension PairJson"), + "records.dart got the generic case wrong" ); +} - let targeting = read(&golden_dir().join("lib/targeting.dart")); - for expected in ["class OnlyDartAndRust", "class NotGo", "sealed class Both"] { - assert!( - targeting.contains(expected), - "targeting.dart dropped `{expected}`, which the dart target selects" - ); - } +/// The text between a class header and the brace that closes it at column +/// zero, which is what generated Dart puts there. +fn class_body(source: &str, header: &str) -> String { + source + .split_once(header) + .and_then(|(_, rest)| rest.split_once("\n}\n")) + .map_or_else( + || panic!("no class body for `{header}`"), + |(body, _)| body.to_owned(), + ) } /// [typediagram.output]: every generated file carries the ownership marker the @@ -269,19 +416,21 @@ fn the_goldens_cover_the_shapes_the_corpus_exists_for() { #[test] fn every_golden_is_marked_as_generated() { for name in FIXTURES { - let source = read(&golden_dir().join(format!("lib/{name}.dart"))); - let first = source.lines().next().unwrap_or_default(); + let source = read(&golden_dir().join(format!("lib/{}.dart", dart_name(name)))); + let mut lines = source.lines(); assert_eq!( - first, - format!("// dmx: generated from docs/{name}.dmx.md — do not edit.") + lines.next().unwrap_or_default(), + format!("// dmx: generated from models/{name}.td — do not edit.") + ); + let identity = lines.next().unwrap_or_default(); + assert!( + identity + .starts_with("// dmx: rendered through the canonical model template, definition "), + "{name}: the identity line does not name the template: {identity}" ); assert!( - source - .lines() - .nth(1) - .unwrap_or_default() - .contains("context v1"), - "{name}: the identity line is missing" + identity.contains("context v1"), + "{name}: the identity line is missing the context version" ); } } diff --git a/src/dmx/tests/typediagram_standalone.rs b/src/dmx/tests/typediagram_standalone.rs new file mode 100644 index 0000000..68259d0 --- /dev/null +++ b/src/dmx/tests/typediagram_standalone.rs @@ -0,0 +1,487 @@ +//! `.td` + `.mustache` → `.dart`, driven the way a user drives it +//! [typediagram.standalone]. +//! +//! Three files and no wrapper, through the real binary over real files. What +//! this suite is for is the *binding*: which template renders which definition, +//! where the output lands, what a watcher does when a template changes, and +//! what happens to a Mustache file that has nothing to do with dmx. The +//! pipeline behind it is the shared one, proven again over the whole parity +//! corpus by `typediagram_golden`. + +// [TEST-RULES] admits `expect` in a test: a fixture that cannot be built is a +// broken test, and unwinding at the point of failure names it better than any +// `Result` plumbing would. Production code is still held to `unwrap_used` and +// `expect_used` at deny — this relaxation is `cfg(test)`-scoped on purpose. +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects + ) +)] + +mod support; + +#[path = "support/watch.rs"] +mod watch; + +#[path = "support/workspace.rs"] +mod workspace; + +use std::fs; +use std::io; + +use watch::WatchProcess; +use workspace::Workspace; + +/// The definition every fixture here renders. +const DEFINITION: &str = "# A parcel on its way to a customer. +type Parcel { + id: Uuid + weightG: Int + insured: Option +} +"; + +/// The template every fixture here renders it through. +const TEMPLATE: &str = "{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +"; + +/// A second template over the same definition, writing something else. +const WIRE_TEMPLATE: &str = "{{#declarations}} +const parcelWireNames = [{{#fields}}'{{snakeName}}'{{comma}}{{/fields}}]; +{{/declarations}} +"; + +/// A package with `models/parcel.td` in it, and whatever else `files` names. +fn package(files: &[(&str, &str)]) -> Workspace { + let workspace = Workspace::create( + "dmx-td-standalone", + &["build", "models", "lib"], + &[ + ("pubspec.yaml", "name: fixture\n"), + ("models/parcel.td", DEFINITION), + ], + ); + for (name, contents) in files { + workspace.write(name, contents); + } + workspace +} + +/// [typediagram.canonical]: a definition on its own is enough. It renders +/// through the canonical model template, into an immutable value with its JSON +/// beside it — and a template beside it takes that template's place. +#[test] +fn a_definition_alone_generates_a_model_class() { + let workspace = package(&[]); + let first = workspace.build(); + assert!(first.contains("1 of 1 file(s) updated"), "{first}"); + + let generated = workspace.read("lib/parcel.dart"); + assert!( + generated.starts_with("// dmx: generated from models/parcel.td — do not edit.\n"), + "{generated}" + ); + assert!( + generated.contains("// dmx: rendered through the canonical model template, definition "), + "{generated}" + ); + for expected in [ + "import 'package:dmx/dmx.dart' as dmx;", + "final class Parcel {", + " bool operator ==(Object other) =>", + " int get hashCode => Object.hash(", + " String toString() => 'Parcel(", + " Parcel copyWith({", + "extension ParcelJson on Parcel {", + " static dmx.Result fromJson(", + " Map toJson() => {", + ] { + assert!( + generated.contains(expected), + "missing `{expected}`:\n{generated}" + ); + } + + // Idempotent, and `--check` sees no drift in what it just wrote. The file + // count grew by one: what was written is Dart, and a pass over `lib` reads + // it like any other source. + let second = workspace.build(); + assert!(second.contains("0 of 2 file(s) updated"), "{second}"); + let checked = workspace.dmx(&["build", "models", "lib", "--check"]); + assert!( + checked.status.success(), + "--check found drift in its own output:\n{}", + String::from_utf8_lossy(&checked.stderr) + ); + + // A template of the definition's own name replaces the canonical one, and + // removing it hands the file back rather than collecting it. + workspace.write("models/parcel.mustache", TEMPLATE); + let _ = workspace.build(); + let replaced = workspace.read("lib/parcel.dart"); + assert!( + replaced.contains("rendered through models/parcel.mustache"), + "{replaced}" + ); + assert!(!replaced.contains("operator =="), "{replaced}"); + + fs::remove_file(workspace.path("models/parcel.mustache")).expect("remove template"); + let _ = workspace.build(); + assert!( + workspace + .read("lib/parcel.dart") + .contains("rendered through the canonical model template"), + "the definition stopped rendering when its template went away" + ); +} + +/// [typediagram.standalone]: a definition and the template beside it generate +/// Dart, a second template generates a second file, and a second build writes +/// nothing. +#[test] +fn a_definition_and_its_templates_generate_dart() { + let workspace = package(&[ + ("models/parcel.mustache", TEMPLATE), + ("models/parcel.wire.mustache", WIRE_TEMPLATE), + ]); + let first = workspace.build(); + // One source, whatever it writes: the definition is what a pass generates + // from, and the two outputs are what it produced. + assert!(first.contains("wrote: models/parcel.td"), "{first}"); + assert!(first.contains("1 of 1 file(s) updated"), "{first}"); + + let generated = workspace.read("lib/parcel.dart"); + assert!( + generated.starts_with("// dmx: generated from models/parcel.td — do not edit.\n"), + "{generated}" + ); + assert!( + generated.contains("// dmx: rendered through models/parcel.mustache, definition "), + "{generated}" + ); + assert!(generated.contains("context v1"), "{generated}"); + assert!(generated.contains("final class Parcel {"), "{generated}"); + assert!( + generated + .contains("const Parcel({required this.id, required this.weightG, this.insured});"), + "{generated}" + ); + // `Option` is resolved before the template runs, and `Uuid` with it. + assert!(generated.contains("final String? insured;"), "{generated}"); + assert!(generated.contains("final String id;"), "{generated}"); + + let wire = workspace.read("lib/parcel_wire.dart"); + assert!( + wire.contains("const parcelWireNames = ['id','weight_g','insured'];"), + "{wire}" + ); + assert!( + wire.contains("rendered through models/parcel.wire.mustache"), + "{wire}" + ); + + let second = workspace.build(); + assert!(second.contains("0 of "), "a second build rewrote: {second}"); + assert_eq!( + workspace.read("models/parcel.td"), + DEFINITION, + "the definition is the source of truth and is never rewritten" + ); + assert_eq!( + workspace.read("models/parcel.mustache"), + TEMPLATE, + "the template is never rewritten either" + ); +} + +/// [typediagram.standalone]: a leading Mustache comment moves the output, and +/// renders to nothing because it is a comment. +#[test] +fn a_leading_comment_moves_the_output() { + let workspace = package(&[( + "models/parcel.mustache", + &format!("{{{{! dmx output=lib/models/parcel.dart }}}}\n{TEMPLATE}"), + )]); + let _ = workspace.build(); + + assert!(!workspace.exists("lib/parcel.dart")); + let generated = workspace.read("lib/models/parcel.dart"); + assert!(generated.contains("final class Parcel {"), "{generated}"); + assert!(!generated.contains("output="), "{generated}"); +} + +/// [typediagram.standalone]: a Mustache file with no definition beside it is +/// somebody else's, and a build leaves it and the tree alone. +#[test] +fn a_template_with_no_definition_generates_nothing() { + let workspace = package(&[("templates/preview.mustache", TEMPLATE)]); + let report = workspace.build(); + assert!(!workspace.exists("lib/preview.dart"), "{report}"); + // The definition beside it still renders, through the canonical model + // template [typediagram.canonical] — the preview is simply not a source. + assert!( + workspace + .read("lib/parcel.dart") + .contains("rendered through the canonical model template"), + "{report}" + ); + + // Naming it explicitly is the same answer, not a different one. + let named = workspace.dmx(&["build", "templates/preview.mustache"]); + assert!( + named.status.success(), + "{}", + String::from_utf8_lossy(&named.stderr) + ); + assert_eq!(workspace.read("templates/preview.mustache"), TEMPLATE); +} + +/// [typediagram.standalone]: every refusal carries its code and names the file +/// a reader has to open — with no fence anywhere in the sentence. +#[test] +fn every_refusal_is_coded_and_names_a_file() { + for (code, needle, files) in [ + ( + "DMX8004", + "in models/parcel.td is not valid", + vec![ + ("models/parcel.td", "type A { x: Int }\ntype B { y }\n"), + ("models/parcel.mustache", TEMPLATE), + ], + ), + ( + "DMX8001", + "`dmx.ouput` is not a setting dmx knows", + vec![( + "models/parcel.mustache", + "{{! dmx ouput=lib/parcel.dart }}\nx\n", + )], + ), + ( + "DMX8001", + "`typo` is not a `key=value` setting", + vec![("models/parcel.mustache", "{{! dmx typo }}\nx\n")], + ), + ( + "DMX8005", + "is an absolute path", + vec![( + "models/parcel.mustache", + &format!("{{{{! dmx output=/etc/parcel.dart }}}}\n{TEMPLATE}"), + )], + ), + ( + "DMX8005", + "does not end in `.dart`", + vec![( + "models/parcel.mustache", + "{{! dmx output=lib/parcel.txt }}\nx\n", + )], + ), + ( + "DMX8003", + "both generate `lib/parcel.dart`", + vec![ + ("models/parcel.mustache", TEMPLATE), + ( + "models/parcel.wire.mustache", + &format!("{{{{! dmx output=lib/parcel.dart }}}}\n{TEMPLATE}"), + ), + ], + ), + ( + "DMX8010", + "has no name left to generate under", + vec![ + ( + "models/parcel.td", + "type Circle { r: Float }\ntype ShapeCircle { r: Float }\nunion Shape { Circle { r: Float } }\n", + ), + ("models/parcel.mustache", TEMPLATE), + ], + ), + ( + "DMX4003", + "never throws", + vec![( + "models/parcel.mustache", + "int probe() => throw StateError('{{#declarations}}{{name}}{{/declarations}}');\n", + )], + ), + ] { + let owned: Vec<(&str, &str)> = files.clone(); + let workspace = package(&owned); + let error = workspace.build_failure(); + assert!(error.contains(code), "expected {code}:\n{error}"); + assert!( + error.contains(needle), + "expected {needle:?} in {code}:\n{error}" + ); + assert!( + error.contains("models/parcel."), + "{code} must name the file to open:\n{error}" + ); + assert!( + !error.contains("fence"), + "{code} talks about fences in a file:\n{error}" + ); + assert!( + !workspace.exists("lib/parcel.dart"), + "{code} wrote an output anyway" + ); + } +} + +/// [typediagram.output]: an output that exists without dmx's marker is +/// hand-written and is never overwritten. +#[test] +fn a_hand_written_output_is_refused() { + let workspace = package(&[ + ("models/parcel.mustache", TEMPLATE), + ("lib/parcel.dart", "// mine\n"), + ]); + let error = workspace.build_failure(); + assert!(error.contains("DMX8006"), "{error}"); + assert_eq!(workspace.read("lib/parcel.dart"), "// mine\n"); +} + +/// [typediagram.output]: a template that goes away takes its output with it. +#[test] +fn a_removed_template_collects_its_output() { + let workspace = package(&[ + ("models/parcel.mustache", TEMPLATE), + ("models/parcel.wire.mustache", WIRE_TEMPLATE), + ]); + let _ = workspace.build(); + assert!(workspace.exists("lib/parcel_wire.dart")); + + fs::remove_file(workspace.path("models/parcel.wire.mustache")).expect("remove the template"); + let report = workspace.build(); + assert!(report.contains("1 of "), "{report}"); + assert!( + !workspace.exists("lib/parcel_wire.dart"), + "a dropped template means a dropped file" + ); + assert!( + workspace.exists("lib/parcel.dart"), + "the other output stays" + ); +} + +/// [typediagram.execution]: `--check` reports drift, writes nothing, and exits +/// 2 — and says nothing once the tree is up to date. +#[test] +fn check_reports_drift_without_writing() { + let workspace = package(&[("models/parcel.mustache", TEMPLATE)]); + let drifted = workspace.dmx(&["build", "models", "lib", "--check"]); + assert_eq!(drifted.status.code(), Some(2)); + let report = String::from_utf8_lossy(&drifted.stdout); + assert!(report.contains("drift: models/parcel.td"), "{report}"); + assert_eq!( + report.matches("drift:").count(), + 1, + "one source drifted, so one line: {report}" + ); + assert!(!workspace.exists("lib/parcel.dart")); + + let _ = workspace.build(); + let clean = workspace.dmx(&["build", "models", "lib", "--check"]); + assert!( + clean.status.success(), + "{}", + String::from_utf8_lossy(&clean.stdout) + ); +} + +/// [typediagram.execution]: `dmx explain` takes the definition or a template +/// bound to it, and answers with the same report either way. +#[test] +fn explain_takes_the_definition_or_its_template() { + let workspace = package(&[("models/parcel.mustache", TEMPLATE)]); + let reports: Vec = ["models/parcel.td", "models/parcel.mustache"] + .into_iter() + .map(|named| { + let output = workspace.dmx(&["explain", named]); + assert!( + output.status.success(), + "`dmx explain {named}`:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + }) + .collect(); + + assert_eq!(reports[0], reports[1], "a template explains its definition"); + let report = &reports[0]; + assert!( + report.contains("models/parcel.td: 1 generation group(s)"), + "{report}" + ); + assert!( + report + .contains("-> lib/parcel.dart (target dart, template models/parcel.mustache, digest "), + "{report}" + ); + assert!(report.contains("group 1 — the definition file"), "{report}"); + assert!( + report.contains("\"template\": \"models/parcel.mustache\""), + "{report}" + ); + assert!(report.contains("\"dartType\": \"String\""), "{report}"); + assert!(!workspace.exists("lib/parcel.dart"), "explain wrote a file"); +} + +/// [typediagram.execution]: the watcher generates on startup, and answers an +/// edit to the *template* — which is not a source of its own — by regenerating +/// the definition it is bound to. +#[test] +fn watch_answers_an_edit_to_either_file() -> io::Result<()> { + let workspace = package(&[("models/parcel.mustache", TEMPLATE)]); + let mut watcher = WatchProcess::spawn_ready_in(workspace.root(), &["models", "lib"])?; + let first = workspace.read("lib/parcel.dart"); + assert!(first.contains("final int weightG;"), "{first}"); + + // The definition. + workspace.write("models/parcel.td", &DEFINITION.replace("Int", "Float")); + watcher.wait_for_line_on("stdout: wrote: ", "parcel.td")?; + let second = workspace.read("lib/parcel.dart"); + assert!(second.contains("final double weightG;"), "{second}"); + + // The template. Nothing generates *from* a `.mustache` file, so what has + // to happen is that its definition runs again. + workspace.write( + "models/parcel.mustache", + &TEMPLATE.replace("final class", "abstract final class"), + ); + watcher.wait_for_line_on("stdout: wrote: ", "parcel.td")?; + let third = workspace.read("lib/parcel.dart"); + assert!(third.contains("abstract final class Parcel {"), "{third}"); + + // An invalid save keeps the last valid output and the watcher alive. + workspace.write("models/parcel.td", "type Parcel {\n weightG:\n}\n"); + watcher.wait_for_line_on("stderr: ", "DMX8004")?; + assert_eq!( + workspace.read("lib/parcel.dart"), + third, + "an invalid definition must leave the last valid output alone" + ); + assert!( + watcher.is_running()?, + "watcher stopped:\n{}", + watcher.output() + ); + Ok(()) +} diff --git a/src/dmx/tests/watch_cli.rs b/src/dmx/tests/watch_cli.rs index 86fc13b..ef76fe0 100644 --- a/src/dmx/tests/watch_cli.rs +++ b/src/dmx/tests/watch_cli.rs @@ -18,14 +18,19 @@ mod support; +#[path = "support/watch.rs"] +mod watch; + use std::fs; -use std::io::{self, BufRead, BufReader, Read}; +use std::io; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Output, Stdio}; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::process::{Command, Output, Stdio}; +use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; + use support::TempDirectory; +use watch::{READY_TIMEOUT, REGENERATION_TIMEOUT, WatchProcess, error_log}; const INITIAL_SOURCE: &str = r"@dmx('model') class User { @@ -35,8 +40,6 @@ class User { } "; -const READY_TIMEOUT: Duration = Duration::from_secs(5); -const REGENERATION_TIMEOUT: Duration = Duration::from_secs(5); const QUIET_PERIOD: Duration = Duration::from_millis(750); struct GeneratedFixture { @@ -80,218 +83,6 @@ impl WatchedGeneratedFixture { } } -struct WatchProcess { - child: Child, - logs: Receiver, - observed: Vec, -} - -impl WatchProcess { - fn spawn_ready(path: &Path) -> io::Result { - let mut watcher = Self::spawn(path)?; - watcher.wait_until_ready(1)?; - Ok(watcher) - } - - fn spawn(path: &Path) -> io::Result { - Self::spawn_args(None, &[path.as_os_str()]) - } - - /// A watcher started *inside* `directory`, watching the relative paths - /// `args` names. - /// - /// A Markdown document's outputs are workspace-relative - /// [typediagram.output], so where the watcher runs is part of what it does - /// — which is the one thing `spawn` cannot express. - fn spawn_ready_in(directory: &Path, args: &[&str]) -> io::Result { - let owned: Vec<&std::ffi::OsStr> = args.iter().map(std::ffi::OsStr::new).collect(); - let mut watcher = Self::spawn_args(Some(directory), &owned)?; - watcher.wait_until_ready(args.len())?; - Ok(watcher) - } - - fn spawn_args(directory: Option<&Path>, args: &[&std::ffi::OsStr]) -> io::Result { - let mut command = Command::new(env!("CARGO_BIN_EXE_dmx")); - let _ = command.arg("watch").args(args); - if let Some(directory) = directory { - let _ = command.current_dir(directory); - } - let mut child = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::other("watch stdout was not piped"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| io::Error::other("watch stderr was not piped"))?; - let (sender, logs) = mpsc::channel(); - spawn_line_reader("stdout", stdout, sender.clone()); - spawn_line_reader("stderr", stderr, sender); - Ok(Self { - child, - logs, - observed: Vec::new(), - }) - } - - fn wait_until_ready(&mut self, root_count: usize) -> io::Result<()> { - let expected = format!("stdout: dmx: watching {root_count} path(s)"); - self.wait_for_log(READY_TIMEOUT, |line| line == expected, &expected) - } - - /// Waits for a line on `stream` carrying `needle`. - /// - /// The exact-match waiters below spell out a whole line because a Dart - /// source's write line is one path and nothing else. A document is named - /// by both its write line and its diagnostics, so what identifies which - /// one arrived is the stream it arrived on. - fn wait_for_line_on(&mut self, stream: &str, needle: &str) -> io::Result<()> { - let prefix = stream.to_owned(); - let expected = format!("{stream}…{needle}"); - self.wait_for_log( - REGENERATION_TIMEOUT, - move |line| line.starts_with(&prefix) && line.contains(needle), - &expected, - ) - } - - fn wait_for_write(&mut self, path: &Path) -> io::Result<()> { - let expected = write_log(path)?; - self.wait_for_log(REGENERATION_TIMEOUT, |line| line == expected, &expected) - } - - fn wait_for_error(&mut self, path: &Path, diagnostic: &str) -> io::Result<()> { - let expected = error_log(path, diagnostic)?; - self.wait_for_log( - REGENERATION_TIMEOUT, - |line| line.starts_with(&expected), - &expected, - ) - } - - fn wait_for_log( - &mut self, - timeout: Duration, - matches: impl Fn(&str) -> bool, - expected: &str, - ) -> io::Result<()> { - let baseline = self.observed.len(); - self.wait_for_observed(timeout, expected, |lines| { - lines[baseline..].iter().any(|line| matches(line)) - }) - } - - fn wait_for_error_and_write( - &mut self, - invalid_path: &Path, - diagnostic: &str, - valid_path: &Path, - ) -> io::Result<()> { - let error = error_log(invalid_path, diagnostic)?; - let write = write_log(valid_path)?; - let expected = format!("`{error}…` and `{write}`"); - let baseline = self.observed.len(); - self.wait_for_observed(REGENERATION_TIMEOUT, &expected, |lines| { - lines[baseline..] - .iter() - .any(|line| line.starts_with(&error)) - && lines[baseline..].iter().any(|line| line == &write) - }) - } - - fn wait_for_observed( - &mut self, - timeout: Duration, - expected: &str, - complete: impl Fn(&[String]) -> bool, - ) -> io::Result<()> { - let deadline = Instant::now() + timeout; - loop { - if complete(&self.observed) { - return Ok(()); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - match self.logs.recv_timeout(remaining) { - Ok(line) => self.observed.push(line), - Err(RecvTimeoutError::Timeout) => { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - format!( - "watcher never emitted `{expected}`; output:\n{}", - self.output() - ), - )); - } - Err(RecvTimeoutError::Disconnected) => { - return Err(io::Error::new( - io::ErrorKind::BrokenPipe, - format!( - "watcher exited before emitting `{expected}`; output:\n{}", - self.output() - ), - )); - } - } - } - } - - fn observe_for(&mut self, duration: Duration) { - let deadline = Instant::now() + duration; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break; - } - match self.logs.recv_timeout(remaining) { - Ok(line) => self.observed.push(line), - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break, - } - } - } - - fn writes(&self) -> Vec<&str> { - self.observed - .iter() - .map(String::as_str) - .filter(|line| line.starts_with("stdout: wrote: ")) - .collect() - } - - fn output(&self) -> String { - self.observed.join("\n") - } - - fn is_running(&mut self) -> io::Result { - self.child.try_wait().map(|status| status.is_none()) - } -} - -impl Drop for WatchProcess { - fn drop(&mut self) { - // Still running, so end it; already gone or unknowable, so nothing to - // do — a test fixture cannot report a failure from `drop` anyway. - if let Ok(None) = self.child.try_wait() { - let _ = self.child.kill(); - } - let _ = self.child.wait(); - } -} - -fn write_log(path: &Path) -> io::Result { - Ok(format!("stdout: wrote: {}", path.canonicalize()?.display())) -} - -fn error_log(path: &Path, diagnostic: &str) -> io::Result { - Ok(format!( - "stderr: error: {}: {diagnostic}", - path.canonicalize()?.display() - )) -} - fn assert_one_write_and_running(watcher: &mut WatchProcess, context: &str) -> io::Result<()> { assert_eq!(watcher.writes().len(), 1, "output:\n{}", watcher.output()); assert!( @@ -316,24 +107,6 @@ fn assert_quiet_and_running(watcher: &mut WatchProcess, context: &str) -> io::Re Ok(()) } -fn spawn_line_reader( - stream_name: &'static str, - stream: impl Read + Send + 'static, - sender: Sender, -) { - drop(thread::spawn(move || { - for result in BufReader::new(stream).lines() { - let line = match result { - Ok(line) => line, - Err(error) => format!("could not read {stream_name}: {error}"), - }; - if sender.send(format!("{stream_name}: {line}")).is_err() { - break; - } - } - })); -} - fn build_initial_region(source_path: &Path) -> io::Result<()> { let output = Command::new(env!("CARGO_BIN_EXE_dmx")) .arg("build") @@ -953,21 +726,7 @@ fn watch_rejects_an_explicit_unsupported_file_without_reporting_readiness() -> i /// the members stayed deleted no matter how often the file was saved. #[test] fn watch_regenerates_a_region_gutted_by_hand() -> io::Result<()> { - let mut fixture = WatchedGeneratedFixture::create()?; - let source_path = fixture.source_path(); - - let gutted = gut_generated_region(&fixture.initial_source)?; - assert!( - !gutted.contains("Map toJson()"), - "the fixture removed no generated members, so this proves nothing:\n{gutted}" - ); - fs::write(&source_path, &gutted)?; - - // Byte equality with the healthy source: every member must return, not just - // enough of one to satisfy a substring probe. - wait_for_source(&source_path, &fixture.initial_source, REGENERATION_TIMEOUT)?; - fixture.watcher.wait_for_write(&source_path)?; - assert_one_write_and_running(&mut fixture.watcher, "after repairing a gutted region") + watch_repairs(gut_generated_region, "after repairing a gutted region") } /// Verifies an emptied — but still parseable — region is refilled [execution.modes]. @@ -976,19 +735,31 @@ fn watch_regenerates_a_region_gutted_by_hand() -> io::Result<()> { /// keep working exactly as before. #[test] fn watch_refills_a_region_emptied_without_breaking_the_file() -> io::Result<()> { + watch_repairs(empty_generated_region, "after refilling an emptied region") +} + +/// Damages a watched source with `damage` and proves one save brings every +/// generated member back [emission.inline-backend.region-recovery]. +/// +/// The two tests above differ only in the damage they inflict, and what is +/// being verified is the same sentence either way: the file that comes back is +/// byte-for-byte the healthy one, in one write, from a watcher still running. +fn watch_repairs(damage: fn(&str) -> io::Result, context: &str) -> io::Result<()> { let mut fixture = WatchedGeneratedFixture::create()?; let source_path = fixture.source_path(); - let emptied = empty_generated_region(&fixture.initial_source)?; + let damaged = damage(&fixture.initial_source)?; assert!( - !emptied.contains("Map toJson()"), - "the fixture removed no generated members:\n{emptied}" + !damaged.contains("Map toJson()"), + "the fixture removed no generated members, so this proves nothing:\n{damaged}" ); - fs::write(&source_path, &emptied)?; + fs::write(&source_path, &damaged)?; + // Byte equality with the healthy source: every member must return, not just + // enough of one to satisfy a substring probe. wait_for_source(&source_path, &fixture.initial_source, REGENERATION_TIMEOUT)?; fixture.watcher.wait_for_write(&source_path)?; - assert_one_write_and_running(&mut fixture.watcher, "after refilling an emptied region") + assert_one_write_and_running(&mut fixture.watcher, context) } /// Verifies repair is repeatable, not a one-shot [emission.inline-backend.region-recovery]. diff --git a/src/editors/vscode/e2e/fixture.js b/src/editors/vscode/e2e/fixture.js index bb0b87b..0f75f9b 100644 --- a/src/editors/vscode/e2e/fixture.js +++ b/src/editors/vscode/e2e/fixture.js @@ -48,4 +48,32 @@ That is the whole document. `; } -module.exports = { annotatedClass, document }; +// A standalone definition and the template beside it [typediagram.standalone]: +// files, nothing embedded in anything, and no Dart source of truth. The +// template carries no metadata at all — the convention answers both questions +// it could ask — and it takes the canonical model template's place, which is +// what makes this fixture about the extension's wiring rather than about what +// dmx generates [typediagram.canonical]. + +function definition(typeName, fieldName) { + return `# ${typeName}, and nothing else in this file. +type ${typeName} { + ${fieldName}: String +} +`; +} + +function template() { + return `{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +`; +} + +module.exports = { annotatedClass, definition, document, template }; diff --git a/src/editors/vscode/e2e/run.js b/src/editors/vscode/e2e/run.js index 8cdc55e..c063434 100644 --- a/src/editors/vscode/e2e/run.js +++ b/src/editors/vscode/e2e/run.js @@ -14,7 +14,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { runTests } = require('@vscode/test-electron'); -const { annotatedClass, document } = require('./fixture.js'); +const { annotatedClass, definition, document, template } = require('./fixture.js'); const BINARY = process.platform === 'win32' ? 'dmx.exe' : 'dmx'; @@ -39,6 +39,12 @@ function writeWorkspace(workspace) { // has to find it, watch it, and generate the file it names. fs.mkdirSync(path.join(workspace, 'docs'), { recursive: true }); fs.writeFileSync(path.join(workspace, 'docs', 'shipping.dmx.md'), document('Parcel', 'tracking')); + // A standalone definition with the template beside it + // [typediagram.standalone]: the extension has to find the `.td`, watch it, + // and answer an edit to either file. + fs.mkdirSync(path.join(workspace, 'models'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'models', 'crate.td'), definition('Crate', 'code')); + fs.writeFileSync(path.join(workspace, 'models', 'crate.mustache'), template()); } async function main() { diff --git a/src/editors/vscode/e2e/suite/watch.e2e.js b/src/editors/vscode/e2e/suite/watch.e2e.js index ab68e10..79d51b3 100644 --- a/src/editors/vscode/e2e/suite/watch.e2e.js +++ b/src/editors/vscode/e2e/suite/watch.e2e.js @@ -288,6 +288,45 @@ describe('the packaged VSIX, running the engine it carries', () => { ); }); + it('generates from a standalone .td definition, and answers an edit to either file', async () => { + // Three files and no wrapper [typediagram.standalone]: models/crate.td, + // models/crate.mustache, and the lib/crate.dart the extension writes. + await until('the definition to generate lib/crate.dart', () => { + try { + return read('lib/crate.dart').includes('final class Crate {'); + } catch { + return false; + } + }); + const generated = read('lib/crate.dart'); + assert.ok( + generated.startsWith('// dmx: generated from models/crate.td'), + `lib/crate.dart carries no ownership marker:\n${generated}`, + ); + assert.match(generated, /rendered through models\/crate\.mustache/); + assert.match(generated, /const Crate\(\{required this\.code\}\);/); + + // Saving the definition regenerates it, with no command. + const definitionFile = await openInEditor('models/crate.td'); + await editOnce(definitionFile.editor, 'code: String', 'code: String\n weightG: Int'); + assert.ok(await definitionFile.document.save(), 'models/crate.td did not save'); + await until('the saved definition to regenerate lib/crate.dart', () => + read('lib/crate.dart').includes('final int weightG;'), + ); + + // And so does saving the TEMPLATE, which is not a source of its own. + const templateFile = await openInEditor('models/crate.mustache'); + await editOnce(templateFile.editor, 'final class', 'abstract final class'); + assert.ok(await templateFile.document.save(), 'models/crate.mustache did not save'); + await until('the saved template to regenerate lib/crate.dart', () => + read('lib/crate.dart').includes('abstract final class Crate {'), + ); + + // Neither source is ever rewritten. + assert.ok(read('models/crate.td').includes('# Crate, and nothing else in this file.')); + assert.ok(read('models/crate.mustache').includes('{{#declarations}}')); + }); + it('stop, build, and restart from the palette all drive the real engine', async () => { await vscode.commands.executeCommand('dmx.stopWatcher'); diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 87d5a11..fd20902 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -49,6 +49,7 @@ "activationEvents": [ "workspaceContains:**/pubspec.yaml", "workspaceContains:**/*.dmx.md", + "workspaceContains:**/*.td", "onLanguage:dart" ], "contributes": { diff --git a/src/editors/vscode/paths.js b/src/editors/vscode/paths.js index fa411be..be0b237 100644 --- a/src/editors/vscode/paths.js +++ b/src/editors/vscode/paths.js @@ -9,9 +9,9 @@ // the editor, from a generator that has stopped working. // // So: what the setting names, if it exists, and otherwise every Dart package -// this folder actually holds — plus every `*.dmx.md` document in it, because a -// document generates Dart with no annotated Dart source to find it by -// [typediagram.documents]. +// this folder actually holds — plus every `*.td` definition and `*.dmx.md` +// document in it, because both generate Dart with no annotated Dart source to +// find them by [typediagram.standalone], [typediagram.documents]. const fs = require('node:fs'); const path = require('node:path'); @@ -74,17 +74,23 @@ function packageLibraries(root, depth = MAX_DEPTH) { return found; } -/// The suffix that makes a Markdown file one dmx generates from. -const DOCUMENT_SUFFIX = '.dmx.md'; +/// The suffixes that make a file one dmx generates from without any annotated +/// Dart to find it by: a standalone typeDiagram definition +/// [typediagram.standalone] and a Markdown document [typediagram.documents]. +const SOURCE_SUFFIXES = ['.td', '.dmx.md']; -/// Every `*.dmx.md` document under `root`, as workspace-relative file paths. +/// Every such file under `root`, as workspace-relative file paths. /// /// Files rather than their directories: `dmx watch` takes either, and naming -/// the document watches exactly it, where naming `docs/` would watch a whole -/// tree of prose for changes that can never matter. A package's own -/// subdirectories ARE searched, unlike `packageLibraries` — a document -/// normally lives in the package whose `lib` it generates into. -function documents(root, depth = MAX_DEPTH + 1) { +/// the file watches exactly it, where naming `docs/` would watch a whole tree +/// of prose for changes that can never matter. A package's own subdirectories +/// ARE searched, unlike `packageLibraries` — these files normally live in the +/// package whose `lib` they generate into. +/// +/// A `.td` file's templates are NOT named. They sit beside it, and `dmx watch` +/// answers an edit to one by regenerating the definition it belongs to — the +/// binding rule lives in the binary, which is the only place it can be right. +function sources(root, depth = MAX_DEPTH + 1) { const found = []; let entries = []; try { @@ -96,10 +102,10 @@ function documents(root, depth = MAX_DEPTH + 1) { if (entry.name.startsWith('.') || SKIPPED.has(entry.name)) { continue; } - if (entry.isFile() && entry.name.endsWith(DOCUMENT_SUFFIX)) { + if (entry.isFile() && SOURCE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) { found.push(entry.name); } else if (entry.isDirectory() && depth > 1) { - found.push(...documents(path.join(root, entry.name), depth - 1).map((relative) => path.join(entry.name, relative))); + found.push(...sources(path.join(root, entry.name), depth - 1).map((relative) => path.join(entry.name, relative))); } } return found; @@ -116,7 +122,7 @@ function watchTargets(root, configured, explicit) { if (explicit) { return present; } - return [...new Set([...present, ...packageLibraries(root), ...documents(root)])]; + return [...new Set([...present, ...packageLibraries(root), ...sources(root)])]; } -module.exports = { documents, packageLibraries, watchTargets }; +module.exports = { packageLibraries, sources, watchTargets }; diff --git a/src/editors/vscode/test/paths.test.js b/src/editors/vscode/test/paths.test.js index 1a2c934..5ae4efa 100644 --- a/src/editors/vscode/test/paths.test.js +++ b/src/editors/vscode/test/paths.test.js @@ -11,7 +11,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); -const { documents, packageLibraries, watchTargets } = require('../paths.js'); +const { packageLibraries, sources, watchTargets } = require('../paths.js'); /// A throwaway workspace holding `directories`, each made a package when its /// entry says so. @@ -104,17 +104,20 @@ function withFiles(layout, files) { return root; } -test('every *.dmx.md document is watched, wherever it lives', () => { - const root = withFiles({ 'packages/store': true, docs: false }, [ +test('every definition and document is watched, wherever it lives', () => { + const root = withFiles({ 'packages/store': true, docs: false, models: false }, [ 'models.dmx.md', 'docs/shipping.dmx.md', + 'models/shipping.td', + 'models/shipping.mustache', 'packages/store/docs/store.dmx.md', 'docs/README.md', 'packages/store/lib/notes.md', ]); - assert.deepEqual(documents(root), [ + assert.deepEqual(sources(root), [ path.join('docs', 'shipping.dmx.md'), + path.join('models', 'shipping.td'), 'models.dmx.md', path.join('packages', 'store', 'docs', 'store.dmx.md'), ]); @@ -122,20 +125,25 @@ test('every *.dmx.md document is watched, wherever it lives', () => { const targets = watchTargets(root, ['lib'], false); assert.ok(targets.includes(path.join('packages', 'store', 'lib')), targets.join(', ')); assert.ok(targets.includes(path.join('docs', 'shipping.dmx.md')), targets.join(', ')); + assert.ok(targets.includes(path.join('models', 'shipping.td')), targets.join(', ')); + // A template is watched by the binary, through the definition beside it — + // naming it here would watch it twice and generate from it never. + assert.ok(!targets.includes(path.join('models', 'shipping.mustache')), targets.join(', ')); assert.ok(!targets.includes(path.join('docs', 'README.md')), targets.join(', ')); }); -test('build output and hidden directories hold no documents worth watching', () => { +test('build output and hidden directories hold no sources worth watching', () => { const root = withFiles({ build: false, node_modules: false, '.git': false }, [ 'build/generated.dmx.md', - 'node_modules/pkg/thing.dmx.md', + 'node_modules/pkg/thing.td', '.git/stash.dmx.md', 'kept.dmx.md', + 'kept.td', ]); - assert.deepEqual(documents(root), ['kept.dmx.md']); + assert.deepEqual(sources(root), ['kept.dmx.md', 'kept.td']); }); -test('explicit paths are honoured exactly, documents included or not', () => { +test('explicit paths are honoured exactly, sources included or not', () => { const root = withFiles({ docs: false }, ['docs/shipping.dmx.md']); assert.deepEqual(watchTargets(root, [path.join('docs', 'shipping.dmx.md')], true), [ path.join('docs', 'shipping.dmx.md'), diff --git a/website/e2e/navigation.spec.ts b/website/e2e/navigation.spec.ts index 60eb0ec..33c6913 100644 --- a/website/e2e/navigation.spec.ts +++ b/website/e2e/navigation.spec.ts @@ -168,10 +168,10 @@ test("shows the blog post image on the article and blog listing", async ({ page ? image.naturalWidth : 0)).toBeGreaterThan(0); - await page.goto("/docs/models-in-markdown/"); - await expect(page.getByRole("heading", { level: 1, name: "Models in Markdown" })).toBeVisible(); + await page.goto("/docs/models-from-a-diagram/"); + await expect(page.getByRole("heading", { level: 1, name: "Models from a diagram" })).toBeVisible(); await expect( - page.getByText("A template belongs to the typeDiagram fence", { exact: false }), + page.getByText("By its name, and by nothing else", { exact: false }), ).toBeVisible(); await page.goto("/blog/"); @@ -221,7 +221,7 @@ test("serves the TechDoc documentation and blog structure", async ({ page }) => "Getting started", "Dart (Custom) Macros", "Macro catalogue", - "Models in Markdown", + "Models from a diagram", ]); await page.goto("/docs/dart-custom-macros/"); diff --git a/website/src/docs/index.md b/website/src/docs/index.md index 054c4a6..7939c65 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -28,9 +28,9 @@ program in your own project, for generating something the built-ins do not cover — reading a database schema, say, or an API document. Not every model starts as Dart. When the types live in a design document rather -than in a class, you can write them once in a `*.dmx.md` file and let Mustache -templates under the diagram write the Dart — -see **[Models in Markdown](/docs/models-in-markdown/)**. +than in a class, write them once in a `.td` definition and dmx writes the Dart: +immutable classes that compare by value, with JSON beside them instead of +inside them — see **[Models from a diagram](/docs/models-from-a-diagram/)**. You opt in with the package's single annotation type: diff --git a/website/src/docs/models-from-a-diagram.md b/website/src/docs/models-from-a-diagram.md new file mode 100644 index 0000000..fce93b0 --- /dev/null +++ b/website/src/docs/models-from-a-diagram.md @@ -0,0 +1,299 @@ +--- +layout: layouts/docs.njk +title: Models from a diagram +description: Define your types once as a typeDiagram definition and dmx writes the Dart — immutable classes with value equality and JSON beside them. +eleventyNavigation: + key: Models from a diagram + order: 4 +--- + +# Models from a diagram + +Sometimes there is no Dart file to annotate yet. The types exist in a design +document, an API contract, or somebody's head, and writing them out in Dart +first — then annotating that Dart — is work you only do so that a generator has +something to read. + +A [typeDiagram](https://typediagram.dev/docs/) definition skips it. You write +the types once and save. dmx writes the Dart. + +## Two files + +```text +models/parcel.td the definition +lib/parcel.dart what dmx writes +``` + +Nothing is embedded in anything. `parcel.td` is pure typeDiagram — the same +file any typeDiagram tool reads, and the same one that renders as a diagram: + +```typeDiagram +type Parcel { + id: Uuid + weightG: Int + insured: Option + labels: List +} +``` + +Run `dmx build models lib` — or leave the watcher running and just save — and +`lib/parcel.dart` appears: + +```dart +// dmx: generated from models/parcel.td — do not edit. +// dmx: rendered through the canonical model template, definition bd16c86d…, template b1cdad67…, context v1, dmx 0.3.0. + +// Generated from models/parcel.td. Edit the definition, not this file. + +import 'package:dmx/dmx.dart' as dmx; + +/// Parcel — an immutable value from the diagram. +final class Parcel { + /// Every field, in the order the diagram declares them. + const Parcel({required this.id, required this.weightG, this.insured, required this.labels}); + + final String id; + final int weightG; + final String? insured; + final List labels; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Parcel && + other.id == id && + other.weightG == weightG && + other.insured == insured && + dmx.dmxDeepEquals(other.labels, labels)); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + weightG, + insured, + dmx.dmxDeepHash(labels), + ); + + @override + String toString() => 'Parcel(id: $id, weightG: $weightG, insured: $insured, labels: $labels)'; + + /// A copy of this value with the named fields replaced. + Parcel copyWith({ + String? id, + int? weightG, + dmx.DmxPatch insured = const dmx.DmxKeep(), + List? labels, + }) => …; +} + +/// JSON for [Parcel]. +extension ParcelJson on Parcel { + static dmx.Result fromJson(Object? json, [String path = 'Parcel']) => …; + + Map toJson() => …; +} +``` + +## One canonical model template + +There is no template in that project, and there did not need to be. A +definition with nothing beside it renders through the **canonical model +template** dmx ships — one template, compiled into the binary, and every model +class dmx generates from a diagram comes out of it. + +What it writes is a value, not a bag of fields. Two parcels built from the same +data are equal and hash alike; the `List` compares by content, not by +reference; a nullable field's `copyWith` tells "leave it alone" apart from "set +it to null". A union becomes a sealed class with one immutable case per +variant, and an alias becomes a `typedef`. + +**JSON lives beside the class, not inside it.** `toJson` and `fromJson` are on +the `ParcelJson` extension, so the class reads as exactly what the diagram said +and nothing more. Nested types decode through their own extensions, and a +union's extension reads the case's tag out of the payload. + +A declaration dmx cannot build a codec for still gets its class and its value +semantics — it just has no extension. That happens for a generic declaration, an +untagged union, a `Unit` member, and a map keyed by anything but a string; +`dmx explain` names the reason. + +Note what none of this asked you to write. `Option` became `String?` +and `Uuid` became `String` before anything was rendered, and every comparison, +hash, and decode expression was finished in Rust. Templates place prepared +values; they never work out Dart types. + +## Deciding the shape yourself + +Put `parcel.mustache` beside `parcel.td` and it takes the canonical template's +place. It is pure Mustache — no front matter, no directives, nothing an engine +would choke on: + +{% raw %} +```mustache +{{#declarations}} +final class {{name}} { + const {{name}}({{{constructorParameters}}}); +{{#fields}} + final {{{dartType}}} {{name}}; +{{/fields}} +} +{{/declarations}} +``` +{% endraw %} + +Save, and `lib/parcel.dart` is what your template says instead. + +## How a template binds to a definition + +By its name, and by nothing else. `parcel.mustache` renders the `parcel.td` +beside it. A dotted suffix is a second template over the same definition: + +| Template | Definition | Output | +| --- | --- | --- | +| none | `parcel.td` | `lib/parcel.dart`, from the canonical model template | +| `parcel.mustache` | `parcel.td` | `lib/parcel.dart`, instead of the canonical one | +| `parcel.wire.mustache` | `parcel.td` | `lib/parcel_wire.dart`, as well | + +Every file is a function of the same definition, so they cannot disagree. Add a +field and they all change. Delete a template and its file is removed — except +the first, which goes back to the canonical template. + +A `.mustache` file with no definition beside it is somebody else's Mustache file +and is left alone. When a template's name matches two definitions — `parcel.td` +and `parcel.wire.td` both present — it binds to the longer one. + +## Sending the output somewhere else + +By default a template writes to `lib/`, under the name it has, in the extension +its target generates. To send it elsewhere, put a Mustache comment on the first +line: + +{% raw %} +```mustache +{{! dmx output=lib/models/parcel.dart target=dart }} +``` +{% endraw %} + +It is a comment, so every Mustache engine renders it to nothing and the +template is still an ordinary template. + +| Setting | Meaning | +| --- | --- | +| `output` | The file to write, relative to the package the definition belongs to — the nearest `pubspec.yaml`. | +| `target` | Optional, `dart` by default. The language the output is written in. | + +`output` cannot be an absolute path, cannot climb out of the package with `..`, +cannot be the definition itself, and must end in the extension its target +generates. A misspelled key is reported rather than silently ignored. The +settings are `key=value` pairs rather than JSON because a Mustache comment ends +at the first `}` inside it. + +## Or keep it all on one page + +When the model, the diagram, and the prose explaining them belong together, put +the definition and its templates in a `*.dmx.md` document instead. A template +binds to the typeDiagram fence **immediately above it**; blank lines are fine, +anything else — a heading, a paragraph, another fence — ends the group: + +{% raw %} +````markdown +# Shipping + +```typeDiagram +type Parcel { id: Uuid, weightG: Int } +``` + +```mustache {"dmx":{"output":"lib/parcel.dart"}} +…the model classes… +``` + +```mustache {"dmx":{"output":"lib/parcel_wire.dart"}} +…the wire-name table… +``` +```` +{% endraw %} + +Here `dmx.output` is required — a fence has no file name to derive one from — +and `dmx.target` is the same optional key. Everything else in the document — +prose, headings, links, code in other languages — is left exactly as you wrote +it. dmx never rewrites the document. + +## Seeing what a template will get + +`dmx explain` prints each generation group, the files it writes, the digests +its outputs depend on, and the exact context the templates will render against: + +```bash +dmx explain models/parcel.td +``` + +It writes nothing, and it takes a definition, a template bound to one, or a +`*.dmx.md` document. It is the fastest way to find out what a name is called +before you use it. + +## What the templates can read + +The root of the context carries `modelVersion`, `target`, `source`, and +`declarations`. Every declaration appears once, in the order you wrote it, with +mutually exclusive `isRecord`, `isUnion`, `isAlias` and `isFunction` flags, so a +template selects a shape rather than filtering a list. + +| Name | On | What it is | +| --- | --- | --- | +| `name`, `camelName`, `pascalName`, `snakeName`, `screamingSnakeName`, `label` | declarations, fields, variants | The identifier, in every casing | +| `genericDeclaration` | declarations | ``, or empty | +| `constructorParameters` | records, variants | `{required this.a, this.b}`, ready to place | +| `dartType`, `targetType` | fields, aliases, returns | The Dart type text, already resolved | +| `typeDiagram` | fields | The type as the diagram spells it | +| `isOptional`, `isRequired`, `parameter` | fields | Whether it is an `Option`, and its constructor fragment | +| `owner`, `ownerGenericDeclaration` | variants | The union the variant belongs to, which its own `name` would otherwise hide | +| `discriminant`, `hasDiscriminant`, `isTuple`, `isBare` | variants | The variant's shape | +| `untagged` | unions | Whether the cases are told apart by shape rather than a tag | +| `signatures`, `hasOverloads` | functions | Every overload, and whether there is more than one | +| `parameterList`, `returnType`, `isAsync`, `params`, `isOverload` | signatures | One signature, ready to place | +| `first`, `last`, `comma`, `index` | every list member | Separators without arithmetic | + +A tuple variant's positional members arrive as `value1`, `value2`, … The +diagram spells them `_0`, `_1`, and the model keeps that spelling, but a +leading underscore is private in Dart — illegal as a named constructor +parameter and dead as a field — so the target sees a name it can compile. + +## Two things worth knowing before you write a template + +{% raw %} +**Use `{{{triple}}}` braces for anything holding a type.** `{{name}}` escapes +its value as HTML, so `{{genericDeclaration}}` renders `` as `<T>` and +the file fails validation rather than being written. Every value that can hold +`<`, `>` or `&` — `dartType`, `targetType`, `genericDeclaration`, +`ownerGenericDeclaration`, `parameterList`, `returnType`, +`constructorParameters` — wants triple braces. + +**A section reads names from the level it was entered on.** Opening +`{{#hasOverloads}}` inside `{{#signatures}}` finds `hasOverloads` on the +*function*, so `{{index}}` inside that section is the function's ordinal, not +the signature's. That is why a signature carries its own `isOverload`: entering +the section on the signature's own name keeps the signature in scope. +{% endraw %} + +## Where the definitions come from + +dmx reads the typeDiagram language itself, in Rust. Installing dmx installs +nothing else: no Node, no npm package, no `typediagram` executable, and no +network access at build time. A compatibility corpus in the dmx repository holds +the parser to typeDiagram's own, fixture by fixture, so the two cannot drift +apart quietly. + +The definition supplies the model. Mustache decides every generated byte. + +## Safety + +Generated files carry an ownership marker on their first line. dmx will not +overwrite a file that does not have one, so a hand-written file is never lost to +a typo in an output path. Rendered source is parsed as a complete file before +anything is written, and checked for the constructs generated code may not +contain — `throw`, `as` casts, `!` null assertions — so a template mistake fails +the build instead of shipping. + +`dmx build --check` writes nothing and exits non-zero when an output is out of +date, which is what CI should run. diff --git a/website/src/docs/models-in-markdown.md b/website/src/docs/models-in-markdown.md deleted file mode 100644 index f260c21..0000000 --- a/website/src/docs/models-in-markdown.md +++ /dev/null @@ -1,198 +0,0 @@ ---- -layout: layouts/docs.njk -title: Models in Markdown -description: Define your types once in a *.dmx.md document and let Mustache templates write the Dart files. -eleventyNavigation: - key: Models in Markdown - order: 4 ---- - -# Models in Markdown - -Sometimes there is no Dart file to annotate yet. The types exist in a design -document, an API contract, or somebody's head, and writing them out in Dart -first — then annotating that Dart — is work you only do so that a generator has -something to read. - -A `*.dmx.md` document skips it. You write the types once, in a -[typeDiagram](https://typediagram.dev/docs/) fence, and put the Mustache -templates that generate from them immediately below. Save the document and dmx -writes the `.dart` files those templates name. - -The fence is an ordinary typeDiagram fence, so the same page still renders as a -diagram anywhere typeDiagram is supported. One page is the model, the -documentation, and the build input. - -## A whole document - -{% raw %} -````markdown -# Shipping - -```typeDiagram -type Parcel { - id: Uuid - weightG: Int - insured: Option - labels: List -} -``` - -```mustache {"dmx":{"output":"lib/parcel.dart"}} -{{#declarations}} -final class {{name}} { - const {{name}}({{{constructorParameters}}}); -{{#fields}} - final {{{dartType}}} {{name}}; -{{/fields}} -} -{{/declarations}} -``` -```` -{% endraw %} - -Save it and `lib/parcel.dart` appears: - -```dart -// dmx: generated from docs/shipping.dmx.md — do not edit. -// dmx: group 1, fences 1/2, definition bd16c86d…, template abae1bb2…, context v1, dmx 0.3.0. - -final class Parcel { - const Parcel({required this.id, required this.weightG, this.insured, required this.labels}); - - final String id; - final int weightG; - final String? insured; - final List labels; -} -``` - -Note what the template did not have to do. `Option` became `String?` -and `List` became `List` before the template ran, and -`constructorParameters` arrived already written — `required` on the fields that -need it, plain on the optional one. Templates place prepared values; they never -work out Dart types. - -## How a template binds to a definition - -A template belongs to the typeDiagram fence **immediately above it**. Blank -lines are fine; anything else — a heading, a paragraph, another fence — ends -the group. Nothing depends on a heading's text or on where the fence sits in -the document, so a template can never quietly attach to the wrong definition. - -One definition can feed several templates, as long as their fences follow it -one after another: - -````markdown -```typeDiagram -type Parcel { id: Uuid, weightG: Int } -``` - -```mustache {"dmx":{"output":"lib/parcel.dart"}} -…the model classes… -``` - -```mustache {"dmx":{"output":"lib/parcel_wire.dart"}} -…the wire-name table… -``` -```` - -Both files are functions of the same definition, so they cannot disagree. Add a -field and both change. Delete a template fence and its file is removed. - -A typeDiagram fence with no template under it is documentation and generates -nothing. A `mustache` fence with no `dmx` metadata is an example and generates -nothing. Everything else in the document — prose, headings, links, code in -other languages — is left exactly as you wrote it. dmx never rewrites the -document. - -## The fence metadata - -The JSON object after `mustache` is the whole configuration: - -| Key | Meaning | -| --- | --- | -| `dmx.output` | Required. The file to write, relative to the package the document belongs to — the nearest `pubspec.yaml`. | -| `dmx.target` | Optional, `dart` by default. The language the output is written in. | - -`dmx.output` cannot be an absolute path, cannot climb out of the package with -`..`, cannot be the document itself, and must end in the extension its target -generates. A misspelled key is reported rather than silently ignored. - -## Seeing what a template will get - -`dmx explain` prints each generation group, the files it writes, the digests -its outputs depend on, and the exact context the templates will render against: - -```bash -dmx explain docs/shipping.dmx.md -``` - -It writes nothing. It is the fastest way to find out what a name is called -before you use it. - -## What the templates can read - -The root of the context carries `modelVersion`, `target`, `source`, and -`declarations`. Every declaration appears once, in the order you wrote it, with -mutually exclusive `isRecord`, `isUnion`, `isAlias` and `isFunction` flags, so a -template selects a shape rather than filtering a list. - -| Name | On | What it is | -| --- | --- | --- | -| `name`, `camelName`, `pascalName`, `snakeName`, `screamingSnakeName`, `label` | declarations, fields, variants | The identifier, in every casing | -| `genericDeclaration` | declarations | ``, or empty | -| `constructorParameters` | records, variants | `{required this.a, this.b}`, ready to place | -| `dartType`, `targetType` | fields, aliases, returns | The Dart type text, already resolved | -| `typeDiagram` | fields | The type as the diagram spells it | -| `isOptional`, `isRequired`, `parameter` | fields | Whether it is an `Option`, and its constructor fragment | -| `owner`, `ownerGenericDeclaration` | variants | The union the variant belongs to, which its own `name` would otherwise hide | -| `discriminant`, `hasDiscriminant`, `isTuple`, `isBare` | variants | The variant's shape | -| `untagged` | unions | Whether the cases are told apart by shape rather than a tag | -| `signatures`, `hasOverloads` | functions | Every overload, and whether there is more than one | -| `parameterList`, `returnType`, `isAsync`, `params`, `isOverload` | signatures | One signature, ready to place | -| `first`, `last`, `comma`, `index` | every list member | Separators without arithmetic | - -A tuple variant's positional members arrive as `value1`, `value2`, … The -diagram spells them `_0`, `_1`, and the model keeps that spelling, but a -leading underscore is private in Dart — illegal as a named constructor -parameter and dead as a field — so the target sees a name it can compile. - -## Two things worth knowing before you write a template - -{% raw %} -**Use `{{{triple}}}` braces for anything holding a type.** `{{name}}` escapes -its value as HTML, so `{{genericDeclaration}}` renders `` as `<T>` and -the file fails validation rather than being written. Every value that can hold -`<`, `>` or `&` — `dartType`, `targetType`, `genericDeclaration`, -`ownerGenericDeclaration`, `parameterList`, `returnType`, -`constructorParameters` — wants triple braces. - -**A section reads names from the level it was entered on.** Opening -`{{#hasOverloads}}` inside `{{#signatures}}` finds `hasOverloads` on the -*function*, so `{{index}}` inside that section is the function's ordinal, not -the signature's. That is why a signature carries its own `isOverload`: entering -the section on the signature's own name keeps the signature in scope. -{% endraw %} - -## Where the definitions come from - -dmx reads the typeDiagram language itself, in Rust. Installing dmx installs -nothing else: no Node, no npm package, no `typediagram` executable, and no -network access at build time. A compatibility corpus in the dmx repository holds -the parser to typeDiagram's own, fixture by fixture, so the two cannot drift -apart quietly. - -The definition supplies the model. Mustache decides every generated byte. - -## Safety - -Generated files carry an ownership marker on their first line. dmx will not -overwrite a file that does not have one, so a hand-written file is never lost to -a typo in an output path. Rendered source is parsed as a complete file before -anything is written, and checked for the constructs generated code may not -contain — `throw`, `as` casts, `!` null assertions — so a template mistake fails -the build instead of shipping. - -`dmx build --check` writes nothing and exits non-zero when an output is out of -date, which is what CI should run. From 3cde9771c1a8fc88e8e4b1a5bd1eed5e97b39e84 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:29:49 +1000 Subject: [PATCH 4/4] Name the canonical template in the corpus recipe, not the deleted per-corpus one --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 37aef78..34f6e05 100644 --- a/Makefile +++ b/Makefile @@ -466,9 +466,9 @@ corpus: ## Generate every golden sample and prove it is valid Dart cargo run $(CRATE) --quiet -- build $(CORPUS_DIR)/lib --insert-regions cd $(CORPUS_DIR) && dart pub get && dart analyze --fatal-infos @# The typeDiagram corpus is generated the other way round: no annotated - @# Dart at all, just `tests/typediagram/corpus/*.td` rendered through - @# `tests/typediagram/golden/template.mustache`. `cargo test --test - @# typediagram_golden` proves the committed files are what the binary + @# Dart at all, just `tests/typediagram/corpus/*.td` rendered through the + @# canonical model template dmx ships [typediagram.canonical]. `cargo test + @# --test typediagram_golden` proves the committed files are what the binary @# writes; this proves they are Dart the analyzer accepts, which is the @# half a byte comparison cannot do. cd $(TD_GOLDEN_DIR) && dart pub get && dart analyze --fatal-infos