Skip to content

Repository files navigation

dmx

Dart code generation that runs when you save. Annotate a class, and copyWith, equality, hashCode, toString and typed JSON appear inside the same file, below a divider — no part files, no .g.dart, no mixins, no delegating factories, and no build_runner run.

Try it without installing anything

Run the real generator in your browser →

The playground compiles this repository's Rust generator to WebAssembly, so it builds the same context, renders the same Mustache template, validates the same way, and splices the same region as the CLI. Edit the Dart and the template. Neither input leaves the tab.

Install

VS Code — install dmx — Dart code generation from the Marketplace, or from a terminal:

code --install-extension nimblesite.dmx

The extension bundles the dmx binary and starts watching when you open a trusted Dart workspace. There is no Rust, no Cargo, and no command to run.

Any other editor — install the CLI and leave the watcher running:

brew install nimblesite/tap/dmx   # or: scoop install dmx
dmx watch lib

Getting started

Add the runtime the generated code composes with:

dart pub add dmx

Annotate a class and save the file:

import 'package:dmx/dmx.dart';

@dmx('model')
class User {
  const User({required this.id, this.email, this.tags = const []});

  final String id;
  final String? email;
  final List<String> tags;
}

The members appear below the divider, in the file you were already looking at:

  //#region
  static Result<User, DecodeError> fromJson(Object? json, [String path = 'User']) =>
      switch (json) {
        {
          'id': final String id,
          'tags': final List<dynamic> tags,
        } =>
          switch ((
            dmxNullable<String>(dmxKey(json, 'email'), '$path.email', dmxString),
            dmxList<String>(tags, '$path.tags', dmxString),
          )) {
            (Ok(value: final email), Ok(value: final tags)) =>
              Ok(User(id: id, email: email, tags: tags)),
            (Err(error: final e), _) => Err(e),
            (_, Err(error: final e)) => Err(e),
          },
        _ => Err(DecodeError(path, 'User', json)),
      };
  //#endregion

Plus toJson, ==, hashCode, toString and copyWith. Decoding returns a sealed Result, so a bad payload is a branch that names the field that failed — Order.lines[2].product.price — rather than a thrown type error.

The getting started guide covers the rest.

What dmx does

Eleven built-in macros. Each one is an annotation name, a Rust context builder, and a Mustache template.

@dmx('model') immutable data class @dmx('union') sealed sum type @dmx('enum') wire-safe enums
@dmx('diff') what changed, as data @dmx('lerp') interpolation @dmx('validate') accumulating constraints
@dmx('table') SQL schema and rows @dmx('route') typed deep links @dmx('cli') argv parser and usage
@dmx('fake') deterministic fixtures @dmx('restClient') HTTP implementations

Templates you own. src/dmx/templates/model.mustache decides what the output looks like, top to bottom. Every expression reaches the template already worked out (resultExpr, equalsExpr, copyArg, …), so changing the layout never means working out Dart types yourself.

Macros written in Dart. A macro receives a typed view of the declaration — name, fields, types, annotations — and returns the Dart to emit, or hands its model to a Mustache template and lets dmx render it with the same engine the built-ins use. Two worked examples do exactly that: one reads a live SQLite database, one reads an OpenAPI document.

Models defined by a diagram. Some types have no Dart file to annotate yet. Write the model once as a typeDiagram definition and save:

models/parcel.td          the definition
lib/parcel.dart           what dmx writes
type Parcel {
  id:      Uuid
  weightG: Int
  insured: Option<Decimal>
}
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<String?> insured});
}

/// JSON for [Parcel].
extension ParcelJson on Parcel {
  static dmx.Result<Parcel, dmx.DecodeError> fromJson(Object? json, [String path = 'Parcel']);
  Map<String, Object?> toJson();
}

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 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:

```typeDiagram
type Parcel {
  id: Uuid
}
```

```mustache {"dmx":{"output":"lib/parcel.dart"}}
{{#declarations}}final class {{name}} {}{{/declarations}}
```

It never writes broken Dart.

Guarantee Mechanism
Never emits unparseable Dart The whole candidate file is re-parsed before writing [validation]
Never touches your code Bytes outside the region are diffed pre-write [emission.inline-backend.byte-exactness]
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, 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 explain FILE

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

make help    # every target
make ci      # every gate CI runs: fmt, clippy, duplication, tests, Dart, website, build

The specifications and plans give every requirement a dotted identifier — [emission.inline-backend] — that code, tests, and diagnostics cite, so grep -r returns the requirement, its implementation, and its tests together.

Licence

BSD-3-Clause, copyright Nimblesite Pty Ltd. The crate, the extension, the published Dart package, and the generated code all ship under it.

About

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.

Topics

Resources

Security policy

Stars

22 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages