Skip to content

Commit a11c4de

Browse files
authored
fix(macros): support #[doc = include_str!(...)] for macros (#444)
Add tests and return syn::Result from extract_doc_line to propagate parsing errors.
1 parent 4c8f5ba commit a11c4de

File tree

3 files changed

+88
-29
lines changed

3 files changed

+88
-29
lines changed

crates/rmcp-macros/src/common.rs

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,45 @@ pub fn none_expr() -> syn::Result<Expr> {
99
}
1010

1111
/// Extract documentation from doc attributes
12-
pub fn extract_doc_line(existing_docs: Option<String>, attr: &Attribute) -> Option<String> {
12+
pub fn extract_doc_line(
13+
existing_docs: Option<Expr>,
14+
attr: &Attribute,
15+
) -> syn::Result<Option<Expr>> {
1316
if !attr.path().is_ident("doc") {
14-
return None;
17+
return Ok(None);
1518
}
1619

1720
let syn::Meta::NameValue(name_value) = &attr.meta else {
18-
return None;
21+
return Ok(None);
1922
};
2023

21-
let syn::Expr::Lit(expr_lit) = &name_value.value else {
22-
return None;
23-
};
24-
25-
let syn::Lit::Str(lit_str) = &expr_lit.lit else {
26-
return None;
24+
let value = &name_value.value;
25+
let this_expr: Option<Expr> = match value {
26+
// Preserve macros such as `include_str!(...)`
27+
syn::Expr::Macro(_) => Some(value.clone()),
28+
syn::Expr::Lit(syn::ExprLit {
29+
lit: syn::Lit::Str(lit_str),
30+
..
31+
}) => {
32+
let content = lit_str.value().trim().to_string();
33+
if content.is_empty() {
34+
return Ok(existing_docs);
35+
}
36+
Some(Expr::Lit(syn::ExprLit {
37+
attrs: Vec::new(),
38+
lit: syn::Lit::Str(syn::LitStr::new(&content, lit_str.span())),
39+
}))
40+
}
41+
_ => return Ok(None),
2742
};
2843

29-
let content = lit_str.value().trim().to_string();
30-
match (existing_docs, content) {
31-
(Some(mut existing_docs), content) if !content.is_empty() => {
32-
existing_docs.push('\n');
33-
existing_docs.push_str(&content);
34-
Some(existing_docs)
44+
match (existing_docs, this_expr) {
45+
(Some(existing), Some(this)) => {
46+
syn::parse2::<Expr>(quote! { concat!(#existing, "\n", #this) }).map(Some)
3547
}
36-
(Some(existing_docs), _) => Some(existing_docs),
37-
(None, content) if !content.is_empty() => Some(content),
38-
_ => None,
48+
(Some(existing), None) => Ok(Some(existing)),
49+
(None, Some(this)) => Ok(Some(this)),
50+
_ => Ok(None),
3951
}
4052
}
4153

crates/rmcp-macros/src/prompt.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use darling::{FromMeta, ast::NestedMeta};
2-
use proc_macro2::TokenStream;
2+
use proc_macro2::{Span, TokenStream};
33
use quote::{format_ident, quote};
44
use syn::{Expr, Ident, ImplItemFn, ReturnType};
55

@@ -23,7 +23,7 @@ pub struct PromptAttribute {
2323
pub struct ResolvedPromptAttribute {
2424
pub name: String,
2525
pub title: Option<String>,
26-
pub description: Option<String>,
26+
pub description: Option<Expr>,
2727
pub arguments: Expr,
2828
pub icons: Option<Expr>,
2929
}
@@ -98,9 +98,14 @@ pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream>
9898
};
9999

100100
let name = attribute.name.unwrap_or_else(|| fn_ident.to_string());
101-
let description = attribute
102-
.description
103-
.or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line));
101+
let description = if let Some(s) = attribute.description {
102+
Some(Expr::Lit(syn::ExprLit {
103+
attrs: Vec::new(),
104+
lit: syn::Lit::Str(syn::LitStr::new(&s, Span::call_site())),
105+
}))
106+
} else {
107+
fn_item.attrs.iter().try_fold(None, extract_doc_line)?
108+
};
104109
let arguments = arguments_expr;
105110

106111
let resolved_prompt_attr = ResolvedPromptAttribute {
@@ -200,4 +205,22 @@ mod test {
200205

201206
Ok(())
202207
}
208+
209+
#[test]
210+
fn test_doc_include_description() -> syn::Result<()> {
211+
let attr = quote! {}; // No explicit description
212+
let input = quote! {
213+
#[doc = include_str!("some/test/data/doc.txt")]
214+
fn test_prompt_included(&self) -> Result<String> {
215+
Ok("Test".to_string())
216+
}
217+
};
218+
let result = prompt(attr, input)?;
219+
220+
// The generated tokens should preserve the include_str! invocation
221+
let result_str = result.to_string();
222+
assert!(result_str.contains("include_str"));
223+
224+
Ok(())
225+
}
203226
}

crates/rmcp-macros/src/tool.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use darling::{FromMeta, ast::NestedMeta};
2-
use proc_macro2::TokenStream;
2+
use proc_macro2::{Span, TokenStream};
33
use quote::{ToTokens, format_ident, quote};
4-
use syn::{Expr, Ident, ImplItemFn, ReturnType, parse_quote};
4+
use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote};
55

66
use crate::common::{extract_doc_line, none_expr};
77

@@ -82,7 +82,7 @@ pub struct ToolAttribute {
8282
pub struct ResolvedToolAttribute {
8383
pub name: String,
8484
pub title: Option<String>,
85-
pub description: Option<String>,
85+
pub description: Option<Expr>,
8686
pub input_schema: Expr,
8787
pub output_schema: Option<Expr>,
8888
pub annotations: Expr,
@@ -244,11 +244,17 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
244244
}
245245
});
246246

247+
let description_expr = if let Some(s) = attribute.description {
248+
Some(Expr::Lit(syn::ExprLit {
249+
attrs: Vec::new(),
250+
lit: syn::Lit::Str(LitStr::new(&s, Span::call_site())),
251+
}))
252+
} else {
253+
fn_item.attrs.iter().try_fold(None, extract_doc_line)?
254+
};
247255
let resolved_tool_attr = ResolvedToolAttribute {
248256
name: attribute.name.unwrap_or_else(|| fn_ident.to_string()),
249-
description: attribute
250-
.description
251-
.or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line)),
257+
description: description_expr,
252258
input_schema: input_schema_expr,
253259
output_schema: output_schema_expr,
254260
annotations: annotations_expr,
@@ -352,4 +358,22 @@ mod test {
352358
assert!(result_str.contains("Explicit description has priority"));
353359
Ok(())
354360
}
361+
362+
#[test]
363+
fn test_doc_include_description() -> syn::Result<()> {
364+
let attr = quote! {}; // No explicit description
365+
let input = quote! {
366+
#[doc = include_str!("some/test/data/doc.txt")]
367+
fn test_function(&self) -> Result<(), Error> {
368+
Ok(())
369+
}
370+
};
371+
let result = tool(attr, input)?;
372+
373+
// The macro should preserve include_str! in the generated tokens so we at least
374+
// see the include_str invocation in the generated function source.
375+
let result_str = result.to_string();
376+
assert!(result_str.contains("include_str"));
377+
Ok(())
378+
}
355379
}

0 commit comments

Comments
 (0)