protocol_droid_macros/lib.rs
1//! Procedural macros for the `protocol-droid` toolkit.
2//!
3//! This crate is separate from `protocol-droid` because a proc-macro crate is
4//! compiled for the host and consumed only by other crates, so it cannot be a
5//! module of the library that would use it.
6//!
7//! It intentionally carries no `domain`, `application` or `adapters` modules.
8//! The other crates in this workspace are built around a hexagonal layering,
9//! and that layering describes where business logic lives. This crate has no
10//! business logic: it is pure tooling that runs at compile time. Giving it four
11//! empty directories to satisfy a pattern would be cargo cult, and would hide
12//! the fact that the layout is chosen per crate rather than imposed blindly.
13//!
14//! # What the macros generate
15//!
16//! `#[tool]` on an `async fn` produces a `Tool` and a `ToolHandler` from the
17//! function's own signature: its parameters become the tool's `inputSchema`,
18//! and its return type becomes the answer. `#[resource]` and `#[prompt]` mark
19//! a plain `fn` whose return value is the primitive itself, and need no
20//! generated code beyond the registration that calls it.
21//!
22//! `#[server]` on an `impl` block collects every marked method below it into
23//! a `Server`.
24//!
25//! # What they deliberately do not generate
26//!
27//! The wire document. A generated handler decodes its arguments and returns
28//! through `application::server::result`, which owns the shape of a
29//! `tools/call` result. A macro that built that JSON itself would be free to
30//! disagree with the domain type and with the codec that lowers it, and
31//! nothing would notice until a client read the difference.
32//!
33//! # Why `#[server]` goes on an `impl` block
34//!
35//! An attribute macro only ever sees the item it is attached to, so a `#[server]`
36//! on a struct would not see the `impl` beside it, and every tool in that impl
37//! would be invisible to it. Putting it on the `impl` puts the macro where the
38//! methods are, which is the only place the tools can be read from.
39
40// `dead_code_pub_in_binary` fires on both proc macros in this crate's own *test*
41// build, and is wrong to. They are the entire public surface — consumed by
42// `protocol-droid` and by its tests — and a proc macro cannot be *called* from
43// inside the crate that defines it, so a test here has nothing to call. The
44// check sees a library exporting two functions nobody calls, because the
45// callers are in another crate and the lint does not cross the boundary.
46//
47// Gated on `test` because that is the only build it fires in; an ungated
48// expectation would be unfulfilled everywhere else, which is its own error.
49// Crate-level because that is where this particular lint has to be expected.
50#![cfg_attr(
51 test,
52 expect(
53 dead_code_pub_in_binary,
54 reason = "a proc macro's callers are in other crates"
55 )
56)]
57// A proc-macro crate can export only macros, so nothing in it is reachable from
58// outside. That puts the two lints this file has to satisfy in direct
59// conflict:
60//
61// * `unreachable_pub` — every `pub` item in a proc-macro crate is unreachable
62// by construction, because there is no way to name it.
63// * `clippy::redundant_pub_crate` — a `pub(crate)` item in a module that is
64// not `pub` is redundant, and its fix is to write `pub`.
65//
66// So `pub` is required and forbidden, and the only spelling left is to
67// acknowledge the contradiction. The crate-level `expect` below covers the
68// `unreachable_pub` half; this one covers the other.
69#[expect(
70 clippy::redundant_pub_crate,
71 reason = "a proc-macro crate exports no items, so 'pub' and 'pub(crate)' are both wrong and the lint that says so is right only by accident"
72)]
73pub(crate) mod args;
74#[expect(
75 clippy::redundant_pub_crate,
76 reason = "a proc-macro crate exports no items, so 'pub' and 'pub(crate)' are both wrong and the lint that says so is right only by accident"
77)]
78pub(crate) mod primitives;
79
80/// Whether `attr` is the named attribute, however it was spelled.
81///
82/// Matched on the **last** path segment, not the whole path, because the
83/// attribute is written `#[protocol_droid::tool]` in a user crate and `#[tool]`
84/// after the re-export — the same attribute, two paths, and only a whole-path
85/// match would see one of them. A macro that silently ignored the qualified
86/// spelling would leave every tool unmarked and produce an empty server rather
87/// than an error, which is the worst outcome available: the build succeeds and
88/// the tools are gone.
89pub(crate) fn is_attr(attr: &syn::Attribute, name: &str) -> bool {
90 attr.path()
91 .segments
92 .last()
93 .is_some_and(|segment| segment.ident == name)
94}
95
96use proc_macro::TokenStream;
97use quote::quote;
98use syn::{ItemFn, ItemImpl, parse_macro_input};
99
100/// Marks an `async fn` as a tool.
101///
102/// This attribute generates nothing on its own. `#[server]` reads the mark,
103/// generates the tool's schema, the argument struct and the handler, and
104/// registers all three — and it does so at module scope, which is the only
105/// scope where generated items may be declared.
106///
107/// That split is forced, not chosen. A `#[tool]` on a method expands *inside*
108/// the `impl` block, where Rust forbids declaring a struct or a function, and
109/// where even a `const _` collides with every other `const _` in the same
110/// `impl`. There is no way to emit a schema or an argument struct from a
111/// method's own attribute, so a macro that tried would be generating code that
112/// cannot compile. A marker that generates nothing is honest about that; a
113/// marker that generated something would only generate something for free
114/// functions, and the difference would be a rule nobody could see.
115///
116/// So a `#[tool]` outside a `#[server]` block is a mark on nothing. `#[server]`
117/// is the only place a tool is built, and that is what makes the generated
118/// names (`__{Type}_{fn}_TOOL`, `__{Type}_{fn}_HANDLER`, `__{Type}_{fn}_Args`)
119/// predictable: there is exactly one place that mints them, and every one of
120/// them carries the type, so two servers in one module can each have an `add`.
121///
122/// # Examples
123///
124/// A worked example lives in
125/// `crates/protocol-droid/tests/macros.rs`, where every macro
126/// shape is exercised against real types and the listings are
127/// read back through the dispatcher. A doctest here would be a
128/// strict subset of that coverage and would also require this
129/// crate to depend on `protocol-droid` and on `serde` for
130/// compilation, which is the cycle the docstring in
131/// `Cargo.toml` says it does not create.
132// `dead_code_pub_in_binary` fires on these in this crate's own *test* build,
133// and is wrong to: they are the entire public surface, consumed by
134// `protocol-droid` and by its tests, and a proc macro cannot be *called* from
135// inside the crate that defines it. The check sees a library exporting
136// functions nobody calls, because the callers are in another crate and the
137// lint does not cross the boundary.
138//
139// Gated on `test` because that is the only build it fires in, and an ungated
140// `expect` would then be unfulfilled everywhere else — which is its own error.
141#[proc_macro_attribute]
142pub fn tool(_attribute: TokenStream, item: TokenStream) -> TokenStream {
143 // Checked so a malformed function is reported against this attribute. The
144 // error is emitted rather than dropped: when the method sits under a
145 // `#[server]`, the `#[server]` expansion has already replaced the whole
146 // `impl` with a `compile_error!` and this never runs, so the author sees
147 // one error rather than two. When it does not — a mark on an orphan method
148 // — this is the only pass that will ever look at the function, and a
149 // silently dropped parse error would leave `syn`'s "expected `fn`" with
150 // nothing pointing at the author.
151 parse_or_report(item)
152}
153
154/// Marks a plain `fn` returning a `Resource` as a resource the
155/// server lists.
156///
157/// This attribute generates nothing on its own, for the same reason
158/// [`tool`] generates nothing: it expands inside the `impl` block, where
159/// Rust forbids declaring a struct or a function. [`server`] reads the mark
160/// and emits the registration.
161///
162/// # The shape
163///
164/// ```text
165/// fn name() -> Resource
166/// ```
167///
168/// A plain `fn`, not an `async fn`. A resource is registered once, while the
169/// server is being built, so there is nothing to await — and an `async fn`
170/// would hand `into_server` a future where a `Resource` belongs. The return
171/// type is read by its last path segment, so `Resource` and
172/// `crate::domain::registry::Resource` are equally good.
173///
174/// # What the method body is for
175///
176/// The whole `Resource`, including its uri, description, mime type and size.
177/// That is deliberate: the body is ordinary Rust, so anything computable
178/// there — a config value, a constant, a `match` — is available.
179///
180/// # What is *not* checked, and why
181///
182/// **Two methods in one `#[server]` block producing the same uri is not
183/// caught at expansion time.** The uri lives in the method body, which is
184/// arbitrary code, so `syn` cannot read it — a check that saw the body would
185/// have to be a check that ran it. Two claims for one key are therefore
186/// decided by the registry at build time, not by the macro. The detection
187/// would need the uri as a `#[resource(uri = "...")]` literal, and that would
188/// put the key in two places at once: the attribute and the body, with
189/// nothing to say they agree. A duplicate-uri check that covers only the
190/// attribute-literal spelling and reads as a general one is worse than none,
191/// because it is confidently green over the case it cannot see.
192///
193/// A `#[resource]` outside a `#[server]` block is also unchecked, and cannot
194/// be: an attribute on a method is expanded without any knowledge of the
195/// `impl` around it, so it has no way to ask whether anything is reading it.
196///
197/// # Examples
198///
199/// A worked example lives in
200/// `crates/protocol-droid/tests/macros.rs`, where every
201/// macro shape is exercised against the real `Resource` and
202/// `Prompt` types and the listings are read back through the
203/// dispatcher. A doctest here would be a strict subset of that
204/// coverage and would also require this crate to depend on
205/// `protocol-droid` for compilation, which is the cycle the
206/// docstring in `Cargo.toml` says it does not create.
207#[proc_macro_attribute]
208pub fn resource(_attribute: TokenStream, item: TokenStream) -> TokenStream {
209 parse_or_report(item)
210}
211
212/// Marks a plain `fn` returning a `Prompt` as a prompt the
213/// server offers.
214///
215/// This attribute generates nothing on its own, for the same reason
216/// [`tool`] generates nothing: it expands inside the `impl` block, where
217/// Rust forbids declaring a struct or a function. [`server`] reads the mark
218/// and emits the registration.
219///
220/// # The shape
221///
222/// ```text
223/// fn name() -> Prompt
224/// ```
225///
226/// A plain `fn`, for the same reason a [`resource`] is one: a prompt is
227/// registered once, while the server is being built, so there is nothing to
228/// await.
229///
230/// # Arguments
231///
232/// Declared in the body through `Prompt::with_argument`, not
233/// through the attribute. A prompt's arguments are an ordered
234/// list of named, required-or-not entries, and a comma-separated
235/// attribute list cannot express "required" without a second
236/// parallel spelling of every argument's name.
237///
238/// # Examples
239///
240/// A worked example lives in
241/// `crates/protocol-droid/tests/macros.rs`, where every macro
242/// shape is exercised against the real `Resource` and `Prompt`
243/// types and the listings are read back through the dispatcher.
244/// A doctest here would be a strict subset of that coverage
245/// and would also require this crate to depend on
246/// `protocol-droid` for compilation, which is the cycle the
247/// docstring in `Cargo.toml` says it does not create.
248#[proc_macro_attribute]
249pub fn prompt(_attribute: TokenStream, item: TokenStream) -> TokenStream {
250 parse_or_report(item)
251}
252
253/// Parses a marked function, reporting rather than discarding a failure.
254///
255/// The marked function is handed back untouched either way: a mark that
256/// changed the item would be a second implementation of whatever it marks,
257/// and the one that has to stay in step with `#[server]` is the other one.
258fn parse_or_report(item: TokenStream) -> TokenStream {
259 match syn::parse::<ItemFn>(item.clone()) {
260 Ok(_) => item,
261 Err(error) => error.to_compile_error().into(),
262 }
263}
264
265/// Collects every marked method in an `impl` block into a `Server`.
266///
267/// Reads three marks: [`tool`], [`resource`] and [`prompt`]. Each is a
268/// different amount of work, and the difference is the reason this macro
269/// exists rather than three separate ones — the registration statements have
270/// to run in one `into_server`, in source order, against one builder.
271///
272/// The `impl` is left exactly as written and a second inherent block is added
273/// beside it, carrying one method:
274///
275/// ```text
276/// fn into_server(self) -> Result<Server, JsonObjectError>
277/// ```
278///
279/// The method consumes `self`, so the name reads as what it is: the type you
280/// wrote becomes a server. It returns a `Result` because a tool's `inputSchema`
281/// is a `serde_json` schema and a type with no schema — a map with a non-string
282/// key, say — is a mistake worth naming rather than a panic.
283///
284/// The server's name is the type's own, or `name = "..."` when the name a peer
285/// sees should differ from the Rust type name, which is usually the case.
286///
287/// # What is checked, and what is left to the registry
288///
289/// Signatures. A `#[resource]` that is `async`, takes a receiver, or returns
290/// something other than a `Resource` is a compile error naming the fix, and
291/// the same for `#[prompt]` and `Prompt`.
292///
293/// Identities are not. Two `#[resource]` methods producing the same uri is
294/// decided by the registry when `into_server` runs, because the uri is built
295/// in a method body and a macro cannot read it without running the body. See
296/// [`resource`] for why a partial check is worse than none here.
297///
298/// # Examples
299///
300/// A worked example lives in
301/// `crates/protocol-droid/tests/macros.rs`, where every macro
302/// shape is exercised against real types and the listings are
303/// read back through the dispatcher. A doctest here would be a
304/// strict subset of that coverage and would also require this
305/// crate to depend on `protocol-droid` for compilation, which
306/// is the cycle the docstring in `Cargo.toml` says it does not
307/// create.
308// `dead_code_pub_in_binary` fires on both of these in this crate's own *test*
309// build, and is wrong to: they are the entire public surface, consumed by
310// `protocol-droid` and by its tests, and a proc macro cannot be *called* from
311// inside the crate that defines it. The check sees a library exporting two
312// functions nobody calls, because the callers are in another crate and the
313// lint does not cross the boundary.
314//
315// Gated on `test` because that is the only build it fires in, and an ungated
316// `expect` would then be unfulfilled everywhere else — which is its own error.
317#[proc_macro_attribute]
318pub fn server(attribute: TokenStream, item: TokenStream) -> TokenStream {
319 let block = parse_macro_input!(item as ItemImpl);
320 let reference = █
321 match expand_server(attribute.into(), reference) {
322 Ok(tokens) => tokens.into(),
323 Err(error) => error.to_compile_error().into(),
324 }
325}
326
327/// Expands `#[server]`.
328///
329/// # Errors
330///
331/// An `impl` with no self type, which has nothing to add a method to; and a
332/// bare `impl` block, for the same reason.
333fn expand_server(
334 attribute: proc_macro2::TokenStream,
335 block: &ItemImpl,
336) -> syn::Result<proc_macro2::TokenStream> {
337 let self_ty = &block.self_ty;
338 let fields = args::literal_fields(attribute)?;
339 let server_name = args::field(&fields, "name");
340
341 // One registration per marked method, with everything it needs generated
342 // beside it. The `const _` blocks are at module scope here — an attribute
343 // on the `impl` is — so the names inside them cannot collide.
344 let mut generated = Vec::new();
345 let mut registrations = Vec::new();
346
347 // A generated name is the type's own last path segment plus the method's,
348 // so `Demo::add` and `Other::add` cannot collide. The type is in the name
349 // because two types with a method of the same name is ordinary Rust, and
350 // two generated `__add_TOOL`s at module scope is an error a user cannot
351 // act on.
352 let type_prefix = match self_ty.as_ref() {
353 syn::Type::Path(path) => path
354 .path
355 .segments
356 .last()
357 .map_or_else(String::new, |segment| segment.ident.to_string()),
358 _ => String::new(),
359 };
360
361 for method in &block.items {
362 let syn::ImplItem::Fn(function) = method else {
363 continue;
364 };
365
366 // A `#[resource]` or `#[prompt]` is one registration statement and
367 // nothing else, so it is handled and skipped rather than falling
368 // through to the tool path below. Falling through would be the
369 // interesting bug: a resource would be registered twice, and never
370 // derive a schema.
371 if let Some(mark) = primitives::Mark::of(function) {
372 mark.check(function)?;
373 let reader = mark.reader_of(function)?;
374 let reader_fn = reader.as_ref().and_then(|wanted| {
375 block.items.iter().find_map(|item| {
376 let syn::ImplItem::Fn(candidate) = item else {
377 return None;
378 };
379 (candidate.sig.ident == *wanted).then_some(candidate)
380 })
381 });
382 registrations.push(mark.registration(function, reader.as_ref(), reader_fn)?);
383 continue;
384 }
385
386 if !function.attrs.iter().any(|attr| is_attr(attr, "tool")) {
387 continue;
388 }
389
390 // The method's own `#[tool(name = .., description = ..)]` arguments.
391 // Reading them is not optional: a `name = "renamed"` the macro dropped
392 // would leave the Rust method name on the wire, and the tool would be
393 // published under a name the author explicitly said not to use.
394 let marker = function
395 .attrs
396 .iter()
397 .find(|attr| is_attr(attr, "tool"))
398 .map_or_else(proc_macro2::TokenStream::new, |attr| {
399 attr.parse_args()
400 .unwrap_or_else(|_| proc_macro2::TokenStream::new())
401 });
402 let mut handler = args::read_handler(&function.attrs, marker, &function.sig)?;
403 handler.prefix.clone_from(&type_prefix);
404 let tool_fn = args::tool_fn(&handler);
405 let handler_fn = args::handler_factory(&handler);
406 let args_struct = args::args_struct(&handler);
407 let method_name = &function.sig.ident;
408 let factory = args::tool_fn_ident(&handler);
409 let handler_factory_ident = args::handler_fn_ident(&handler);
410
411 generated.push(quote! {
412 #args_struct
413 #tool_fn
414 #handler_fn
415 });
416 registrations.push(quote! {
417 builder = builder.tool(#factory()?, #handler_factory_ident(Self::#method_name));
418 });
419 }
420
421 // With no `name`, the type's own name — which is what a reader of the
422 // generated code would expect, and what `type_name` gives without the
423 // macro having to carry a copy of the identifier.
424 let name_expression = server_name.as_ref().map_or_else(
425 || quote! { ::std::string::ToString::to_string(::std::any::type_name::<#self_ty>()) },
426 |name| quote! { #name.to_string() },
427 );
428
429 let __out = quote! {
430 #block
431 #(#generated)*
432
433 #[allow(
434 missing_docs,
435 reason = "the doc comment above is the one that should be read",
436 )]
437 impl #self_ty {
438 /// Builds a server from every marked method in this type.
439 ///
440 /// A `#[tool]` contributes its schema and its handler; a
441 /// `#[resource]` and a `#[prompt]` contribute the value their
442 /// method returns. All three are registered here, in the order
443 /// they were written.
444 ///
445 /// # Errors
446 ///
447 /// [`JsonObjectError`](::protocol_droid::domain::ir::JsonObjectError)
448 /// when one of the tools has no serialisable `inputSchema`. That is a
449 /// mistake in a tool's argument type, and naming it is more use than
450 /// panicking over it.
451 pub fn into_server(
452 self,
453 ) -> ::core::result::Result<
454 ::protocol_droid::application::server::Server,
455 ::protocol_droid::domain::ir::JsonObjectError,
456 > {
457 #[allow(unused_mut, reason = "nothing marked means nothing to build")]
458 let mut builder =
459 ::protocol_droid::application::server::Server::builder(#name_expression);
460 #(#registrations)*
461 Ok(builder.build())
462 }
463 }
464 };
465 Ok(__out)
466}
467
468#[cfg(test)]
469mod scratch {
470 use super::expand_server;
471 use quote::quote;
472 use syn::ItemImpl;
473
474 #[test]
475 fn debug() {
476 let source = r"
477 impl Docs {
478 /// The project's readme.
479 #[resource]
480 fn readme() -> Resource { todo!() }
481 }
482 ";
483 let Ok(block) = syn::parse_str::<ItemImpl>(source) else {
484 eprintln!("PARSE ERR: {:?}", syn::parse_str::<ItemImpl>(source).err());
485 return;
486 };
487 match expand_server(quote! { name = "docs" }, &block) {
488 Ok(tokens) => eprintln!("OK: {tokens}"),
489 Err(error) => eprintln!("ERR: {error}"),
490 }
491 }
492}