dsdlc

dsdlc is the primary compiler/codegen driver. Run dsdlc --help for languages, target syntax, and the full option set.

This page covers behaviour that switch descriptions do not carry.

Path arguments

Every option taking a filesystem path reads it the same way. A leading ~ is the invoking user's home directory, expanded by dsdlc rather than by the shell, so a path built by a build system or read from a configuration file resolves the way one typed at a prompt does. . and .. fold away lexically, before anything is opened. An absolute path is used as given.

A relative path is measured from --outdir when it names a file the run writes, and from the working directory when it names one the run reads. So --outdir gen --prune-manifest .dsdlc/types writes gen/.dsdlc/types, and moving the generated tree is one flag rather than several. Write outside --outdir by giving an absolute path.

Support code

Support code is everything a backend emits that is not derived from a definition. It is rendered from content compiled into dsdlc, and --generate-support selects when it is written.

Backend Support artifacts
c dsdl_runtime.h
cpp dsdl_runtime.h, dsdl_runtime.hpp (per profile)
rust Cargo.toml, src/dsdl_runtime.rs, src/dsdl_runtime_semantic_wrappers.rs
go go.mod, dsdlruntime/dsdl_runtime.go
ts package.json, dsdl_runtime.ts
python pyproject.toml, _dsdl_runtime.py, _runtime_loader.py, py.typed

Under never, Python still writes the __init__.py chain its generated modules import through.

Dependency files

-MD writes a make-style .d beside each generated output listing the .dsdl files it was built from: its own definition plus the transitive closure of the composite types it references.

Some outputs have no .dsdl source to name: definitions from the embedded uavcan catalogue and all support code are compiled into the binary. Those rules name the dsdlc executable as their prerequisite instead — upgrading the compiler rebuilds what it produced. An output mixing local and embedded definitions lists its real inputs and the executable.

Every path emitted in a depfile or by --list-inputs exists on disk, so both can be fed to a build system verbatim.

Pruning stale output

dsdlc writes what it is asked for; without --prune-manifest it does not remove what it wrote last time. Delete a definition and its generated header stays in --outdir, still on the include path, so code that names a type nobody defines any more keeps compiling.

--prune-manifest <file> closes that. The run records its outputs in <file> and, on the next run, deletes the outputs the previous manifest listed that it no longer produces. Directories emptied by pruning are removed too, so a deleted namespace leaves no shape behind.

One manifest per invocation, not per output directory. Generation decomposes — a build may split one namespace across several runs for support, the embedded catalogue, and definitions — and a run owns only the files it emits. A run that swept --outdir would delete the files its siblings had just written, so each tranche is given its own manifest and prunes only what it owns:

dsdlc -l c --outdir gen --generate-support only  --prune-manifest .dsdlc/support
dsdlc -l c --outdir gen --generate-support never --prune-manifest .dsdlc/builtin +uavcan.node
dsdlc -l c --outdir gen --generate-support never --omit-dependencies \
      --prune-manifest .dsdlc/types dsdl/myns

Removals are confined to --outdir: a manifest naming anything outside it is a hard error rather than a deletion, since a manifest is an input and an input that can name any path is a way to turn a stale file into an arbitrary rm. A manifest in an unrecognised format is treated as absent — a format change should cost one stale file, not every configured tree.

The flag is ignored under --dry-run and the --list-* modes, which imply it. A dry run that deleted files while reporting that it wrote none would be worse than either.

Embedded uavcan catalogue

For the standard uavcan.* namespace, dsdlc ships an embedded catalogue used by the mlir and codegen targets. Types referencing core uavcan definitions resolve without external uavcan source roots.

The catalogue is consulted automatically during dependency resolution, and can be named directly as a target with the + sigil.

+ targets behave as explicit targets: their dependency closure is generated too, and --omit-dependencies restricts output to what was named. They mix freely with filesystem targets, and a local definition sharing a type key shadows the embedded one.

