Preserving edits to generated code
Everything under a generated crate's src/generated/** is tool-owned and rewritten on every
export. Three kinds of hand edits survive a regeneration anyway:
- comments you add on their own line, marked with a
// cddl-codegen:keepmarker; - inserted code, wrapped in an
insertblock — added lines the generator never emits; - replaced code, wrapped in a
replaceblock — your version swapped in over a recorded copy of the generated code it overrides.
Anything else — an untagged code edit — is clobbered by the next export, exactly as before. An
untagged comment is not clobbered but is not carried either: it cannot be classified, so it is
trapped in a compile_error! for you to review (see Unclassified comments).
The tags are the opt-in: explicit, reviewable in diffs, and impossible to lose silently.
The whole feature follows one contract, never silent: every preserved comment or block either
lands back in the regenerated file, or is trapped in a compile_error! block so the generated
crate fails to build until you review it. There is no third outcome. --no-preserve-comments
disables the entire overlay (comments and code blocks alike; see
command line flags).
Which mechanism for which edit
| You want to… | Use |
|---|---|
| annotate generated code | an own-line comment with a // cddl-codegen:keep marker |
annotate generated code with /// docs | a bare // cddl-codegen:keep marker above the doc lines |
| swap one generated line | a replace block (one-line recorded original) |
| swap a run of lines (e.g. most of a function body) | a replace block |
replace a whole function or impl | a replace block whose recorded original is the whole item |
| add lines the generator doesn't emit | an insert block |
| remove generated code / take over a whole concept | not a preservation feature — turn off codegen for that piece instead: @custom_serialize / @custom_deserialize (see the comment DSL) or an _CDDL_CODEGEN_EXTERN_TYPE_ extern type, and hand-write it in user-owned space |
Comments
Mark your comments with // cddl-codegen:keep. A generated file is full of comments the tool
wrote — the header banner, /// docs rendered from your CDDL, notes inside the serialization
runtime — and on regeneration the tool has no record of what it emitted last time. So it cannot
tell "I reworded my own comment" from "the user wrote this", and guessing wrong does not merely
duplicate a line: a reworded tool comment re-anchors below the new one, and a re-wrapped paragraph
leaves exactly the lines whose wrap changed spliced into the middle of maintained prose. The rule
that removes the guess is: outside a cddl-codegen: block, every comment in a generated file is
tool-owned. Yours declares itself.
There are two forms. Inline — the whole line is your comment:
// cddl-codegen:keep this is why the bound is checked here
Bare marker — claims the contiguous run of comment lines directly below it, as one unit. One
extra line no matter how long the run, and it is the only form that can carry //////! doc
comments (an inline marker's text is necessarily a // comment):
// cddl-codegen:keep
/// from-bytes using the exact CBOR format
/// this type was serialized with.
The run ends at the first line that is not a comment on the immediately following line — so a blank line, or a line of code, terminates it. The marker travels with your text: it is part of what gets carried, and removing it on a later regeneration makes the comment unclassified again.
Marked comments are re-anchored onto the fresh output by symbol identity (which named item they sit
in or above) and token equality — never a textual diff, so rustfmt reflow can't break them. A
comment on an unchanged item transfers; a comment above a still-present item re-attaches above it; a
comment on a statement inside a changed body re-attaches if that exact statement still appears
exactly once on both sides.
Unclassified comments
An own-line comment that is neither part of this run's output nor keep-marked is unclassified,
and lands in the same compile_error! trap a failed block does — with a headline that claims
nothing about who wrote it:
// cddl-codegen:unpreserved-comment (delete this block after review)
compile_error!("cddl-codegen found a comment it cannot classify.
Outside a `cddl-codegen:` block every comment in a generated file is tool-owned, and this one is
neither emitted by this run nor marked as yours. It is one of two things:
(1) stale tool output whose text changed or was removed upstream — delete this whole block;
(2) your own comment — delete this whole block and re-add the comment with a marker:
// cddl-codegen:keep <your text>
…
Original comment:
// the text you wrote");
Your text is always in the message, so nothing is lost — you delete the block and either drop the comment (it was stale tool output) or re-add it with a marker (it was yours). When this run emits a comment at the same position, the message names it too, as a hint that you are most likely looking at a tool comment whose wording changed upstream. The hint is guidance for you; it never changes the tool's decision.
If you have free-floating comments in a generated tree from before this rule, the first regeneration
after upgrading traps each of them. That is a one-time review pass: for each block, delete it and —
if the text is yours — re-add it with a // cddl-codegen:keep marker. Nothing is silently lost, and
after that pass regeneration is quiet again.
Scope notes:
- Trailing (end-of-line) comments are not carried — the
keepmarker is own-line only, so a trailing comment fails loudly with a hint to move it to its own line. - Doc text on tool-documented items is tool-owned:
///flows from your CDDL and the@doccomment DSL, so edit it there. A hand edit to a tool-emitted doc block is dropped on regeneration. To add doc text where the tool does not document, use a barekeepmarker above the///lines. - A comment this run also emits is never reported, wherever the two copies sit. This is what
keeps a tool UPGRADE quiet: an upgrade rewrites generated code across a whole file, so the
position a comment anchored to no longer exists, and without this rule a comment the new output
carries verbatim would be trapped anyway — a
compile_error!claiming a comment was lost while that comment sits a few lines below it. The rule is matched per line and by text alone, which is the second deliberate silent-drop class: an unmarked comment of yours whose text happens to match one the tool emits elsewhere in the same file is dropped instead of trapped. Akeepmarker is exact and is not subject to it.
Insert blocks — adding code
Wrap the added lines in a pair of own-line tag comments:
// cddl-codegen:insert-start
self.extra_validation()?;
// cddl-codegen:insert-end
The whole block — tags, code, any comments inside it — travels as one verbatim unit, anchored by
the code that follows it, through the same tiers as a comment. The inserted code must be balanced
({}/()/[]) and is otherwise arbitrary: statements inside a function, an extra field in a
struct definition, even a whole new item between two generated ones.
Write the tags on their own line; you do not have to keep them there. rustfmt's canonical form
folds a marker that trails the closing } of a match's last arm onto that } as a trailing
comment (} // cddl-codegen:replaces), and the tool applies rustfmt to its own output — so a
formatted repo commits the folded shape. Both spellings are recognized: the overlay unfolds a
trailing cddl-codegen: marker back to its own line before scanning, so a block whose user section
ends at a match's tail arm regenerates cleanly whether the file is in the emitted own-line form or
the rustfmt-folded form.
Replace blocks — swapping code
A replace block has three sections: your code, the marker replaces, and a //-commented copy
of the generated code you overrode (the recorded original):
// cddl-codegen:replace-start
let len = custom_len_logic(&self.items);
// cddl-codegen:replaces
// serializer.write_array(cbor_event::Len::Len(self.items.len() as u64))?;
// cddl-codegen:replace-end
To author one: wrap the region, move the original generated lines under replaces and comment
them out (prefix each with // ), and write your replacement above the marker. Exact whitespace
in the recorded original doesn't matter — it is matched token-wise, not textually.
The recorded original is what makes this work, doing three jobs at once:
- Placement. On regeneration the recorded original is uncommented, tokenized, and located in the regenerated item; your block is spliced in over that exact spot. No positional guessing — the block finds its place even when unrelated code moved. When the whole surrounding top-level item regenerates token-identically, the block places by its own position instead, so the recorded original does not need to be unique within the item (this lets you replace one of several identical fragments, or even two different occurrences of the same fragment).
- Drift detection. If the generator's output for that region changed — you edited the CDDL,
or a tool upgrade emits different code — the recorded original no longer matches, and the block
is trapped in a
compile_error!showing what you overrode. A stale override can never silently keep suppressing new generated behavior; you re-review, update your code, re-record the original, and delete the error block. - Review record. Every override is visible in your VCS diff next to the code it replaced.
The same syntax scales from one line to a whole item: record a single statement, a run of
statements, an entire member function, or a whole impl — whatever the recorded original spans
(within one top-level item) is what your code replaces. Neither your code nor the recorded original
may close a delimiter ({}, (), []) it did not open, and the two must change delimiter depth by
the same net amount — so an if flag { paired with a recorded if old_cond { (both open one brace)
is fine, while a } else { fragment (which closes a brace first) is not. An empty recorded original
is an error (there'd be nothing to match against).
Generated comments inside the replaced span are replaced with it; comments outside survive. A plain comment of yours whose anchor falls inside a span some replace block deletes fails loudly — move it into the block.
Imports and your block's code. The generated struct modules import the collection helper types
(BTreeMap, OrderedHashMap, NonEmptyVec, NonEmptyMap) only when the file — or one of its
descendant modules — references them (see Output format). The relationship to
your block's code is asymmetric. Your block cannot make an import appear: the initial import
derivation runs over the pristine generated content, before your edits are applied, so a file whose
generated code never justified BTreeMap won't gain the use just because your block names it —
name such types by a fully qualified path (std::collections::BTreeMap) rather than relying on a
use that won't be there. But once your edits are applied, the import set is re-derived from that
final content — your block's code included — so a block that names an import the generated code
also justified keeps that import alive even if your block replaced the generated reference.
Conversely, if your replace block removes the last generated reference to an import — say you
replace pub type Mint = OrderedHashMap<PolicyId, …> with a hand type that names neither — the now
unused use is pruned for you on the next export. You do not need a second replace block to
delete the orphaned import. This holds even when the reference you removed lived in a different
file of the module (e.g. a serialization.rs edit orphaning a mod.rs import).
When preservation fails: the two failure channels
Malformed tags abort the export (a hard error naming the file, before anything is written).
This covers: an unclosed block, an orphaned insert-end/replaces/replace-end, a fragment that
closes a delimiter it did not open (or, for a replace block, a user section and recorded original
whose net delimiter deltas differ), an empty or unlexable recorded original, code lines under
replaces (every line
there must be //-commented), a recorded original spanning multiple top-level items, a bare
// cddl-codegen:keep marker with no comment on the line below it — and any
unrecognized comment starting with // cddl-codegen: (including a near miss like
// cddl-codegen:keep-this, which is an unknown tag, not keep). That last rule is what keeps the
never-silent contract airtight: a typo'd tag can never demote your block to plain text and let
half of it be clobbered. It covers the reserved namespace wherever a marker sits, including the
rustfmt-folded trailing position — so a mistyped } // cddl-codegen:not-a-tag at a match tail is
a hard error, not a silently-dropped trailing comment. Fix the tag structure (or pass
--no-preserve-comments to clobber pristine) and re-run.
Placement failures trap the payload in the output. When the file regenerates but a comment or block can't be safely re-placed, the export succeeds and the affected payload lands at the top of the file as:
// cddl-codegen:unpreserved-comment (delete this block after review)
compile_error!("cddl-codegen could not preserve a user code block across regeneration.\n<reason>\nOriginal code block:\n<your full block, verbatim>");
The generated crate fails to build with your original text in the error message; the block carries forward verbatim across further regenerations until you delete it, so nothing is ever lost. The reasons you'll see, and what they mean:
| Reason (abbreviated) | What happened | What to do |
|---|---|---|
…which no longer exists in the regenerated code | the item holding your edit vanished (removed/renamed in the CDDL) | re-place the edit by hand where it now belongs, or discard it |
…the number of same-named items changed | same-keyed items (e.g. two impl Foo blocks) were added/removed, so "the Nth one" is ambiguous | re-place by hand |
…whose generated code changed (comment) | the statement your comment annotated changed or is no longer unique | re-place by hand against the new code |
The generated code for …changed, so its recorded original no longer appears (drift) | the generator now emits different code where your replace block applied | re-review: update your replacement for the new behavior, re-record the new original under replaces |
…recorded original is not unique within … / …appears more than once… | the item also changed (so the item-identity fast path didn't apply) and the recorded original then matches zero-or-multiple spots ambiguously | include more surrounding lines in the recorded original so it's unique, or re-place by hand |
Its anchor lies inside code replaced by a …replace-start block | a separate comment/insert block pointed into a span your replace block overrides | move that comment/block inside the replace block |
It is a trailing (end-of-line) comment | a plain trailing comment (no cddl-codegen: tag) can't be carried | move it to its own line, with a keep marker |
cddl-codegen found a comment it cannot classify | an own-line comment that is neither this run's output nor keep-marked | delete the block; if the text is yours, re-add it with a // cddl-codegen:keep marker |
(A trailing comment that is a cddl-codegen: marker is unfolded to its own line before scanning, so a well-formed folded block is recognized and an unknown-tag one hits the hard reserved-namespace error above rather than this row.)
Guarantees and residual limits
Running the tool twice equals running it once: re-exporting an unchanged spec reproduces the same
file byte-for-byte, blocks included. The overlay never touches code tokens outside your tagged
regions — its only effects are inserting your comments/blocks and removing exactly the span a
recorded original identifies. The one downstream consequence is import pruning: a use the
generated code no longer references — because your replace block removed its last user — is dropped,
since the import set is always re-derived from the final code (never from prior output), which keeps
the byte-for-byte reproducibility intact.
Known residual limits, by design:
- A generator-emitted
//comment whose text changes between tool versions cannot be told apart from a user comment, so it surfaces as an unclassified comment rather than being re-anchored on a guess. This is the specified behavior, not a residual: the only silent outcome would be a wrong one. - Preservation input must be lexable Rust: if a hand edit leaves an unterminated string/comment, the export is a hard error naming the file rather than a silent clobber.
- A file that stops being generated (a removed/renamed type or scope) is orphaned on disk with a stderr warning; edits there are not carried anywhere.