feat(ohno_macros): rewrite the crate from requirements and design - #656
feat(ohno_macros): rewrite the crate from requirements and design#656Evgenii (Vaiz) wants to merge 20 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #656 +/- ##
========================================
Coverage 100.0% 100.0%
========================================
Files 532 538 +6
Lines 59839 59676 -163
========================================
- Hits 59839 59676 -163
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
b31778b to
abf2ede
Compare
0e79b29 to
4c0f169
Compare
9306f0d to
1401eca
Compare
There was a problem hiding this comment.
Pull request overview
This PR is a full rewrite of crates/ohno_macros, introducing a phased architecture (parse → validate → generate), new internal diagnostics accumulation, and a refreshed test/snapshot setup to keep macro behavior pinned by crates/ohno’s existing UI/trybuild tests.
Changes:
- Replaces the previous
ohno_macrosimplementation with new modules for diagnostics, marker handling, message lowering, and codegen. - Adds/updates documentation describing requirements and design for the rewritten crate.
- Adds
justrecipes to run and overwrite trybuild-based diagnostic snapshots more conveniently.
Reviewed changes
Copilot reviewed 147 out of 147 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| justfiles/basic.just | Adds trybuild and trybuild-overwrite recipes for running/refreshing compile-fail diagnostic tests. |
| crates/ohno_macros/src/utils.rs | Removes legacy helper macros/utilities from the previous implementation. |
| crates/ohno_macros/src/paths.rs | Centralizes ::ohno::... paths used by generated code. |
| crates/ohno_macros/src/message.rs | Introduces message lowering/rendering utilities and parsing for format-like args. |
| crates/ohno_macros/src/marker.rs | Implements the reserved doc-marker scheme for injected core fields. |
| crates/ohno_macros/src/lib.rs | Rewires proc-macro entry points to new expand(...) APIs and adds module structure. |
| crates/ohno_macros/src/diagnostics.rs | Adds an Errors accumulator for multi-error reporting. |
| crates/ohno_macros/src/derive_error/types.rs | Removes prior derive helper types from the old implementation. |
| crates/ohno_macros/src/enrich_err/tests.rs | Removes prior enrich_err unit tests from the old implementation. |
| crates/ohno_macros/src/enrich_err/test_attrs.rs | Removes prior signature/attribute preservation tests from the old implementation. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__the_signature_survives_untouched.snap | Adds updated snapshot for signature preservation behavior. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__the_body_runs_inside_a_closure.snap | Adds updated snapshot for closure-wrapped body behavior. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__arguments_are_passed_through_unchanged.snap | Adds updated snapshot asserting argument passthrough. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__an_inline_capture_goes_through_format.snap | Adds updated snapshot for inline capture formatting. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__an_async_function_awaits_an_async_block.snap | Adds updated snapshot for async rewrite shape. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_self_prefixed_argument_is_left_alone.snap | Adds updated snapshot ensuring self. args remain unchanged. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_non_literal_first_argument_is_rejected.snap | Adds updated snapshot for rejecting non-literal messages. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_non_function_is_rejected.snap | Adds updated snapshot for rejecting non-function inputs. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_missing_return_type_is_rejected.snap | Adds updated snapshot for rejecting missing return type. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_literal_message_renders_without_format.snap | Adds updated snapshot for literal message optimization. |
| crates/ohno_macros/src/enrich_err/snapshots/ohno_macros__enrich_err__tests__a_bare_attribute_names_the_function.snap | Adds updated snapshot for default message behavior. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__other_attributes_and_docs_survive.snap | Adds snapshots for #[ohno::error] rewrite behavior. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__no_constructors_is_rejected.snap | Adds snapshot for #[no_constructors] rejection under #[ohno::error]. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__an_ordinary_doc_comment_is_left_alone.snap | Adds snapshot for preserving ordinary doc comments. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_unit_struct_becomes_a_tuple_struct.snap | Adds snapshot for unit-struct rewrite behavior. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_tuple_struct_gains_a_trailing_core.snap | Adds snapshot for tuple-struct rewrite behavior. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_non_struct_is_rejected.snap | Adds snapshot for rejecting non-struct items. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_named_struct_gains_a_named_core.snap | Adds snapshot for named-struct core insertion. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_marked_field_is_rejected.snap | Adds snapshot for rejecting #[error] on user fields under #[ohno::error]. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_hand_written_reserved_marker_is_rejected.snap | Adds snapshot for rejecting reserved marker misuse. |
| crates/ohno_macros/src/error_attr/snapshots/ohno_macros__error_attr__tests__a_colliding_name_is_numbered.snap | Adds snapshot for disambiguating inserted core field names. |
| crates/ohno_macros/src/derive_error/ast.rs | Adds the decoded AST layer used between parse and validate. |
| crates/ohno_macros/src/derive_error/mod.rs | Implements the parse/validate/generate pipeline and “all-or-nothing” emission gating. |
| crates/ohno_macros/src/derive_error/generate/mod.rs | Adds generation phase entry point plus generator-focused tests/snapshots. |
| crates/ohno_macros/src/derive_error/generate/traits.rs | Implements generated trait impls (Display/Error/Enrichable/ErrorExt/Debug). |
| crates/ohno_macros/src/derive_error/generate/conversions.rs | Implements generated From<T> conversions and From<Infallible>. |
| crates/ohno_macros/src/derive_error/generate/constructors.rs | Implements generated new/caused_by constructors. |
| crates/ohno_macros/src/derive_error/display/template.rs | Adds template splitting/lowering logic for #[display(...)]. |
| crates/ohno_macros/src/derive_error/display/argument.rs | Adds positional argument rooting/scoping logic for #[display(...)]. |
| crates/ohno_macros/src/derive_error/display/mod.rs | Implements #[display(...)] lowering into Message with diagnostics. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__the_suppressing_flags_remove_their_items.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__generics_thread_through_every_impl.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__conversions_initialize_every_non_core_field.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__a_tuple_struct_generates_positional_items.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__a_single_field_struct_takes_no_constructor_parameters.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__a_named_struct_generates_every_item.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__a_message_overrides_the_default.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/generate/snapshots/ohno_macros__derive_error__generate__tests__a_core_in_the_middle_keeps_declaration_order.snap | Adds/updates generator snapshot coverage. |
| crates/ohno_macros/src/derive_error/snapshots/ohno_macros__derive_error__tests__a_valid_input_expands_to_every_item.snap | Adds/updates derive expansion snapshot coverage. |
| crates/ohno_macros/src/derive_error/snapshots/ohno_macros__derive_error__tests__a_rejected_input_expands_to_diagnostics_only.snap | Adds/updates derive expansion snapshot coverage. |
| crates/ohno_macros/src/derive_error/snapshots/ohno_macros__derive_error__tests__a_valid_shape_with_an_invalid_template_generates_nothing.snap | Adds/updates derive expansion snapshot coverage. |
| crates/ohno_macros/src/derive_error/snapshots/ohno_macros__derive_error__tests__the_suppressing_flags_remove_their_items.snap | Adds/updates derive expansion snapshot coverage. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__too_few_arguments_are_reported.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__the_added_core_is_not_referenceable.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__every_fault_in_one_template_is_reported_together.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__escapes_are_resolved_for_a_literal_message.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__an_unsupported_argument_root_is_reported.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__an_unknown_placeholder_lists_the_available_fields.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__an_unknown_argument_root_is_reported.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__an_unconsumed_argument_is_reported.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__an_unbalanced_brace_stops_the_lowering.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_stray_closing_brace_stops_the_lowering.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_tuple_field_is_named_by_index.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_static_template_lowers_to_a_literal.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_self_prefixed_argument_is_reported.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_raw_identifier_is_offered_with_its_prefix.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_positional_argument_is_scoped_and_parenthesized.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_named_placeholder_becomes_a_field_access.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_format_spec_survives_lowering.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_declared_core_stays_referenceable.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/src/derive_error/display/snapshots/ohno_macros__derive_error__display__tests__a_declared_core_is_offered_as_an_available_field.snap | Adds/updates display lowering snapshots. |
| crates/ohno_macros/README.md | Updates generated README to include #[ohno::error] in the public surface list. |
| crates/ohno_macros/docs/requirements.md | Adds requirements doc for crate behavior and diagnostics expectations. |
| crates/ohno_macros/docs/error_error.md | Updates error-field selection design notes. |
| crates/ohno_macros/docs/error_display.md | Updates display argument-scoping design notes. |
| crates/ohno_macros/Cargo.toml | Adds syn feature flag (clone-impls) needed by the new implementation. |
| .spelling | Adds new accepted words used in docs/comments/snapshots. |
Suppressed comments (1)
justfiles/basic.just:251
- Same issue as
trybuild:cargo test ... --tests -- {{ FILTER }}filters test names but still builds/runs every integration test target. When FILTER is provided, prefer--test {{ FILTER }}so only the selected trybuild target is executed while overwriting snapshots.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 147 out of 147 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/ohno_macros/docs/requirements.md:9
- The requirements doc says unit tests and expansion snapshots were removed, but this rewrite adds extensive unit tests and
instasnapshots undercrates/ohno_macros/src/**/snapshots. This wording is currently inaccurate and could mislead readers about where internal checks live vs. what is authoritative.
The authority for behavior is `crates/ohno/tests/**` (integration tests) and
`crates/ohno/tests/ui/**` (compile-fail snapshots). Nothing in the crate's own
tree is authoritative; internal unit tests and expansion snapshots were removed
with the implementation.
crates/ohno_macros/src/error_attr/mod.rs:140
unused_nameuses.unwrap_or_default(), which can silently return an empty string if the search ever fails and then fail later (or generate a bogus ident). Since the preceding comment argues the search is guaranteed to find a free suffix, use.expect(...)so an invariant break is reported immediately and with a clear message.
|
🤖 Clawpilot here! Posted automatically by Clawpilot (an AI agent), not by a human. Please verify before acting. Answering the two suppressed comments in #656 (review), since they are not threads and cannot be replied to inline. 1. 2.
Swapping in (The third suppressed comment, on |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 147 out of 147 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/ohno_macros/src/error_attr/mod.rs:140
unused_nameis documented as a bounded search that always finds a free candidate, but the fallback currently uses.unwrap_or_default(). If the invariant is ever violated, this would produce an empty identifier string and then panic later (likely insideformat_ident!) with a much less actionable error. Prefer an.expect(...)here to make the invariant explicit and keep any failure localized to this logic.
|
review this PR |
martintmk
left a comment
There was a problem hiding this comment.
[Copilot speaking]
Reviewed the rewrite end to end: the three-phase split is a real improvement over what it replaces, the diagnostics story is coherent, and the test design (whole-expansion snapshots, per-phase styles, no proc-macro bridge) is the right call. Two things stop it: #[enrich_err] loses a case that compiles on main today, and the public rustdoc lost consumer-facing facts the requirements doc still claims are documented.
Verdict: changes requested — one verified behavioural regression, one docs regression, plus non-blocking notes inline.
On design.md more broadly: it earns its length, the arguments are real and the "Decided" list is genuinely useful. The one place it overstates itself is the R4 guarantee — "generate cannot fail" is true of generate, but the #[display(...)] format spec is copied through unchecked, so rustc can still land an error on the derive. Inline comment on template.rs with the reproduction.
I am an AI agent; verify before acting on any of this. Every claim below was checked against a build of this branch.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 152 out of 152 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/ohno_macros/src/derive_error/display/argument.rs:77
root()treats any integer literal as a tuple-field index viabase10_digits(), which strips suffixes. That means an argument like0u8would be accepted as index0during validation, but later expanded asself.0u8, which Rust does not accept for tuple-field access. Similar issue for suffixed floats used for nested tuple access (0.1f32).
Reject suffixed numeric literals up front so invalid inputs produce a macro diagnostic (anchored to the argument) instead of a syntax error in expanded code.
Expr::Lit(literal) => match &literal.lit {
Lit::Int(value) => Root::Index(value.base10_digits().to_owned(), expr),
// `0.1` is a nested tuple access, and only its leading component names a field.
Lit::Float(value) => value
.base10_digits()
|
🤖 Clawpilot here! Posted automatically by Clawpilot (an AI agent), not by a human. Please verify before acting. Re the suppressed comment on You are right, and it is worth spelling out why this one matters. The fix is a guard on each literal arm requiring an empty suffix, so such a root falls through to the Verified rather than assumed: the new unit test One doc sentence in This is a good catch of the kind no gate here could make — the fault was invisible to the test suite because nothing exercised a suffixed literal, and invisible to coverage and mutation because the wrong behaviour lived in a branch that was fully covered doing the wrong thing. Thanks. |
…rite design Remove every implementation module and all internal unit/snapshot tests, leaving lib.rs as the declared public surface with unimplemented bodies. The behavioral spec is now solely crates/ohno/tests/** (integration and compile-fail), which is untouched. Add docs/requirements.md (what the rewrite must deliver, extracted from the removed code, the existing docs and the public-surface tests) and docs/design.md (the parse -> validate -> generate pipeline being proposed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Diagnostics accumulate rather than bailing on the first violation, and the compile-fail snapshots are regenerated accordingly. enrich_err keeps a single module instead of the derive's parse/validate/generate split, since its whole input is a message and a signature that is re-emitted rather than read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Settle the phase pipeline, the module tree, the concrete types the phases exchange, how each generated item is produced, and the diagnostics anchors, so implementation can start from the document. Resolve the open question: an owned `Ast` earns its place, because it is what lets validation skip a check whose input failed to decode instead of reporting faults invented by repairing it, and it is the only value holding decoded attribute payloads together with the spans they came from. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Build the three phases the design settles: parse decodes the crate's own attributes into `Ast`, validate applies the rules and yields `Model`, and generate renders tokens. `generate` returns `TokenStream` rather than `Result`, because `Shape` makes "exactly one core" unrepresentable and `Message` makes an argument that is not rooted in a field unrepresentable, so a `Model` that could make generation fail cannot be built. Diagnostics accumulate through one `Errors` type whose `add` takes tokens rather than a `Span`, so every diagnostic is anchored with `syn::Error::new_spanned`. A concern whose own input failed to decode is skipped rather than guessed at, so a malformed template reports one fault instead of faults invented by repairing it. `crates/ohno/tests/**` is untouched and passes unchanged, including all twelve `.stderr` compile-fail snapshots. The design predicted those would need regenerating for accumulation; they did not, because each fixture struct breaks exactly one rule. The design is corrected to say so, and updated where the implementation settled a detail differently: `Message` splits literal from formatted so a static message costs no allocation, `enrich_err` applies its message through `map_err` so it also works for the `Poll<Result<..>>` an implemented `Future::poll` returns, and the generated `Debug` is deliberately not `#[automatically_derived]`, because dead-code analysis skips field reads in a derived `Debug` and would report every `Debug`-only field as unused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the substring assertions on expanded tokens with `insta` snapshots of the pretty-printed output. What these macros have to get right is the shape of what they emit — which body runs where, which field lands in which position, what survives beside it — and a substring assertion cannot see shape: it confirms one token appears somewhere and passes on an expansion that is wrong everywhere it did not look. Asserting on a `TokenStream`'s rendering also made the expected value space-separated token soup (`(| | -> Result < () , MyError >`) rather than readable Rust. The display snapshots carry the lowered message and its diagnostics together, because they are one outcome: a template either lowers or reports why it cannot, and showing one half alone cannot tell "lowered cleanly" apart from "lowered and also complained". Add `just trybuild` to run the compile-fail tests alone while iterating on a diagnostic, and `just trybuild-overwrite` to rewrite the `.stderr` snapshots when a message or a span changes on purpose. Both use `cargo test` rather than nextest: one trybuild target drives every case in it from a single test function, and its own output is what names the case that failed, which nextest would collapse into one opaque target failure. Running `trybuild-overwrite` over the existing snapshots rewrote them byte-identically, which is independent confirmation that the implementation's diagnostics match what the spec pinned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The `ohno_macros` rewrite is merge-blocked on spellcheck, not on design:
19 of 48 checks fail, and every one traces to `anvil-spellcheck`
(Hunspell) rejecting words in the new rustdoc. The diary entry for
2026-08-11 records it as "a dictionary problem, not a design problem, but
it is unfixed" — so the analysis was done and only the dictionary entry
was missing.
Words added, each read off the CI caret rather than guessed:
backticked derive_error/display/mod.rs:42
derive's marker.rs:7
initializers derive_error/generate/mod.rs:45, model.rs:128,
model.rs:156, generate/conversions.rs:27
lexes derive_error/display/argument.rs:20
referenceable derive_error/validate.rs:10, ast.rs:60
reportable derive_error/ast.rs:51
substrings error_attr/mod.rs:151, enrich_err/mod.rs:124
unrepresentable derive_error/model.rs:40
unterminated derive_error/display/template.rs:61
The diary named four of these. Decoding the caret column against each
source line found nine, and corrected one misreading that matters:
`snapshotted` is ALREADY in `.spelling`, and the two lines containing it
are flagged for `substrings` later on the same line. Trusting the summary
would have left both sites red and looked like the dictionary was being
ignored.
Why the dictionary and not the prose: these are precise technical terms,
and extending `.spelling` is this repo's established practice — it
already carries `dereferenceable`, `representable`, `initializer`,
`substring`, `tokenizes`, and ~25 possessives (`struct's`, `serde's`,
`enum's`). Rewording accurate documentation to satisfy a word list was
rejected; the docs are not wrong.
Why each inflection is listed separately: `initializer` and `substring`
are present yet `initializers` and `substrings` are flagged, and
`representable` is present yet `unrepresentable` is flagged. The
`anvil-spellcheck` recipe generates `target/spelling.dic` from `.spelling`
with no affix flags, so Hunspell does no morphology on these entries and
every inflection needs its own line. Appended at the end of the file,
matching the existing convention — the recipe sorts at check time.
Verification, stated exactly: the failing words come from the actual CI
log of run 31566064059 at head 09e8907, and absence from `.spelling` was
confirmed directly. The gate could NOT be run locally — `cargo-spellcheck`
is not installed and `cargo install` is unavailable in this environment —
so this is verified against the gate's own output, not against a local
re-run. CI on this push is the confirming check.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`wrap` now takes the return type the caller already destructured, which removes the `unreachable!` the async branch needed. `scope_to_self` matches the two rooted variants directly, which removes the second `unreachable!` and the `field_name` lookup that fed it. The three proc-macro entry points are excluded from coverage and from mutation testing: a unit test cannot build a `proc_macro::TokenStream`, so they are exercised through the `ohno` crate instead. That also makes the `mutants` dev-dependency used, which `cargo udeps` reported as unused. Two tests cover the remaining uncovered lines: a `#[doc = ...]` whose value is not a literal, and a `#[from(...)]` entry that names no type. The README is regenerated from the current crate documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`anvil-mutants-diff` failed with five timeouts, all of them mutants of the scanner's index arithmetic: an index that stops moving forward, or moves backward, turns the scan into an infinite loop, so the mutant is reported as a timeout rather than as a failed assertion. The scan now walks `bytes.iter().enumerate()`, which always moves forward, and skips a placeholder with `nth` rather than by assigning a computed index. A corrupted computation now produces a wrong segment that a test asserts on. The escape arms fold into one, since both consume a doubled brace. The design document said this mutant class survived by construction; that is no longer true, so it is corrected. Verified: the same scoped `cargo mutants` run fails with those five timeouts on the previous code and passes on this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`#[from(...)]` stopped collecting the source type at every parenthesis group, so a parenthesized type — a tuple, a function pointer, a callable trait object — was truncated or lost entirely. A group is now read as an override list only when it opens with a member and a lone `:`, which is what tells `(kind: e.kind())` apart from `(u32, String)`. `#[no_debug]` and `#[no_constructors]` are bare markers per R1.7, but only their paths were inspected, so `#[no_debug(foo)]` suppressed the item silently. Both now go through `check_bare_marker`, which already did this for `#[error]`. R1.2 rejects `#[error]` beside the generated marker, but the check only looked at the marked field itself, so a marker on a sibling was dropped and the core chosen silently. The generated marker now seeds the "already marked" state, which also removes the second pass. The design document drifted from the code in four places: the pipeline blurb claimed validate applies R2 and R3, the `Ast` sketch predated `marks`/`generated`, `#override_message` was described as always allocating, and `Style` was said to be read by one item. `error_display.md` and `error_error.md`, which `requirements.md` names as the full statement of the rules, still described the implementation this PR replaced. The `impl Trait` return-type limit is recorded beside the `const fn` one. The crate's `# Status` section pointed at the requirements and design documents from public rustdoc; that is process documentation and is removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`anvil-spellcheck` rejected `R1` in the `ast.rs` module docs — the Hunspell dictionary has no entry for it, and there is no reason to add one. The sentence says "the derive's own rule violations" instead, which is what the reader needs anyway; the rule numbering lives in the documents under `docs/`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`requirements.md` was written before the rewrite and said the internal unit tests and expansion snapshots had been removed. The rewrite ships both, so the sentence describes a state that never shipped. It now says what is true: `crates/ohno/tests/**` remains the only place that compiles what the macros produce, and the crate's own tests pin the shape of the tokens each phase emits as a regression net. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`wrap` named the declared return type in both arms — as the closure's return type, and as the `let` annotation on the async arm. Both are positions where an opaque type is rejected, so a function returning `Result<impl Trait, E>` stopped compiling with E0562. That case builds on main. The annotation turns out to buy nothing. The wrapper's tail is the function's return expression, so inference reaches the body from the signature: with both annotations dropped the whole `ohno` suite passes, including the `Err`-only bodies the annotation was supposed to pin. `an_opaque_ok_type_is_supported` covers the case that regressed and fails with E0562 without this change. The eight `enrich_err` snapshots change by exactly the dropped annotation. `wrap` no longer needs the type, so it takes only the function; the presence check stays in `expand`, which still has to reject a function with no return type at all. The derive's rustdoc regains two facts a consumer cannot get elsewhere: that the generated constructors are `pub(crate)` even on a `pub` error type and that `#[no_constructors]` is rejected under `#[ohno::error]`, and that an existing manual `#[derive(Debug, Error)]` now collides. `requirements.md` R1.4 already claims the first is documented. A second `#[display(...)]` silently replaced the first while a second `#[from(...)]` accumulates; it is now reported, which is what the parse/validate split exists to do. `unused_name` uses `expect` with the reason in the message, as AGENTS.md asks, rather than `unwrap_or_default` on an unreachable branch. `design.md` records that a `#[display(...)]` format spec is carried through unchecked, so a spec referring to another argument is reported against the derive. That is the one place the guarantee is not structural, and the document previously stated it without qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A `#[display(...)]` positional argument written as a bare call — `describe()` — was rooted as unsupported, so the diagnostic promising "a field or method of `self`" rejected the very thing it named. A call in leftmost position is now a method root: it is prefixed with `self.` and looked up as no field. A type whose every field is generated offered "available fields: " with nothing after it. It now says the error type has no fields that can be referenced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A `#[from(...)]` source type was collected token by token up to the next comma, so a comma inside a generic argument list ended the type early and `#[from(PairError<u32, String>)]` failed to parse. The type is now parsed speculatively by `syn`, which knows where a type ends; the token walk remains for the one case `syn` cannot decide, a `(member: expression)` override list following a plain path, which reads as a parenthesized generic argument list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The `#[display(...)]` format spec limit was filed under `#[enrich_err(...)]`, which is not the macro it describes, and the paragraph explaining the wrapper's return type had drifted away from the code block it explains. Both limits now sit in a `Limits` section, which is what `requirements.md` and `template.rs` already point at, and the return type paragraph is back beside its code block. R4 and R5 now say what the code does: the two `rustc`-reported exceptions, and that mutation testing covers `validate.rs` and `display/`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ocals `From<T>` bound each data initializer to its own `__ohno_field_N` local, so a generated name was in scope while the next initializer was evaluated. An initializer naming an outer item the derive happened to shadow read the generated local instead — and when that outer item is a `const`, the `let` becomes a constant pattern and generated code fails to compile, which is the outcome R4 exists to prevent. The old struct-literal expansion had no locals, so this was a regression. The initializers are now evaluated into one tuple, whose elements are all evaluated before its binding exists, and the fields read tuple indices. The two affected snapshots change by exactly that and nothing else. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The comment explaining the `Expr::Call` arm was duplicated above `Expr::Index`, where it describes nothing — indexing is not a call. It was left behind when the `Call` arm was moved into place. A test asserting an opaque `Ok` type uses `unwrap` rather than `expect`, which is what AGENTS.md asks for in test code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The speculative parse added a token-by-token fallback for the case `syn` was said not to decide, a `(member: expression)` override list following a plain path. That justification was wrong: `syn` stops at the group on its own, so the fallback was reachable only to produce an error. CI proved it — three mutants of its loop condition survived and the crate's line coverage fell to 99.8%, both pointing at code no test could reach. `source_type` now rejects an entry that opens with an override list, which is the one thing a type cannot start with, and hands the rest to `syn`. All 143 crate tests and the `ohno` suite pass unchanged, mutation testing on the file is clean, and coverage is back to 100%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
R4 and the Limits section both said there were exactly two inputs that reach `rustc` instead of a macro. There is a third: `#[enrich_err(...)]` checks that a return type is present, not what it is, so a function returning a plain value meets the generated `map_err` at the compiler. Recorded, with why it cannot be decided syntactically. Neither document counts any more. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`root` read every integer literal as a tuple index through `base10_digits`,
which drops the suffix. `#[display("{}", 0u8)]` therefore validated against
field `0` and then expanded to `self.0u8`, which is not a tuple field access,
so the input reached `rustc` as a syntax error in generated code instead of a
macro diagnostic. The same held for a suffixed float used for nested access.
A suffix is now required to be absent for both literal kinds, so such a root
falls through to the existing unsupported-root diagnostic. No new message.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cecf73a to
1f80640
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 152 out of 152 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/ohno_macros/src/message.rs:54
Message::renderrebuilds string literals withSpan::call_site(), andenrich_err::parse_message/display::lowerconvertLitStrintoStringearly. This drops the user-authored literal span, so any compiler diagnostics originating from the generatedformat!(e.g., bad captures like{missing}or invalid format specs) will point at the macro expansion/call-site instead of the attribute argument that caused the error. If the design goal is for diagnostics to be anchored in user code, preserve the originalLitStrspan through lowering (e.g., store the span inMessageand use it when constructingLitStrinrender).
🤖 Clawpilot here! Posted automatically by Clawpilot (an AI agent), not by a human. Please verify before acting.
What this PR does
This PR rewrites
crates/ohno_macrosfrom zero. It adds two documents that state what the crate has to deliver and how it is arranged to deliver it, then implements the crate from them.docs/requirements.md— the behavior the crate owes: the derive (R1),#[ohno::error](R2),#[enrich_err](R3), diagnostics (R4), quality gates (R5).docs/design.md— the phases, the module tree, the types the phases exchange, how each generated item is produced, the diagnostics anchors, and the testing plan.src/— the implementation.The shape
The derive runs three phases:
TokenStream -> parse -> Ast -> validate -> Model -> generate -> TokenStream.TokenStream, notResult. It cannot fail.That last point is the design. R4 forbids a diagnostic that points into generated code, and that is only structural if a
Modelwhich could make generation fail cannot be built.Shapesplits the field list around the core instead of carrying an index, so "exactly one core" is unrepresentable.Messagecan only be obtained through validation, so an argument that is not rooted in a field is unrepresentable.Where an invariant is not structural, the design says so plainly. The alignment between a conversion's initializers and the field list is a relation between two values; it is held by a private field and a single constructor, not pretended into the type system.
#[ohno::error]keeps no model and#[enrich_err]keeps noAst, because neither has enough input to warrant one.Diagnostics
Faults accumulate through one
Errorstype. Itsaddtakes tokens rather than aSpan, so R4's rule that a multi-token diagnostic usessyn::Error::new_spannedis enforced by the signature.Accumulation runs across independent concerns. A concern whose own input failed to decode is skipped, not guessed at: a template that cannot be split reports one fault, instead of that fault plus field errors invented by repairing it. This is what the separate parse phase buys.
Tests
crates/ohno/tests/**is untouched and is the sole behavioral spec. It passes unchanged, including all twelve.stderrcompile-fail snapshots.The design predicted those snapshots would need regenerating to show accumulated errors. They did not: each fixture struct breaks exactly one rule, and separate structs are separate macro invocations. The design has been corrected to record that.
The crate also has 133 unit tests, one style per phase, none of which needs the proc-macro bridge:
parse_quote!in andAstasserted for parse, the exact diagnostic set asserted for validate, andinstasnapshots ofprettypleaseoutput from a hand-builtModelfor generate.cargo mutantscatches every mutant in the rule-bearing modules. Two classes survive by construction and are documented: the three#[proc_macro]entry points cannot be called from a unit test at all, so onlycrates/ohno/tests/kills them; and a mutant that corrupts the template scanner's index arithmetic is reported as a timeout rather than a failed assertion.Gates
cargo check,clippyat workspace lint level with warnings as errors,cargo teston both crates, doc tests, examples,cargo doc,cargo fmt --check,cargo macheteand the license-header check all pass.Notes for review
Three places where the implementation settled a detail differently from the first draft of the design, each now recorded in it:
Messagesplits literal from formatted, so a static#[display("...")]renders as a string literal and costs no allocation at run time.#[enrich_err]applies its message throughmap_errrather than throughEnrichableon the result. The return type is not always aResult: an implementedFuture::pollreturnsPoll<Result<..>>, which carriesmap_errbut is not itselfEnrichable.Debugis deliberately not#[automatically_derived]. Dead-code analysis skips field reads inside a derivedDebug, so marking it would make every field that onlyDebugreads look unused in the user's own crate.One limit is stated rather than hidden: the
#[enrich_err]body rewrite cannot work inside aconst fn.constis re-emitted faithfully, so such a function is rejected byrustcrather than by the macro. No test exercises the combination.