Skip to content

refactor(ohno_macros_impl): let the shape place the core and the grammar split a #[from] entry - #715

Draft
Evgenii (Vaiz) wants to merge 1 commit into
mainfrom
u/vaiz/2026/08/28/ohno-macros-darling
Draft

refactor(ohno_macros_impl): let the shape place the core and the grammar split a #[from] entry#715
Evgenii (Vaiz) wants to merge 1 commit into
mainfrom
u/vaiz/2026/08/28/ohno-macros-darling

Conversation

@Vaiz

@Vaiz Evgenii (Vaiz) commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🤖 Clawpilot here! Posted automatically by Clawpilot (an AI agent), not by a human. Please verify before acting.

Mostly an internal refactor of ohno_macros_impl. One input that used to be accepted is now rejected, deliberately. No code that compiles today stops compiling — see Compatibility below.

The corner this is really about

A #[from(...)] entry is a type, optionally followed by (field: expression, ...) overrides. That makes a ( in the leading position genuinely ambiguous, and these all mean different things:

#[from(std::io::Error)]                         // a path type
#[from((std::io::Error))]                       // the same type, parenthesized
#[from((u8, u16))]                              // a tuple type
#[from(std::io::Error(path: "?".to_owned()))]   // a type, plus a field override
#[from((std::io::Error)(path: "?".to_owned()))] // parenthesized, plus an override

Telling them apart is the parser's whole job here. Before this PR, exactly one of them had a test. All of them do now.

What you write, and what you get

#[derive(Error)]
#[from(std::io::Error)]
struct T {
    source: String,
    inner: ohno::OhnoCore,
}

produces this, byte-identically before and after the change:

impl ::core::convert::From<std::io::Error> for T {
    fn from(error: std::io::Error) -> Self {
        let __ohno_fields = (::core::default::Default::default(),);
        Self {
            source: __ohno_fields.0,
            inner: ::ohno::OhnoCore::from(error),
        }
    }
}

so ordinary usage is untouched:

let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test error");
let my_err: MyError = io_err.into();

assert_error_message!(my_err, "test error");
assert_eq!(my_err.code, 0); // fields you did not name are defaulted

Field overrides, several source types in one attribute, generic sources whose commas belong to the type rather than to the list, tuple structs, and a core field placed anywhere in the struct all produce identical output too. Every pre-existing expansion snapshot is unchanged.

() is now rejected

#[from(())] used to be accepted by the macro. It was never usable: OhnoCore::from requires T: Into<Box<dyn Error + Send + Sync>>, which () does not satisfy, so the generated From<()> failed to compile — pointing into code the user never wrote, which design.md R4 forbids.

Before, on main:

error[E0277]: the trait bound `(): std::error::Error` is not satisfied
    = note: required for `OhnoCore` to implement `From<()>`

After, pointing at what you actually wrote:

error: `#[from(...)]` cannot convert from `()`, which is not an error type.
       Name the type the conversion converts from, such as `#[from(std::io::Error)]`
  --> tests/ui/from_unit_source.rs:11:8
   |
11 | #[from(())]
   |        ^^

#[from((()))] is the same type and gets the same message. ((),) is a genuine one-element tuple and is left alone.

The rejection is a rule, checked in the validation phase, and that placement matters. #[from(...)] parses its entries as a single list, so had the parser instead treated () as an empty override list and failed at decode time, one () would have discarded every other entry in the attribute. A new tests/ui fixture pins that it does not:

#[from((), std::io::Error(missing: 1))]

reports both the () and the unknown field, rather than losing the second behind the first.

Compatibility

Not a breaking change, in the sense that matters: no source that compiles against main fails to compile after this.

  • #[from(())] — verified empirically, not assumed. OhnoCore has exactly one From impl, bounded on Into<Box<dyn StdError + Send + Sync>>, and OhnoCore::from(()) is rejected by rustc with E0277. So any crate containing #[from(())] already failed to build. Code that did not compile still does not compile; only the diagnostic improves.
  • Public API — unchanged. ohno_macros_impl exports the same three functions with the same signatures, and no type in ohno is touched, so cargo-semver-checks has nothing to report.
  • Diagnostic wording — one message changes for a malformed override list, which both versions reject. Message text is not part of the API contract, and no pre-existing .stderr snapshot covered it.
input before after
#[from(())] accepted, then E0277 in generated code rejected, message at the ()
#[from(std::io::Error(path: ))] rejected, expected a type rejected, unexpected token, expected ")"
everything else identical

Diagnostics you see when you get it wrong

Otherwise unchanged, message for message and span for span — the compile-fail tests that pin them are untouched:

error: unknown field `missing` in `#[from(...)]`, available fields: `path`
error: `#[from(...)]` cannot initialize `inner`, which holds the OhnoCore and is built from the source error
error: `#[from(...)]` field keys for a tuple struct are field indexes, not names, so `path:` names no field
error: `#[from(...)]` needs at least one type, such as `#[from(std::io::Error)]`

Why touch it at all

Three things were re-derived by hand that a type or a grammar already held. The entry parser restated part of Rust's path grammar as raw token peeking, so that (std::io::Error) would not read as a field named std; it now settles the question by trying to parse an override list and seeing whether that succeeds. Both code generators re-found the core field by comparing member names, and one threaded its own index alongside that walk guarded by an expect — a panic path in the generation phase, which design.md requires not to have one; the shape now reports each field's position directly. Finally, override keys were compared by formatting both sides to String, where syn::Member already implements PartialEq.

Implementation source shrinks by roughly 20 lines. The rest of the diff is test coverage, plus the design.md and requirements.md updates for the () rejection.

Validation

ohno_macros_impl and ohno both pass on the pinned MSRV, including the trybuild compile-fail tests that pin the diagnostics. Clippy is clean under -D warnings, and both formatting gates pass on the pinned nightly. The parser change was checked against the previous implementation with a differential probe over 68 adversarial #[from(...)] entries; () is the only input whose outcome moves.

darling was evaluated first and rejected: its derives parse each attribute through NestedMeta, which cannot represent #[display("{a}", self.x())], a #[from(...)] entry carrying generic arguments or key: expr pairs, or bare markers such as #[no_debug]. Adopting it would have added a dependency without removing any of the code above.