Namespace matching is anchored at a dot boundary, so +uavcan.n selects nothing rather than standing in for +uavcan.node. A selector matching nothing is an error with a did-you-mean; an unavailable version reports the versions the catalogue carries.

Type versions

Only the newest version of each type is generated, and the run reports what it left out:

note: generating the newest version of each type; 22 older version(s) were not generated.

A corpus holding several versions of a type forces a choice on everything downstream — Go compiles a namespace as one package and cannot hold two versions of a type at all, and C and C++ share a scope across versions — while most code speaks one version.

Newest is per full name, not per major version: Foo.1.0, Foo.1.1 and Foo.2.0 leave only Foo.2.0. Per-major would match Cyphal's compatibility model, where majors are incompatible, but it leaves a type that has two majors still carrying two versions — the thing this exists to prevent.

Naming a version keeps it, and affects no other type. Any of the version-precise target spellings does it; dsdlc --help gives their syntax.

Limits

The narrowing applies to the set of types asked for, not to the finished output. A version that survives may still reference an older one — a field of type Dep.1.0 in a definition that is itself the newest — and dependency resolution keeps what it needs:

note: kept 1 older version(s) that a newer definition still references: ns.Dep:1:0

So the output is usually single-version per type without being guaranteed to be, and the backends keep their own guards, described below.

Deprecated types

A deprecated definition is usually one a newer version replaced, so this default drops almost every @deprecated type as a side effect. Generating every version brings them back, with the deprecation attributes and notices described under Deprecation.

Type-name versioning

A generated type name does not carry the definition's version: uavcan.node.Heartbeat.1.0 becomes Heartbeat in C++, Go, TypeScript and Python, uavcan__node__Heartbeat in C, and uavcan_node_Heartbeat in Rust. Code that handles two versions of one type at once needs them kept apart, and can ask for the version to be included.

Output file names carry the version either way, so the choice changes what you write, not what you include or import. Under both, the name follows from the definition alone; it never depends on what else was in the invocation.

What an unversioned name costs depends on what scopes the type, and arises only where a corpus carries two versions of one type — which the newest-version default prevents:

Language Scope holding the type Two versions, unversioned
Rust, TypeScript, Python a module per type and version no conflict
C, C++ a scope shared across versions generates; a translation unit including both stops on an #error
Go a package per namespace, shared across versions cannot be generated; dsdlc refuses, naming the type and its versions

Deprecation

A definition marked @deprecated generates a Deprecated: … notice in its documentation comment and an IS_DEPRECATED metadata constant (DSDL_IS_DEPRECATED in TypeScript and Python, <TYPE>_IS_DEPRECATED_ in C), in every language. Go recognises the Deprecated: doc paragraph, and TypeScript is additionally given a /** @deprecated … */ JSDoc block.

C, C++, and Rust additionally get a language-native attribute — __attribute__((deprecated)), [[deprecated]], and #[deprecated(note = …)] respectively — so naming the type produces a compiler diagnostic. The Rust attribute carries the notice as its message, which rustc prints in the warning. This is on by default.

Generated code does not trip its own attribute. In C the attribute is on the typedef, and generated code names the type through its struct tag, struct <name>, which carries no attribute. In C++ and Rust the struct is declared as <name>_ and <name> is a deprecated alias of it; generated code names the struct. Compiling generated code is clean under -Werror or -D warnings, and only your own code naming a deprecated type is diagnosed.

The obj backend never emits these attributes.

@aliasable

@aliasable asserts that a section's serialised form is a contiguous byte image: fixed length, sealed, not a union, every field a whole number of bytes beginning on a byte boundary, and every composite field a type that holds the same. A definition that asserts it and does not hold it is an error, naming the field that blocked the layout.

@aliasable
float32 x
float32 y
float32 z
@sealed

It is a flag, like @union and @sealed, and takes no expression. It scopes to a section, so a service asserts for its request and its response independently. It requires @sealed rather than implying it: a directive that reads as an assertion should not change the wire format.

