vector_config_macros/ast/util.rs
1use darling::{ast::NestedMeta, error::Accumulator};
2use quote::{quote, ToTokens};
3use serde_derive_internals::{attr as serde_attr, Ctxt};
4use syn::{
5 punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Expr, ExprLit, ExprPath,
6 Lit, Meta, MetaNameValue,
7};
8
9const ERR_FIELD_MISSING_DESCRIPTION: &str = "field must have a description -- i.e. `/// This is a widget...` or `#[configurable(description = \"...\")] -- or derive it from the underlying type of the field by specifying `#[configurable(derived)]`";
10const ERR_FIELD_IMPLICIT_TRANSPARENT: &str =
11 "field in a newtype wrapper should not be manually marked as `derived`/`transparent`";
12
13pub fn try_extract_doc_title_description(
14 attributes: &[Attribute],
15) -> (Option<String>, Option<String>) {
16 // Scrape all the attributes that have the `doc` path, which will be used for holding doc
17 // comments that we're interested in utilizing, and extract their value.
18 let doc_comments = attributes
19 .iter()
20 // We only care about `doc` attributes.
21 .filter(|attribute| attribute.path().is_ident("doc"))
22 // Extract the value of the attribute if it's in the form of `doc = "..."`.
23 .filter_map(|attribute| match &attribute.meta {
24 Meta::NameValue(MetaNameValue {
25 value:
26 Expr::Lit(ExprLit {
27 lit: Lit::Str(s), ..
28 }),
29 ..
30 }) => Some(s.value()),
31 _ => None,
32 })
33 .collect::<Vec<_>>();
34
35 // If there were no doc comments, then we have no title/description to try and extract.
36 if doc_comments.is_empty() {
37 return (None, None);
38 }
39
40 // We emulate what `cargo doc` does, which is that if you have a doc comment with a bunch of
41 // text, then an empty line, and then more text, it considers the first chunk the title, and
42 // the second chunk the description.
43 //
44 // If there's no empty line, then we just consider it all the description.
45 //
46 // The grouping logic of `group_doc_lines` lets us determine which scenario we're dealing with
47 // based on the number of grouped lines.
48 let mut grouped_lines = group_doc_lines(&doc_comments);
49 match grouped_lines.len() {
50 // No title or description.
51 0 => (None, None),
52 // Just a single grouped line/paragraph, so we emit that as the description.
53 1 => (None, none_if_empty(grouped_lines.remove(0))),
54 // Two or more grouped lines/paragraphs, so the first one is the title, and the rest are the
55 // description, which we concatenate together with newlines, since the description at least
56 // needs to be a single string.
57 _ => {
58 let title = grouped_lines.remove(0);
59 let description = grouped_lines.join("\n\n");
60
61 (none_if_empty(title), none_if_empty(description))
62 }
63 }
64}
65
66fn group_doc_lines(ungrouped: &[String]) -> Vec<String> {
67 // When we write a doc comment in Rust, it typically ends up looking something like this:
68 //
69 // /// A helper for XYZ.
70 // ///
71 // /// This helper works in the following way, and so on and so forth.
72 // ///
73 // /// This separate paragraph explains a different, but related, aspect
74 // /// of the helper.
75 //
76 // To humans, this format is natural and we see it and read it as three paragraphs. Once those
77 // doc comments are processed and we get them in a procedural macro, they look like this,
78 // though:
79 //
80 // #[doc = " A helper for XYZ."]
81 // #[doc = ""]
82 // #[doc = " This helper works in the following way, and so on and so forth."]
83 // #[doc = ""]
84 // #[doc = " This separate paragraph explains a different, but related, aspect"]
85 // #[doc = " of the helper."]
86 //
87 // What we want to do is actually parse this as three paragraphs, with the individual lines of
88 // each paragraph merged together as a single string, and extraneous whitespace removed, such
89 // that we should end up with a vector of strings that looks like:
90 //
91 // - "A helper for XYZ."
92 // - "This helper works in the following way, and so on and so forth."
93 // - "This separate paragraph explains a different, but related, aspect\n of the helper."
94
95 // TODO: Markdown link reference definitions (LFDs) -- e.g. `[foo]: https://zombohtml5.com` --
96 // have to be on their own line, which is a little annoying because ideally we want to remove
97 // the newlines between lines that simply get line wrapped, such that in the above example.
98 // While that extra newline towards the end of the third line/paragraph is extraneous, because
99 // it represents a forced line break which is imposing some measure of stylistic license, we
100 // _do_ need line breaks to stay in place so that LFDs stay on their own line, otherwise it
101 // seems like Markdown parsers will treat them as free-form text.
102 //
103 // I'm not sure if we'll want to go as far as trying to parse each line specifically as an LFD,
104 // for the purpose of controlling how we add/remove linebreaks... but it's something we'll
105 // likely want/need to eventually figure out.
106
107 let mut buffer = String::new();
108 let mut grouped = ungrouped.iter().fold(Vec::new(), |mut grouped, line| {
109 match line.as_str() {
110 // Full line breaks -- i.e. `#[doc = ""]` -- will be empty strings, which is our
111 // signal to consume our buffer and emit it as a grouped line/paragraph.
112 "" => {
113 if !buffer.is_empty() {
114 let trimmed = buffer.trim().to_string();
115 grouped.push(trimmed);
116
117 buffer.clear();
118 }
119 }
120 // The line actually has some content, so just append it to our string buffer after
121 // dropping the leading space, if one exists.
122 s => {
123 buffer.push_str(s.strip_prefix(' ').unwrap_or(s));
124 buffer.push('\n');
125 }
126 };
127
128 grouped
129 });
130
131 // If we have anything left in the buffer, consume it as a grouped line/paragraph.
132 if !buffer.is_empty() {
133 let trimmed = buffer.trim().to_string();
134 grouped.push(trimmed);
135 }
136
137 grouped
138}
139
140fn none_if_empty(s: String) -> Option<String> {
141 if s.is_empty() {
142 None
143 } else {
144 Some(s)
145 }
146}
147
148pub fn err_field_missing_description<T: Spanned>(field: &T) -> darling::Error {
149 darling::Error::custom(ERR_FIELD_MISSING_DESCRIPTION).with_span(field)
150}
151
152pub fn err_field_implicit_transparent<T: Spanned>(field: &T) -> darling::Error {
153 darling::Error::custom(ERR_FIELD_IMPLICIT_TRANSPARENT).with_span(field)
154}
155
156pub fn get_serde_default_value<S: ToTokens>(
157 source: &S,
158 default: &serde_attr::Default,
159) -> Option<ExprPath> {
160 match default {
161 serde_attr::Default::None => None,
162 serde_attr::Default::Default => {
163 let qualified_path = syn::parse2(quote! {
164 <#source as ::std::default::Default>::default
165 })
166 .expect("should not fail to parse qualified default path");
167 Some(qualified_path)
168 }
169 serde_attr::Default::Path(path) => Some(path.clone()),
170 }
171}
172
173pub fn err_serde_failed(context: Ctxt) -> darling::Error {
174 context
175 .check()
176 .map_err(|errs| darling::Error::multiple(errs.into_iter().map(Into::into).collect()))
177 .expect_err("serde error context should not be empty")
178}
179
180pub trait DarlingResultIterator<I> {
181 fn collect_darling_results(self, accumulator: &mut Accumulator) -> Vec<I>;
182}
183
184impl<I, T> DarlingResultIterator<I> for T
185where
186 T: Iterator<Item = Result<I, darling::Error>>,
187{
188 fn collect_darling_results(self, accumulator: &mut Accumulator) -> Vec<I> {
189 self.filter_map(|result| accumulator.handle(result))
190 .collect()
191 }
192}
193
194/// Checks if the path matches `other`.
195///
196/// If a valid ident can be constructed from the path, and the ident's value matches `other`,
197/// `true` is returned. Otherwise, `false` is returned.
198fn path_matches<S: AsRef<str>>(path: &syn::Path, other: S) -> bool {
199 path.get_ident().filter(|i| *i == &other).is_some()
200}
201
202/// Tries to find a specific attribute with a specific name/value pair.
203///
204/// Only works with derive macro helper attributes, and not raw name/value attributes such as
205/// `#[path = "..."]`.
206///
207/// If an attribute with a path matching `attr_name`, and a meta name/value pair with a name
208/// matching `name_key` is found, `Some(path)` is returned, representing the value of the name/value pair.
209///
210/// If no attribute matches, or if the given name/value pair is not found, `None` is returned.
211fn find_name_value_attribute(
212 attributes: &[syn::Attribute],
213 attr_name: &str,
214 name_key: &str,
215) -> Option<Lit> {
216 attributes
217 .iter()
218 // Only take attributes whose name matches `attr_name`.
219 .filter(|attr| path_matches(attr.path(), attr_name))
220 // Derive macro helper attributes will always be in the list form.
221 .filter_map(|attr| match &attr.meta {
222 Meta::List(ml) => ml
223 .parse_args_with(Punctuated::<NestedMeta, Comma>::parse_terminated)
224 .map(|nested| nested.into_iter())
225 .ok(),
226 _ => None,
227 })
228 .flatten()
229 // For each nested meta item in the list, find any that are name/value pairs where the
230 // name matches `name_key`, and return their value.
231 .find_map(|nm| match nm {
232 NestedMeta::Meta(meta) => match meta {
233 Meta::NameValue(nv) if path_matches(&nv.path, name_key) => match nv.value {
234 Expr::Lit(ExprLit { lit, .. }) => Some(lit),
235 _ => None,
236 },
237 _ => None,
238 },
239 _ => None,
240 })
241}
242
243/// Tries to find a delegated (de)serialization type from attributes.
244///
245/// In some cases, the `serde_with` crate, more specifically the `serde_as` attribute macro, may be
246/// used to help (de)serialize a field/container with type A via a (de)implementation on type B, in order to
247/// provide more ergonomic (de)serialization of values that can represent type A without needing to
248/// explicitly match type A when (de)serialized. This is similar to `serde`'s existing support for
249/// "remote" types but is taken further with a more generic and extensible approach.
250///
251/// This, however, presents an issue because while normally we can handle scenarios like
252/// `#[serde(from = "...")]` and its siblings, `serde_as` depends on `#[serde(with = "...")]` and
253/// the fact that it simply constructs a path to the (de)serialize methods, rather than always
254/// needing to explicitly reference a type. This means that we cannot simply grab the value of the
255/// `with` name/value pair blindly, and assume if there's a value that a delegated/remote type is in
256/// play... it could be a module path, too.
257///
258/// This method looks for two indicators to understand when it should be able to extract the
259/// delegated type:
260///
261/// - `#[serde(with = "...")]` is present
262/// - `#[serde_as(as = "...")]` is present
263///
264/// When both of these are true, we can rely on the fact that the value of `with` will be a valid
265/// type path, and usable like a virtual newtype, which is where we use the type specified for
266/// `try_from`/`from`/`into` for the delegated (de)serialization type of a container itself.
267///
268/// If we find both of those attribute name/value pairs, and the value of `with` can be parsed
269/// successfully as a type path, `Some(...)` is returned, contained the type. Otherwise, `None` is
270/// returned.
271pub fn find_delegated_serde_deser_ty(attributes: &[syn::Attribute]) -> Option<syn::Type> {
272 // Make sure `#[serde_as(as = "...")]` is present.
273 find_name_value_attribute(attributes, "serde_as", "r#as")
274 // Make sure `#[serde(with = "...")]` is present, and grab its value.
275 .and_then(|_| find_name_value_attribute(attributes, "serde", "with"))
276 // Try and parse the value as a type path.
277 .and_then(|with| match with {
278 Lit::Str(s) => s.parse::<syn::Type>().ok(),
279 _ => None,
280 })
281}