Introduction
attribute-dsl parses Rust proc-macro attributes made from a path root,
dot-call chains, comma-separated entries, and named groups. It preserves
syn syntax nodes so a macro can inspect the input, report spanned errors, and
quote the parsed pieces into generated Rust.
This guide is for authors of derive and attribute macros. It assumes familiarity
with syn, quote, and Rust token streams. The crate supports Rust 1.96 and
edition 2024.
Use the crate when an attribute accepts syntax such as:
RootType::<_>.first(1).second::<String>("value")
The parsed model separates that input into a syn::Path root, ordered
ChainCall values, and an optional completion-probe marker. Additional parsers
cover labeled entries, comma-separated lists, and named parenthesized groups.
The infer helpers replace _ placeholders with an application-owned subject
type without converting syntax trees to strings. This is useful when an
attribute describes a builder or validator whose type depends on the annotated
field.
Start with Getting started. Continue to Parse chain syntax when choosing a parser, Emit completion probes for rust-analyzer completion, and Substitute infer placeholders for subject-type substitution. Build a proc-macro expansion combines those operations into one derive-macro workflow.
Getting started
This path parses one path-rooted dot-call chain and inspects its root and calls.
A successful cargo check confirms the parser and syn types resolve in the
macro implementation crate.
Prerequisites
- Rust 1.96 or newer.
- A derive-macro or attribute-macro implementation crate.
- A clear grammar for the attribute accepted by that macro.
Add dependencies
Add attribute-dsl alongside the syntax dependencies used by the macro:
[dependencies]
attribute-dsl = "0.1"
quote = "1.0"
syn = { features = [ "full" ], version = "2.0" }
Parse a chain
AttributeChain implements syn::parse::Parse, so it works with attribute
argument parsing and syn::parse_str:
use attribute_dsl::AttributeChain;
let chain: AttributeChain =
syn::parse_str("RootType::<_>.first(1).second::<String>(\"value\")")?;
assert_eq!(
chain
.root_path()
.segments
.last()
.expect("a parsed path has a segment")
.ident,
"RootType"
);
assert_eq!(chain.calls().len(), 2);
Ok::<(), syn::Error>(())
In a proc macro, use attr.parse_args::<AttributeChain>()? to retain the
attribute’s source spans in diagnostics.
Verify the integration
Run cargo check in the macro workspace. A successful check confirms that the
crate versions resolve and the selected parser API is available.
If parsing fails, return the syn::Error from the macro expansion path or
combine it with other spanned diagnostics. Do not replace it with a string-only
error, because callers need the source span to locate invalid syntax.
Parse chain syntax
Choose the parser that matches the complete attribute arguments. Each parser
retains paths, identifiers, and call arguments as syn nodes.
| Input shape | Parser |
|---|---|
Root::<_>.first(1) | AttributeChain |
label = Root::<_>.first(1) | ChainEntry |
Root::<_>, other = Root::<i32> | ChainList |
fields(Root::<_>, other = Root::<i32>) | NamedChainGroup |
Supported grammar
An AttributeChain starts with a Rust path. Each following call requires a dot,
a method name, and parentheses. Calls can include turbofish arguments and
comma-separated syn::Expr arguments.
AttributeChain := Path ("." Ident Turbofish? "(" Expr,* ")")* CompletionProbe?
CompletionProbe := "." CompletionMarker
ChainEntry := (Ident "=")? AttributeChain
ChainList := (ChainEntry ("," ChainEntry)* ","?)?
NamedChainGroup := Ident "(" ChainList ")"
Path includes module-qualified and absolute paths with normal Rust generic
arguments. Parentheses around a complete chain are accepted and normalized to
the same parsed model. A ChainList and the contents of a NamedChainGroup may
be empty, and lists may end with a comma.
Inspect parsed values
Use the accessors instead of reparsing tokens:
use attribute_dsl::{AttributeChain, ChainList, NamedChainGroup};
let chain: AttributeChain = syn::parse_str("Root::<i32>.first(1)")?;
let first_call = &chain.calls()[0];
assert_eq!(first_call.method().to_string(), "first");
assert_eq!(first_call.args().len(), 1);
let list: ChainList =
syn::parse_str("value = Root::<_>.first(1), Root::<String>")?;
assert_eq!(list.entries().len(), 2);
assert_eq!(
list.entries()[0]
.label()
.expect("a labeled entry")
.to_string(),
"value"
);
let group: NamedChainGroup =
syn::parse_str("fields(value = Root::<_>, Root::<String>)")?;
assert_eq!(group.name().to_string(), "fields");
assert_eq!(group.entries().len(), 2);
Ok::<(), syn::Error>(())
AttributeChain and ChainCall implement quote::ToTokens. An expansion that
changes the root or completion behavior should quote the root, calls, and probe
separately instead.
Keep constructors in the expansion
The chain root must remain a syn::Path; arbitrary Rust expressions are not
accepted. This boundary keeps parsing predictable and leaves construction in
the consumer macro.
| Unsupported input | Action |
|---|---|
Root::builder().first(1) | Parse Root.first(1), then emit Root::builder() before the calls. |
Root.field | Use a method call, or reserve the configured terminal marker for completion probes. |
left + right | Parse the expression with syn directly instead of AttributeChain. |
Perform domain checks after parsing and attach each syn::Error to the
narrowest relevant path, method, or argument node.
Emit completion probes
Completion probes let an incomplete trailing dot produce rust-analyzer method completion inside an attribute token tree. Use them only when the macro emits the marker after a real typed receiver.
Handle a trailing dot
With the default options, Root::<_>.first(1). parses as a chain whose terminal
marker is raCompletionMarker:
use attribute_dsl::AttributeChain;
use quote::quote;
let chain: AttributeChain = syn::parse_str("Root::<_>.first(1).")?;
assert!(chain.has_completion_probe());
let completion = chain
.completion_marker()
.map(|marker| quote! { .#marker })
.unwrap_or_default();
Ok::<(), syn::Error>(())
Append completion after the application-owned constructor and parsed calls.
At that position, the missing marker method gives rust-analyzer a typed
receiver and allows it to offer the receiver’s methods at the original dot.
The explicit terminal syntax Root::<_>.raCompletionMarker represents the same
completion state.
Configure the marker
Use one stable Rust identifier when the consumer needs a custom marker:
use attribute_dsl::{AttributeChain, ChainParseOptions};
use quote::quote;
let options = ChainParseOptions::new().completion_marker("completeHere");
let chain = AttributeChain::parse_tokens_with_options(
quote!(Root::<_>.first(1).),
&options,
)?;
assert_eq!(
chain
.completion_marker()
.expect("the input ends with a probe")
.to_string(),
"completeHere"
);
Ok::<(), syn::Error>(())
An invalid identifier produces a syn::Error when trailing-dot recovery needs
to emit it. Custom ChainParseOptions apply to direct AttributeChain parsing;
the Parse implementations for entries, lists, and groups use the defaults.
Reject probe syntax
Disable completion probes when the macro accepts only complete chains or cannot emit a typed receiver:
use attribute_dsl::{AttributeChain, ChainParseOptions, CompletionProbeParsing};
use quote::quote;
let options = ChainParseOptions::new()
.allow_completion_probe(CompletionProbeParsing::Disabled);
let result = AttributeChain::parse_tokens_with_options(
quote!(Root::<_>.first(1).),
&options,
);
assert!(result.is_err());
Disabling probes rejects both trailing-dot recovery and an explicit terminal completion marker.
Preserve later list entries
Default completion recovery stops before a comma in ChainList, so later
entries remain available while one entry is incomplete:
use attribute_dsl::ChainList;
let list: ChainList = syn::parse_str(
"first = Root::<_>., second = Other::<String>",
)?;
assert!(list.entries()[0].chain().has_completion_probe());
assert_eq!(list.entries().len(), 2);
Ok::<(), syn::Error>(())
If parsing succeeds but rust-analyzer offers no methods, inspect the generated expression. The constructor and all preceding calls must resolve to the desired receiver type before the marker access.
Substitute infer placeholders
Use the infer helpers when _ in an attribute stands for a subject type known
by the macro, such as an annotated field’s type. Each helper returns a new
syntax tree and leaves the input node available to the caller.
| Helper | Use it for |
|---|---|
split_terminal_single_type_arg | Distinguish an absent, inferred, or explicit final type argument. |
substitute_infer_in_path | Replace _ inside path arguments. |
substitute_infer_in_type | Replace _ in supported nested type forms. |
substitute_infer_in_expr | Replace _ in paths and types nested in an expression. |
Inspect a terminal type argument
split_terminal_single_type_arg consumes a path, removes one terminal type
argument, and returns SingleTypeArg::None, SingleTypeArg::Infer, or
SingleTypeArg::Explicit:
use attribute_dsl::{SingleTypeArg, split_terminal_single_type_arg};
use syn::{Path, parse_quote};
let path: Path = parse_quote!(RootType::<_>);
let (root, argument) = split_terminal_single_type_arg(path, "validator")?;
assert_eq!(
root.segments
.last()
.expect("a parsed path has a segment")
.ident
.to_string(),
"RootType"
);
assert!(matches!(argument, SingleTypeArg::Infer));
Ok::<(), syn::Error>(())
The subject string appears in diagnostics. Use the consumer’s domain term,
such as "validator" or "component", so errors identify the invalid path.
The helper returns a syn::Error for multiple arguments, non-type arguments,
or parenthesized arguments on the final segment.
Substitute nested placeholders
The path helper traverses generic arguments on every segment, including nested types, associated type values and constraints, and parenthesized function arguments and results.
The type helper supports:
_and paths with type arguments;- arrays, slices, raw pointers, and references;
- bare function inputs and results;
- trait-object and
impl Traitbounds; - tuples; and
- parenthesized and grouped types.
The expression helper traverses the expression and applies the path and type
rules wherever those nodes occur. A syn::Type variant outside the listed
forms is cloned unchanged, so choose a consumer grammar whose placeholders are
within the supported forms.
use attribute_dsl::{
substitute_infer_in_expr, substitute_infer_in_path,
substitute_infer_in_type,
};
use quote::ToTokens as _;
use syn::{Expr, Path, Type, parse_quote};
let replacement: Type = parse_quote!(i32);
let path: Path = parse_quote!(RootType::<Option<_>>);
let path = substitute_infer_in_path(&path, &replacement);
assert!(path.to_token_stream().to_string().contains("i32"));
let ty: Type = parse_quote!(fn([_; 2], &[_]) -> Option<_>);
let ty = substitute_infer_in_type(&ty, &replacement);
assert!(ty.to_token_stream().to_string().contains("i32"));
let expr: Expr = parse_quote!(RootType::<_>.first(Vec::<_>::new()));
let expr = substitute_infer_in_expr(&expr, &replacement);
assert!(expr.to_token_stream().to_string().contains("i32"));
Keep the replacement as a syn::Type and quote the returned tree directly.
Converting through source strings discards syntax and span information needed
for precise diagnostics.
Build a proc-macro expansion
Build an expansion by parsing each matching attribute, substituting its subject type, preserving every call in order, and appending a completion marker only when the parsed chain contains one.
Expand one attribute
For each attribute:
- Parse the arguments with
attr.parse_args::<AttributeChain>()?. - Substitute the field type into
chain.root_path(). - Quote each call’s method, optional turbofish, and arguments.
- Quote
chain.completion_marker()after the calls when it exists. - Place the result after an application-owned constructor that returns the desired receiver type.
use attribute_dsl::{AttributeChain, substitute_infer_in_path};
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Attribute, Type};
fn expand_attribute(
attr: &Attribute,
field_ty: &Type,
field_name: &str,
) -> syn::Result<TokenStream> {
let chain = attr.parse_args::<AttributeChain>()?;
let root = substitute_infer_in_path(chain.root_path(), field_ty);
let calls = chain.calls().iter().map(|call| {
let method = call.method();
let turbofish = call.turbofish();
let args = call.args();
quote! { .#method #turbofish (#(#args),*) }
});
let completion = chain
.completion_marker()
.map(|marker| quote! { .#marker })
.unwrap_or_default();
Ok(quote! {
#root::builder_for(#field_name) #(#calls)* #completion
})
}
The consumer owns builder_for, its arguments, and the final generated item.
Keeping construction outside the parser lets multiple macro crates share the
chain grammar while producing different domain-specific code.
Preserve completion typing
A complete chain has no marker, so its normal expansion ends after the final call. For trailing-dot input, the marker must follow the same constructor and calls so rust-analyzer sees the real receiver type at the cursor. Disable probe parsing when the expansion cannot maintain that invariant.
Report and test failures
Return parsing and semantic failures as syn::Error values. Use
syn::Error::new_spanned for consumer-owned checks so the diagnostic points to
the relevant root, method, or argument.
Test at least:
- a root-only chain;
- ordered calls with arguments and turbofish syntax;
- inferred and explicit subject types;
- rejected non-path roots; and
- trailing-dot input when completion probes are enabled.
The repository’s examples/derive_field_attrs.rs shows the same workflow in a
complete executable derive-style example.