Use it on a type whose decode cost is part of its design. Without it, a schema change that costs such a type its layout is silent — the code still generates, and the reader pays for a decode it was written not to need.

Every generated type reports the same verdict whether or not it asserts it, as WIRE_FLAT and a WIRE_FLAT_REASON naming what blocked it. C, C++, Rust and Go carry HOST_IMAGE beside it, which answers a second question: whether the generated structure is that byte image on the target that compiles it. The two differ whenever a width the wire carries in five bytes is held in eight, or where a structure aligns a field the wire does not. A TypeScript or Python object has no byte image to compare, so those two report the wire verdict alone; the accessors are what the property buys them.

A wire-flat type's scalar fields, and the elements of its fixed arrays of scalars, have accessors beside the serialisation functions: a getter that reads one field off a serialised buffer, and a setter that writes one into it, each at the field's fixed offset and in the member's own type. An element accessor takes the element's index after the buffer. A nested composite field has a getter alone, answering the buffer from the field's offset — with what remains through a size pointer in C and C++, as a slice elsewhere — for the nested type's own accessors to read. A getter answers what deserialize_ puts in the field, on a short buffer too, where both zero-extend, and reads an index at or past the array's capacity as zero; a setter answers the runtime's error code, refusing such an index and a buffer too short for the field. C spells them <type>__get_<field>_ and <type>__set_<field>_, C++ as static members get_<field> and set_<field>, Rust as associated functions of the same names, Go as <Type>Get<Field> and <Type>Set<Field>, TypeScript as get<Type><Field> and set<Type><Field>, Python as static methods get_<field> and set_<field>.

A union whose options are all flat and of one length, sealed, has its tag at a fixed offset and every option at the offset after it, so it has the same accessors: the tag as a member named _tag_, with a getter and a setter in the tag's own width, and each option's accessors at that one offset. An option's setter writes the value and not the tag; the tag's setter selects, with the option's tag constant. Such a union is not wire-flat and cannot assert @aliasable.

--aliasable-only emits the accessors and neither the object type nor the serialisation. Every targeted type must carry @aliasable or be nested by a type that does; each that is neither fails the run, named. The files keep their names; Go's endianness guard, which belongs to the folded bodies, is not among them.

--aliasable-views holds each composite field of an @aliasable type as a view of the buffer the holder was deserialised from, in place of a decoded copy: the field's bytes and their count, for the nested type's accessors to read. The holder's deserialise skips the field and its serialise copies the view. A buffer that ends inside the field leaves a short view, which the accessors read as zeros past its end and which serialises zero-filled; an initialised object holds an empty view, which serialises as the nested type's default. An array of an @aliasable type is one view per element: a fixed array's held in place, a variable-length one's beside its count. A holder of a view is not a host image. A field of a union, and a field whose type is wire-flat without asserting it, are decoded as usual. C and C++ hold a view as dsdl_runtime_view_t, a pointer and a size; Rust as &'a [u8], which gives the holder, and every type that holds one, a lifetime parameter and a deserialise that borrows the buffer for it; Go as []byte; TypeScript as Uint8Array; Python as memoryview. A file names no type it holds only as a view.

A host-image type asserts the verdict where it is compiled: the generated structure carries a static assertion on its size and on each member's offset. On the byte-image targets — C, obj, C++, Rust and Go — its serialise and deserialise are one move of the object's bytes when the target triple, the host's when none is given, is little-endian, and the generated code refuses a big-endian build with the reason and the fix. Under the C++ PMR profile a host image carries no memory resource: it allocates nothing, and the pointer would widen the structure past the image.

⚠️ @aliasable is an llvm-dsdl extension. The reference implementation rejects an unknown directive, so a namespace using it does not parse under pydsdl or generate under Nunavut. It is kept out of the differential corpus for that reason. Proposing it upstream is the intent; until then, a namespace meant to stay portable should not use it.