opendp_tooling/bootstrap/
docstring.rs

1use std::{collections::HashMap, env, path::PathBuf};
2
3use darling::{Error, FromMeta, Result};
4use proc_macro2::{Literal, Punct, Spacing, TokenStream, TokenTree};
5use quote::format_ident;
6use syn::{
7    AttrStyle, Attribute, AttributeArgs, ItemFn, Lit, Meta, MetaNameValue, Path, PathSegment,
8    ReturnType, Type, TypePath,
9};
10
11use crate::{
12    proven::filesystem::{get_src_dir, make_proof_link},
13    Deprecation,
14};
15
16use super::arguments::BootstrapArguments;
17
18#[derive(Debug, Default)]
19pub struct BootstrapDocstring {
20    pub description: Option<String>,
21    pub arguments: HashMap<String, String>,
22    pub generics: HashMap<String, String>,
23    pub returns: Option<String>,
24    pub deprecated: Option<Deprecation>,
25}
26
27#[derive(Debug, FromMeta, Clone)]
28pub struct DeprecationArguments {
29    pub since: Option<String>,
30    pub note: Option<String>,
31}
32
33impl BootstrapDocstring {
34    pub fn from_attrs(
35        name: &String,
36        attrs: Vec<Attribute>,
37        output: &ReturnType,
38        path: Option<(&str, &str)>,
39        features: Vec<String>,
40    ) -> Result<BootstrapDocstring> {
41        // look for this attr:
42        // #[deprecated(note="please use `new_method` instead")]
43        let deprecated = attrs
44            .iter()
45            .find(|attr| {
46                attr.path.get_ident().map(ToString::to_string).as_deref() == Some("deprecated")
47            })
48            .map(|attr| {
49                let meta = DeprecationArguments::from_meta(&attr.parse_meta()?)?;
50                Result::Ok(Deprecation {
51                    since: meta.since.ok_or_else(|| {
52                        Error::custom("`since` must be specified").with_span(&attr)
53                    })?,
54                    note: meta.note.ok_or_else(|| {
55                        Error::custom("`note` must be specified").with_span(&attr)
56                    })?,
57                })
58            })
59            .transpose()?;
60
61        let mut doc_sections = parse_docstring_sections(attrs)?;
62
63        const HONEST_SECTION: &str = "Why honest-but-curious?";
64        const HONEST_FEATURE: &str = "honest-but-curious";
65        let has_honest_section = doc_sections.keys().any(|key| key == HONEST_SECTION);
66        let has_honest_feature = features
67            .clone()
68            .into_iter()
69            .any(|feature| feature == HONEST_FEATURE);
70        if has_honest_feature && !has_honest_section {
71            let msg = format!(
72                "{name} requires \"{HONEST_FEATURE}\" but is missing \"{HONEST_SECTION}\" section"
73            );
74            return Err(Error::custom(msg));
75        }
76        if has_honest_section && !has_honest_feature {
77            let msg = format!(
78                "{name} has \"{HONEST_SECTION}\" section but is missing \"{HONEST_FEATURE}\" feature"
79            );
80            return Err(Error::custom(msg));
81        }
82
83        if let Some(sup_elements) = parse_sig_output(output)? {
84            doc_sections.insert("Supporting Elements".to_string(), sup_elements);
85        }
86
87        let mut description = Vec::from_iter(doc_sections.remove("Description"));
88
89        if !features.is_empty() {
90            let features_list = features
91                .into_iter()
92                .map(|f| format!("`{f}`"))
93                .collect::<Vec<_>>()
94                .join(", ");
95            description.push(format!("\n\nRequired features: {features_list}"));
96        }
97
98        // add a link to rust documentation (with a gap line)
99        if let Some((module, name)) = &path {
100            description.push(String::new());
101            description.push(make_rustdoc_link(module, name)?)
102        }
103
104        let mut add_section_to_description = |section_name: &str| {
105            doc_sections.remove(section_name).map(|section| {
106                description.push(format!("\n**{section_name}:**\n"));
107                description.push(section)
108            })
109        };
110        // can add more sections here...
111        add_section_to_description(HONEST_SECTION);
112        add_section_to_description("Citations");
113        add_section_to_description("Supporting Elements");
114        add_section_to_description("Proof Definition");
115
116        Ok(BootstrapDocstring {
117            description: if description.is_empty() {
118                None
119            } else {
120                Some(description.join("\n").trim().to_string())
121            },
122            arguments: doc_sections
123                .remove("Arguments")
124                .map(parse_docstring_args)
125                .unwrap_or_else(HashMap::new),
126            generics: doc_sections
127                .remove("Generics")
128                .map(parse_docstring_args)
129                .unwrap_or_else(HashMap::new),
130            returns: doc_sections.remove("Returns"),
131            deprecated,
132        })
133    }
134}
135
136/// Parses a section that is delimited by bullets into a hashmap.
137///
138/// The keys are the arg names and values are the descriptions.
139///
140/// # Example
141///
142/// ```text
143/// # Arguments
144/// * `a` - a description for argument a
145/// * `b` - a description for argument b
146///         ...multiple lines of description
147/// * `c` - a description for argument c
148/// ```
149///
150fn parse_docstring_args(args: String) -> HashMap<String, String> {
151    // split by newlines
152    let mut args = args
153        .split("\n")
154        .map(ToString::to_string)
155        .collect::<Vec<_>>();
156
157    // add a trailing delimiter so that we can use .windows
158    args.push("* `".to_string());
159
160    // find the row indexes where each argument starts
161    (args.iter().enumerate())
162        .filter_map(|(i, v)| v.starts_with("* `").then(|| i))
163        .collect::<Vec<usize>>()
164        // each window corresponds to the documentation for one argument
165        .windows(2)
166        .map(|window| {
167            // split the variable name from the first line
168            let mut splitter = args[window[0]].splitn(2, " - ").map(str::to_string);
169            let name = splitter.next().unwrap();
170            let name = name[3..name.len() - 1].to_string();
171
172            // retrieve the rest of the first line, as well as any other lines, trim all, and join them together with newlines
173            let description = vec![splitter.next().unwrap_or_else(String::new)]
174                .into_iter()
175                .chain(
176                    args[window[0] + 1..window[1]]
177                        .iter()
178                        .map(|v| v.trim().to_string()),
179                )
180                .collect::<Vec<String>>()
181                .join("\n")
182                .trim()
183                .to_string();
184            (name, description)
185        })
186        .collect::<HashMap<String, String>>()
187}
188
189/// Break a vector of syn Attributes into a hashmap.
190///
191/// Keys represent section names, and values are the text under the section
192fn parse_docstring_sections(attrs: Vec<Attribute>) -> Result<HashMap<String, String>> {
193    let mut docstrings = (attrs.into_iter())
194        .filter(|v| v.path.get_ident().map(ToString::to_string).as_deref() == Some("doc"))
195        .map(parse_doc_attribute)
196        .collect::<Result<Vec<_>>>()?
197        .into_iter()
198        .filter_map(|v| {
199            if v.is_empty() {
200                Some(String::new())
201            } else {
202                v.starts_with(" ").then(|| v[1..].to_string())
203            }
204        })
205        .collect::<Vec<String>>();
206
207    // wrap in headers to prepare for parsing
208    docstrings.insert(0, "# Description".to_string());
209    docstrings.push("# End".to_string());
210
211    Ok(docstrings
212        .iter()
213        .enumerate()
214        .filter_map(|(i, v)| v.starts_with("# ").then(|| i))
215        .collect::<Vec<usize>>()
216        .windows(2)
217        .map(|window| {
218            (
219                docstrings[window[0]]
220                    .strip_prefix("# ")
221                    .expect("won't panic (because of filter)")
222                    .to_string(),
223                docstrings[window[0] + 1..window[1]]
224                    .to_vec()
225                    .join("\n")
226                    .trim()
227                    .to_string(),
228            )
229        })
230        .collect())
231}
232
233/// Parses the return type into a markdown-formatted summary
234fn parse_sig_output(output: &ReturnType) -> Result<Option<String>> {
235    match output {
236        ReturnType::Default => Ok(None),
237        ReturnType::Type(_, ty) => parse_supporting_elements(&*ty),
238    }
239}
240
241fn parse_supporting_elements(ty: &Type) -> Result<Option<String>> {
242    let PathSegment { ident, arguments } = match &ty {
243        syn::Type::Path(TypePath {
244            path: Path { segments, .. },
245            ..
246        }) => segments.last().ok_or_else(|| {
247            Error::custom("return type cannot be an empty path").with_span(&segments)
248        })?,
249        _ => return Ok(None),
250    };
251
252    match ident {
253        i if i == "Fallible" => parse_supporting_elements(match arguments {
254            syn::PathArguments::AngleBracketed(ab) => {
255                if ab.args.len() != 1 {
256                    return Err(Error::custom("Fallible needs one angle-bracketed argument")
257                        .with_span(&ab.args));
258                }
259                match ab.args.first().expect("unreachable due to if statement") {
260                    syn::GenericArgument::Type(ty) => ty,
261                    arg => {
262                        return Err(
263                            Error::custom("argument to Fallible must to be a type").with_span(&arg)
264                        )
265                    }
266                }
267            }
268            arg => {
269                return Err(
270                    Error::custom("Fallible needs an angle-bracketed argument").with_span(arg)
271                )
272            }
273        }),
274        i if i == "Transformation" || i == "Measurement" || i == "Function" => {
275            match arguments {
276                syn::PathArguments::AngleBracketed(ab) => {
277                    let num_args = if i == "Function" { 2 } else { 4 };
278
279                    if ab.args.len() != num_args {
280                        return Err(Error::custom(format!(
281                            "{i} needs {num_args} angle-bracketed arguments"
282                        ))
283                        .with_span(&ab.args));
284                    }
285
286                    let [input_domain, output_domain] = [&ab.args[0], &ab.args[1]];
287
288                    // syn doesn't have a pretty printer but we don't need to add a dep...
289                    let pprint = |ty| {
290                        quote::quote!(#ty)
291                            .to_string()
292                            .replace(" ", "")
293                            .replace(",", ", ")
294                    };
295
296                    let input_label = match i {
297                        i if i == "Transformation" => "Domain:",
298                        i if i == "Measurement" => "Domain:",
299                        i if i == "Function" => "Type:  ",
300                        _ => unreachable!(),
301                    };
302
303                    let output_label = match i {
304                        i if i == "Transformation" => "Domain:",
305                        i if i == "Measurement" => "Type:  ",
306                        i if i == "Function" => "Type:  ",
307                        _ => unreachable!(),
308                    };
309
310                    let mut lines = vec![
311                        format!("* Input {}   `{}`", input_label, pprint(input_domain)),
312                        format!("* Output {}  `{}`", output_label, pprint(output_domain)),
313                    ];
314
315                    if i != "Function" {
316                        let output_distance = match i {
317                            i if i == "Transformation" => "Metric: ",
318                            i if i == "Measurement" => "Measure:",
319                            _ => unreachable!(),
320                        };
321                        let [input_metric, output_metmeas] = [&ab.args[2], &ab.args[3]];
322                        lines.extend([
323                            format!("* Input Metric:   `{}`", pprint(input_metric)),
324                            format!("* Output {} `{}`", output_distance, pprint(output_metmeas)),
325                        ]);
326                    }
327
328                    Ok(Some(lines.join("\n")))
329                }
330                arg => {
331                    return Err(
332                        Error::custom("Fallible needs an angle-bracketed argument").with_span(arg)
333                    )
334                }
335            }
336        }
337        _ => Ok(None),
338    }
339}
340
341/// extract the string inside a doc comment attribute
342fn parse_doc_attribute(attr: Attribute) -> Result<String> {
343    match attr.parse_meta()? {
344        Meta::NameValue(MetaNameValue {
345            lit: Lit::Str(v), ..
346        }) => Ok(v.value()),
347        _ => Err(Error::custom("doc attribute must be a string literal").with_span(&attr)),
348    }
349}
350
351/// Obtain a relative path to a proof, given all available information
352pub fn get_proof_path(
353    attr_args: &AttributeArgs,
354    item_fn: &ItemFn,
355    proof_paths: &HashMap<String, Option<String>>,
356) -> Result<Option<String>> {
357    let BootstrapArguments {
358        name,
359        proof_path,
360        unproven,
361        ..
362    } = BootstrapArguments::from_attribute_args(&attr_args)?;
363
364    let name = name.unwrap_or_else(|| item_fn.sig.ident.to_string());
365    if unproven && proof_path.is_some() {
366        return Err(Error::custom("proof_path is invalid when unproven"));
367    }
368    Ok(match proof_path {
369        Some(proof_path) => Some(proof_path),
370        None => match proof_paths.get(&name) {
371            Some(None) => return Err(Error::custom(format!("more than one file named {name}.tex. Please specify `proof_path = \"{{module}}/path/to/proof.tex\"` in the macro arguments."))),
372            Some(proof_path) => proof_path.clone(),
373            None => None
374        }
375    })
376}
377
378/// add attributes containing the proof link
379pub fn insert_proof_attribute(attributes: &mut Vec<Attribute>, proof_path: String) -> Result<()> {
380    let source_dir = get_src_dir()?;
381    let proof_path = PathBuf::from(proof_path);
382    let repo_path = PathBuf::from("rust/src");
383    let proof_link = format!(
384        " [(Proof Document)]({}) ",
385        make_proof_link(source_dir, proof_path, repo_path)?
386    );
387
388    let position = (attributes.iter())
389        .position(|attr| {
390            if attr.path.get_ident().map(ToString::to_string).as_deref() != Some("doc") {
391                return false;
392            }
393            if let Ok(comment) = parse_doc_attribute(attr.clone()) {
394                comment.starts_with(" # Proof Definition")
395            } else {
396                false
397            }
398        })
399        // point to the next line after the header, if found
400        .map(|i| i + 1)
401        // insert a header to the end, if not found
402        .unwrap_or_else(|| {
403            attributes.push(new_comment_attribute(" "));
404            attributes.push(new_comment_attribute(" # Proof Definition"));
405            attributes.len()
406        });
407
408    attributes.insert(position, new_comment_attribute(&proof_link));
409
410    Ok(())
411}
412
413/// construct an attribute representing a documentation comment
414fn new_comment_attribute(comment: &str) -> Attribute {
415    Attribute {
416        pound_token: Default::default(),
417        style: AttrStyle::Outer,
418        bracket_token: Default::default(),
419        path: Path::from(format_ident!("doc")),
420        tokens: TokenStream::from_iter(
421            [
422                TokenTree::Punct(Punct::new('=', Spacing::Alone)),
423                TokenTree::Literal(Literal::string(comment)),
424            ]
425            .into_iter(),
426        ),
427    }
428}
429
430pub fn make_rustdoc_link(module: &str, name: &str) -> Result<String> {
431    // link from foreign library docs to rust docs
432    let proof_uri = if let Ok(rustdoc_port) = std::env::var("OPENDP_RUSTDOC_PORT") {
433        format!("http://localhost:{rustdoc_port}")
434    } else {
435        // find the docs uri
436        let docs_uri =
437            env::var("OPENDP_REMOTE_RUSTDOC_URI").unwrap_or_else(|_| "https://docs.rs".to_string());
438
439        // find the version
440        let mut version = env!("CARGO_PKG_VERSION");
441        if version.ends_with("-dev") {
442            version = "latest";
443        };
444
445        format!("{docs_uri}/opendp/{version}")
446    };
447
448    Ok(format!(
449        // RST does not support nested markup, so do not try `{name}`!
450        "[{name} in Rust documentation.]({proof_uri}/opendp/{module}/fn.{name}.html)"
451    ))
452}