read-only-derive 0.1.1

Proc-macro support crate for read-only
Documentation

read-only-derive

Internal proc-macro support crate for read-only.

This crate implements the #[embed] attribute macro that powers the safe, composition-based readonly pattern exported by the public read-only crate.

It is intended primarily for maintainers, contributors, and anyone trying to understand how the macro parses Rust syntax trees and rewrites them into the generated readonly view type.

Relationship to read-only

  • The root read-only crate is the public user-facing crate.
  • read-only-derive is the proc-macro implementation crate.
  • read_only::embed is re-exported from this crate.
  • read_only::cast does not live here; it is re-exported from readonly::make.

This split follows the common two-crate Rust pattern used by crates such as serde / serde_derive.

What #[embed] Does

At a high level, the macro takes a named-field struct and splits it into two parts:

  1. readonly fields marked with #[readonly]
  2. all remaining fields

It then generates:

  • a ReadOnly{Type} struct containing the readonly fields
  • a rewritten original struct that embeds read_only: ReadOnly{Type}
  • a Deref<Target = ReadOnly{Type}> implementation for the rewritten original struct

This means consumers get direct readonly access syntax through deref, while the implementation remains composition-based rather than cast-based.

Example

Input:

use read_only::embed;

#[embed]
pub struct Device {
    #[readonly]
    pub handle: u64,
    #[readonly]
    pub label: String,
    pub mutable: i32,
}

Expansion shape:

pub struct ReadOnlyDevice {
    pub handle: u64,
    pub label: String,
}

pub struct Device {
    read_only: ReadOnlyDevice,
    pub mutable: i32,
}

impl core::ops::Deref for Device {
    type Target = ReadOnlyDevice;

    fn deref(&self) -> &Self::Target {
        &self.read_only
    }
}

Parsing and Rewrite Rules

The macro operates on a parsed syn::DeriveInput.

Accepted input

Supported:

  • named-field structs
  • generics
  • where-clauses
  • field-level #[readonly]
  • field types containing Self

Rejected:

  • enums
  • unions
  • tuple structs
  • unit structs
  • empty named structs
  • structs with no #[readonly] fields
  • structs with a field already named read_only

Rewrite algorithm

The implementation in src/expand.rs follows roughly this sequence:

flowchart TD
    A[Parse DeriveInput] --> B{Named struct?}
    B -- no --> E1[Emit compile error]
    B -- yes --> C{Has at least one field?}
    C -- no --> E2[Emit compile error]
    C -- yes --> D[Scan fields for #[readonly]]
    D --> F{Any readonly fields?}
    F -- no --> E3[Emit compile error]
    F -- yes --> G{Field named read_only already exists?}
    G -- yes --> E4[Emit compile error]
    G -- no --> H[Split fields into readonly and remaining]
    H --> I[Rewrite readonly field types containing Self]
    I --> J[Generate ReadOnlyType struct]
    J --> K[Generate outer struct with read_only field]
    K --> L[Generate Deref impl]

Field handling rules

Field kind Result
#[readonly] pub field: T moved into ReadOnly* and remains public there
#[readonly] field: T moved into ReadOnly* and promoted to outer struct visibility
non-#[readonly] field stays on outer struct
field type using Self rewritten to the concrete outer type in readonly fields

Why Self Replacement Exists

If a readonly field type refers to Self, that Self cannot remain unchanged inside the generated ReadOnly* struct because the generated struct is a different type.

Example input:

#[embed]
pub struct Node {
    #[readonly]
    pub next: Option<Box<Self>>,
    pub value: i32,
}

If left untouched, Self inside the generated readonly struct would refer to ReadOnlyNode, not Node, which changes semantics.

The macro therefore rewrites readonly field types from:

Option<Box<Self>>

to:

Option<Box<Node>>

This is implemented through a small VisitMut visitor that walks the type syntax tree and replaces path occurrences of Self.

Error Handling Model

The macro uses two kinds of errors:

  1. hard structural errors that stop expansion immediately
  2. collected attribute-shape errors that are appended as compile errors

Hard structural errors include:

  • wrong item kind
  • wrong field style
  • empty struct
  • missing readonly fields
  • conflicting read_only field name

Collected attribute errors currently come from malformed #[readonly] usage, for example:

#[readonly(oops)]

The macro strips the attribute and preserves the diagnostic by storing the parser error and appending its to_compile_error() output to the final token stream.

Why the Code Is Written This Way

The implementation may look a little unusual compared with some proc-macro crates that aggressively factor parsing into many helpers.

That is intentional.

The current style aims to keep the most important structural validation and rewrite steps in a single visible control flow inside embed():

  • validate input shape
  • strip and classify readonly fields
  • split field ownership
  • rewrite Self
  • emit expansion

This has a few benefits:

  • maintainers can read the expansion logic top-to-bottom without jumping across many files
  • structural error paths stay close to the logic that needs them
  • macro compile tests map more directly to the expansion steps

It also ended up being helpful for coverage work. Some of the code shape was chosen to keep validation paths explicit and testable rather than hiding them behind layered helper abstractions that would leave more unreachable or hard-to-attribute coverage artifacts in proc-macro expansion code.

That does not mean the code is coverage-driven first; it means we preferred an implementation style that is both readable and easy to verify with compile tests.

Testing Strategy

This crate is exercised through the root workspace test suite:

  • tests/macro/pass/*.rs: passing macro expansion cases
  • tests/macro/fail/*.rs: compile-fail macro expansion cases with checked stderr snapshots
  • tests/miri.rs: runtime coverage of the unsafe cast-based path in the root crate (cast), included because the public crate exposes both patterns

Useful commands:

cargo test
cargo test --test compile
cargo +nightly miri test --test miri

Relevant Source Files

  • src/lib.rs: proc-macro entrypoint for #[embed]
  • src/expand.rs: parsing, validation, tree rewriting, and token generation
  • ../tests/macro/: compile-time macro test suite

Related Reading

Publishing Note

This crate is intended to be published before the root read-only crate, because the public crate depends on it as a versioned proc-macro dependency.