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}