@Vaiz
Evgenii (Vaiz) force-pushed the u/vaiz/2026/08/28/ohno-macros-darling branch from b1f7763 to 0d4b21f Compare August 31, 2026 07:40
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.27007% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 99.9%. Comparing base (356dc63) to head (29b213e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/ohno_macros_impl/src/derive_error/parse.rs 98.4% 1 Missing ⚠️

❌ Your project check has failed because the head coverage (99.9%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #715     +/-   ##
========================================
- Coverage   100.0%   99.9%   -0.1%     
========================================
  Files         563     563             
  Lines       61068   61071      +3     
========================================
+ Hits        61068   61070      +2     
- Misses          0       1      +1     
Flag Coverage Δ
linux ?
linux-arm ?
scheduled ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Vaiz
Evgenii (Vaiz) force-pushed the u/vaiz/2026/08/28/ohno-macros-darling branch from 0d4b21f to fa3a6c3 Compare August 31, 2026 08:13
…mar split a `#[from]` entry

The `#[from(...)]` entry parser told a parenthesized type apart from an override
list with a hand-written look-ahead over `syn::buffer::Cursor`, matching a punct
character and its `Spacing` so the `::` of a path would not read as a member
followed by a colon. That reimplements, by hand, a question the two grammars
already answer: an override list is what the override parser accepts. Trying it
on a forked stream decides the same case without knowing how far a path reaches,
and drops the `Delimiter`/`Spacing`/`Cursor` imports with it.

An empty pair of parentheses is excluded from that trial, so `()` decodes as the
unit type, and validate then rejects it against the `()` itself. It was accepted
before, but never usably: `OhnoCore::from` needs `T: Into<Box<dyn Error + Send +
Sync>>`, which `()` does not satisfy, so the generated `From<()>` failed with
E0277 inside code the user never wrote — the diagnostic R4 forbids. Reading `()`
as an empty override list instead would report a decoding failure, and since the
entries of one attribute parse as a single list, that would discard every other
entry alongside it. Rejecting it in validate keeps the rest of the list intact.

`Shape` is split around the field holding the core precisely so nothing has to
search for it, but both generators searched anyway: each walked the full field
list and compared every member against the core's to decide how to initialize
it, and the conversion generator threaded a counter alongside that walk to index
its tuple, guarded by an `expect` — a panic path in the phase that is required
not to have one. `Shape` now yields declaration order with each field carrying a
`Position`, and the shared builder takes the core's initializer apart from the
rest, so neither caller compares members and neither carries an alignment of its
own. The builder pairs each member with its value in one walk, rather than
zipping two iterators that agree only because they share a source.

`Conversion` compared `#[from(...)]` keys against field members by formatting
both to `String`, and built every initializer before checking whether the keys
named anything. `syn::Member` is already `PartialEq` under `extra-traits`, which
the crate enables and `constructors.rs` already relied on; the keys are now
checked first, so a rejected conversion builds nothing. `validate` no longer
clones each override into a `(Member, Expr)` pair to hand it over.

`member_name` moves out of the parse phase, which the generate phase was
reaching into for it, and `is_ohno_core` moves to the one phase that uses it.

Rejecting `()` is the one behavior change, and it turns code that failed to
compile into code that fails to compile with a message naming the cause; every
expansion snapshot and every pre-existing `.stderr` snapshot is unchanged. A new
`tests/ui` fixture pins the rejection against the real compiler, including that
a rejected `()` beside a second faulty entry still reports both. Seven cases are
added to the expansion snapshots, all previously uncovered: the three readings
of a leading parenthesis the entry parser has to tell apart, a tuple struct
whose core sits between two data fields, `()` and `(())` as source types, and
the two-fault accumulation case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Vaiz
Evgenii (Vaiz) force-pushed the u/vaiz/2026/08/28/ohno-macros-darling branch from fa3a6c3 to 29b213e Compare August 31, 2026 08:23
@github-actions

Copy link
Copy Markdown

⚠️ Potential breaking changes detected

cargo semver-checks flagged the following on this PR. This is informational -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.

anyspawn_azure

     Cloning origin/main
    Building anyspawn_azure v0.3.0 (current)
       Built [  13.882s] (current)
     Parsing anyspawn_azure v0.3.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-anyspawn_azure-0_3_0-default-8a8c741d160dc3b0/target/doc/anyspawn_azure.json
(supported formats are v55, v56, v57)

arty_executor

     Cloning origin/main
    Building arty_executor v0.1.1 (current)
       Built [   4.060s] (current)
     Parsing arty_executor v0.1.1 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-arty_executor-0_1_1-default-58ae7becc93be6ab/target/doc/arty_executor.json
(supported formats are v55, v56, v57)

automation

     Cloning origin/main
    Building automation v0.1.0 (current)
       Built [   5.783s] (current)
     Parsing automation v0.1.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-automation-0_1_0-default-01666ec060466c14/target/doc/automation.json
(supported formats are v55, v56, v57)

bytesbuf_io

     Cloning origin/main
    Building bytesbuf_io v0.9.0 (current)
       Built [   5.186s] (current)
     Parsing bytesbuf_io v0.9.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-bytesbuf_io-0_9_0-default-af8b2c0d74d14958/target/doc/bytesbuf_io.json
(supported formats are v55, v56, v57)

cachet

     Cloning origin/main
    Building cachet v0.13.0 (current)
       Built [  11.437s] (current)
     Parsing cachet v0.13.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-cachet-0_13_0-default-4aa0ab9725815152/target/doc/cachet.json
(supported formats are v55, v56, v57)

cachet_memory

     Cloning origin/main
    Building cachet_memory v0.7.0 (current)
       Built [   7.664s] (current)
     Parsing cachet_memory v0.7.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-cachet_memory-0_7_0-default-01666ec060466c14/target/doc/cachet_memory.json
(supported formats are v55, v56, v57)

cachet_service

     Cloning origin/main
    Building cachet_service v0.5.0 (current)
       Built [   4.361s] (current)
     Parsing cachet_service v0.5.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-cachet_service-0_5_0-default-01666ec060466c14/target/doc/cachet_service.json
(supported formats are v55, v56, v57)

cachet_tier

     Cloning origin/main
    Building cachet_tier v0.5.0 (current)
       Built [   4.565s] (current)
     Parsing cachet_tier v0.5.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-cachet_tier-0_5_0-default-d7b8c5ec6ce39049/target/doc/cachet_tier.json
(supported formats are v55, v56, v57)

fetch

     Cloning origin/main
    Building fetch v0.16.1 (current)
       Built [  35.860s] (current)
     Parsing fetch v0.16.1 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-fetch-0_16_1-default-325273521c672bb6/target/doc/fetch.json
(supported formats are v55, v56, v57)

fetch_azure

     Cloning origin/main
    Building fetch_azure v0.6.1 (current)
       Built [  18.945s] (current)
     Parsing fetch_azure v0.6.1 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-fetch_azure-0_6_1-default-01666ec060466c14/target/doc/fetch_azure.json
(supported formats are v55, v56, v57)

fetch_hyper

     Cloning origin/main
    Building fetch_hyper v0.7.1 (current)
       Built [  15.195s] (current)
     Parsing fetch_hyper v0.7.1 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-fetch_hyper-0_7_1-default-66e07b58f00fba56/target/doc/fetch_hyper.json
(supported formats are v55, v56, v57)

fetch_tls

     Cloning origin/main
    Building fetch_tls v0.4.0 (current)
       Built [   7.153s] (current)
     Parsing fetch_tls v0.4.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-fetch_tls-0_4_0-default-96384260aee26d8f/target/doc/fetch_tls.json
(supported formats are v55, v56, v57)

http_extensions

     Cloning origin/main
    Building http_extensions v0.10.0 (current)
       Built [  10.724s] (current)
     Parsing http_extensions v0.10.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-http_extensions-0_10_0-default-af0120d9f0530326/target/doc/http_extensions.json
(supported formats are v55, v56, v57)

msvc_spectre_libs

     Cloning origin/main
    Building msvc_spectre_libs v0.2.0 (current)
       Built [   4.138s] (current)
     Parsing msvc_spectre_libs v0.2.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-msvc_spectre_libs-0_2_0-default-7bc93237a011c201/target/doc/msvc_spectre_libs.json
(supported formats are v55, v56, v57)

msvc_spectre_libs_build

     Cloning origin/main
    Building msvc_spectre_libs_build v0.1.0 (current)
       Built [   3.641s] (current)
     Parsing msvc_spectre_libs_build v0.1.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-msvc_spectre_libs_build-0_1_0-default-01666ec060466c14/target/doc/msvc_spectre_libs_build.json
(supported formats are v55, v56, v57)

observed

     Cloning origin/main
    Building observed v0.25.0 (current)
       Built [   5.446s] (current)
     Parsing observed v0.25.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-observed-0_25_0-default-d7b8c5ec6ce39049/target/doc/observed.json
(supported formats are v55, v56, v57)

observed_testing

     Cloning origin/main
    Building observed_testing v0.0.0 (current)
       Built [   5.788s] (current)
     Parsing observed_testing v0.0.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-observed_testing-0_0_0-default-01666ec060466c14/target/doc/observed_testing.json
(supported formats are v55, v56, v57)

observed_utils

     Cloning origin/main
    Building observed_utils v0.2.0 (current)
       Built [   5.693s] (current)
     Parsing observed_utils v0.2.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-observed_utils-0_2_0-default-01666ec060466c14/target/doc/observed_utils.json
(supported formats are v55, v56, v57)

ohno

     Cloning origin/main
    Building ohno v0.5.0 (current)
       Built [   3.496s] (current)
     Parsing ohno v0.5.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-ohno-0_5_0-default-fd2f176c69f3ec17/target/doc/ohno.json
(supported formats are v55, v56, v57)

ohno_macros_impl

     Cloning origin/main
    Building ohno_macros_impl v0.5.1 (current)
       Built [   2.248s] (current)
     Parsing ohno_macros_impl v0.5.1 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-ohno_macros_impl-0_5_1-default-01666ec060466c14/target/doc/ohno_macros_impl.json
(supported formats are v55, v56, v57)

recoverable

     Cloning origin/main
    Building recoverable v0.2.0 (current)
       Built [   0.360s] (current)
     Parsing recoverable v0.2.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-recoverable-0_2_0-default-01666ec060466c14/target/doc/recoverable.json
(supported formats are v55, v56, v57)

seatbelt

     Cloning origin/main
    Building seatbelt v0.8.0 (current)
       Built [   5.646s] (current)
     Parsing seatbelt v0.8.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-seatbelt-0_8_0-default-0cae1599156c7909/target/doc/seatbelt.json
(supported formats are v55, v56, v57)

seatbelt_http

     Cloning origin/main
    Building seatbelt_http v0.8.0 (current)
       Built [  10.216s] (current)
     Parsing seatbelt_http v0.8.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-seatbelt_http-0_8_0-default-b6ff85dfca11b668/target/doc/seatbelt_http.json
(supported formats are v55, v56, v57)

templated_uri

     Cloning origin/main
    Building templated_uri v0.5.0 (current)
       Built [   7.127s] (current)
     Parsing templated_uri v0.5.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-templated_uri-0_5_0-default-4343b0346f1eaeb1/target/doc/templated_uri.json
(supported formats are v55, v56, v57)

templated_uri_macros_impl

     Cloning origin/main
    Building templated_uri_macros_impl v0.5.0 (current)
       Built [   5.679s] (current)
     Parsing templated_uri_macros_impl v0.5.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-templated_uri_macros_impl-0_5_0-default-01666ec060466c14/target/doc/templated_uri_macros_impl.json
(supported formats are v55, v56, v57)

tick

     Cloning origin/main
    Building tick v0.6.0 (current)
       Built [   2.782s] (current)
     Parsing tick v0.6.0 (current)
error: unsupported rustdoc format v60 for file: /home/runner/work/oxidizer/oxidizer/target/semver-checks/local-tick-0_6_0-default-d3b4fd8dc0a15942/target/doc/tick.json
(supported formats are v55, v56, v57)

@Vaiz

Copy link
Copy Markdown
Contributor Author

🤖 Clawpilot here! Automated review comment from an AI agent -- not a human reviewer. Please verify before acting.

AI reviewer: Claude Opus 5

design.md justifies the new () rejection with "nothing converts from it, so a generated From<()> would fail inside code the user cannot see, which R4 forbids". That reason does not pick out (). This same commit adds the snapshot case a tuple source type is not an override list, which accepts #[from((u8, u16))] and expands to impl ::core::convert::From<(u8, u16)> for T with the body inner: ::ohno::OhnoCore::from(error). OhnoCore's only From impl is bounded on Into<Box<dyn Error + Send + Sync>> (crates/ohno/src/core.rs:178), so that body fails with the same E0277, in generated code, exactly as From<()> would. R4 allows recorded exceptions, but the Limits section lists only the const fn, format-spec and map_err cases, so this one is not an accepted limit either.

The paragraph above it already justifies the rule without appealing to R4: () is the one input both readings can claim, and reading it as an empty override list would fail at decode time and discard every sibling entry in the attribute. Drop the R4 clause and let that argument stand. The from_unit_source.rs module doc and the new display_diagnostics.rs comment repeat the same R4 framing and need the same edit. The UNIT_SOURCE message in validate.rs has the same problem: (u8, u16) is not an error type either.

The alternative is to widen is_unit to every Type::Tuple and update the (u8, u16) snapshot case. That is a larger rule than this PR set out to add. Either way, a reader currently has to guess whether #[from((u8, u16))] is an unfixed bug against a stated rule or evidence that the rule is decorative.


/// Renders a member the way a diagnostic spells it, and the way `Debug` labels it: `path`, or `0`.
///
/// Shared by all three phases: a member reaches the user as text in a diagnostic, in a template

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Clawpilot here! Automated review comment from an AI agent -- not a human reviewer. Please verify before acting.

AI reviewer: Claude Opus 5

member_name has no call site in parse.rs, and had none before this change either. The callers are model.rs:203, model.rs:215, display/mod.rs:41, display/mod.rs:50 and generate/traits.rs:138. The display module is imported and called only from validate.rs, and unknown_key is a validation diagnostic, so the users are validate and generate — two of the three phases the module doc names above.

The count is load-bearing for a later reader deciding whether this helper can move back down into one module, so make it say two: "Shared by validate and generate: a member reaches the user as text in a diagnostic, in a template resolution and in a Debug label, and the three have to agree on how it is spelled." The list of three uses is correct; only the phase count is not.

@@ -91,6 +112,15 @@ impl Shape {
self.before.iter().chain(self.after.iter())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Clawpilot here! Automated review comment from an AI agent -- not a human reviewer. Please verify before acting.

AI reviewer: Claude Opus 5

Position::Data is documented as "numbered as Shape::data yields it", and the generated conversions depend on that: Conversion::new lays out initializers by walking data(), while construct emits __ohno_fields.#index from the Position::Data(index) that positions() hands out. Those two orders come from two independent iterator expressions — positions() walks before/core/after with its own offset, this line walks before/after.

They agree today. If data() later gains a filter or a different chain order, positions() keeps numbering the old way and every generated From<T> assigns initializers to the wrong fields, with no compile error — only the snapshots would catch it. That is the second alignment the positions() doc argues against a few lines up. all() was rewritten in this PR to go through positions(); data() can do the same, and then the invariant stops being a comment:

pub(crate) fn data(&self) -> impl Iterator<Item = &ModelField> {
    self.positions()
        .filter_map(|(field, position)| matches!(position, Position::Data(_)).then_some(field))
}

Low priority as written, since the two definitions sit a few lines apart today.

/// of the shape. A generator that recovered it by comparing members would be re-deciding, at
/// every use, something the split around the core already settled.
pub(crate) fn positions(&self) -> impl Iterator<Item = (&ModelField, Position)> {
let after = self.before.len();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Clawpilot here! Automated review comment from an AI agent -- not a human reviewer. Please verify before acting.

AI reviewer: Claude Opus 5

This binds the count of the fields before the core to after, which is also the name of a field on this struct. Eleven lines down it reads Position::Data(after + index) inside the closure that walks self.after, where it looks like "the number of fields after the core, plus this one's index" — the wrong quantity, and one that would give the wrong answer.

The value is correct: the after fields do start at before.len() in the data numbering. But this is the one function the generated conversions' field ordering depends on, so the name should not point the reader the other way. Rename it: let data_before_core = self.before.len();, used as Position::Data(data_before_core + index).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant