Skip to main content

microscpi_doc/
lib.rs

1use std::{error::Error, path::Path};
2
3use microscpi_common::{Command, CommandPart};
4use serde::Serialize;
5use syn::{ImplItemFn, Lit, visit::Visit};
6
7/// Represents a serializable SCPI command for documentation export.
8#[derive(Debug, Serialize)]
9pub struct CommandDocumentation {
10    /// The full canonical path of the command.
11    pub path: String,
12    /// The name of the command as defined in the command definition.
13    pub name: String,
14    /// The parts of the command.
15    pub parts: Vec<CommandPart>,
16    /// Whether the command is a query (ends with ?).
17    pub is_query: bool,
18    /// The plain text documentation.
19    pub description: Option<String>,
20    /// Structured data extracted from YAML blocks in the documentation.
21    pub attributes: Option<serde_yaml::Value>,
22}
23
24/// Parses a doc comment string, extracting any YAML blocks.
25fn parse_doc(doc_str: &str) -> (Option<String>, Option<serde_yaml::Value>) {
26    // Regular expression to remove a single space in front of each line.
27    let clean_re = regex::Regex::new(r"(?m)^ ?").unwrap();
28    let doc_str = clean_re.replace_all(doc_str, "");
29
30    // Look for YAML code blocks
31    let re = regex::Regex::new(r"```yaml\s+([\s\S]+?)\s+```").unwrap();
32
33    let mut attributes = None;
34    let description = if let Some(captures) = re.captures(&doc_str) {
35        if let Some(yaml) = captures.get(1) {
36            match serde_yaml::from_str(yaml.as_str()) {
37                Ok(yaml) => {
38                    attributes = Some(yaml);
39                    // Remove the YAML block from the text
40                    re.replace(&doc_str, "").trim().to_string()
41                }
42                Err(_) => doc_str.to_string(),
43            }
44        } else {
45            doc_str.to_string()
46        }
47    } else {
48        doc_str.to_string()
49    };
50
51    (
52        if !description.is_empty() {
53            Some(description)
54        } else {
55            None
56        },
57        attributes,
58    )
59}
60
61/// A collection of command documents to be serialized.
62#[derive(Debug, Serialize)]
63pub struct Documentation {
64    /// List of all SCPI commands with their documentation.
65    pub commands: Vec<CommandDocumentation>,
66}
67
68impl Default for Documentation {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Documentation {
75    /// Creates a new empty Documentation.
76    pub fn new() -> Self {
77        Self {
78            commands: Vec::new(),
79        }
80    }
81
82    /// Parses a rust file containing SCPI command definitions and adds its documentation to the collection.
83    pub fn parse_file(&mut self, path: impl AsRef<Path>) -> Result<(), Box<dyn Error>> {
84        let content = std::fs::read_to_string(path)?;
85        let file = syn::parse_file(content.as_str())?;
86        self.visit_file(&file);
87        Ok(())
88    }
89
90    /// Adds a command to the documentation.
91    pub fn add_command(&mut self, command: CommandDocumentation) {
92        self.commands.push(command);
93    }
94
95    /// Serializes the command documentation to JSON.
96    pub fn to_json(&self) -> Result<String, serde_json::Error> {
97        serde_json::to_string_pretty(self)
98    }
99
100    /// Writes the command documentation to a JSON file.
101    pub fn write_to_file(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
102        let content = self
103            .to_json()
104            .map_err(|e| std::io::Error::other(e.to_string()))?;
105
106        std::fs::write(path, content)
107    }
108}
109
110/// Try to get the SCPI command name from a attribute.
111fn get_command_name(attr: &syn::Attribute) -> Option<String> {
112    let mut command_name = None;
113
114    let _ = attr.parse_nested_meta(|meta| {
115        if meta.path.is_ident("cmd") {
116            if let Lit::Str(name) = meta.value()?.parse()? {
117                command_name = Some(name.value());
118                Ok(())
119            } else {
120                Ok(())
121            }
122        } else {
123            Ok(())
124        }
125    });
126
127    command_name
128}
129
130/// Try to get the documentation string from an attribute.
131fn get_command_doc(item_fn: &ImplItemFn) -> String {
132    let doc: String = item_fn
133        .attrs
134        .iter()
135        .filter(|attr| attr.path().is_ident("doc"))
136        .filter_map(|attr| {
137            if let syn::Meta::NameValue(meta) = attr.meta.clone() {
138                if let syn::Expr::Lit(expr_lit) = meta.value {
139                    if let syn::Lit::Str(lit_str) = expr_lit.lit {
140                        return Some(lit_str.value());
141                    }
142                }
143            }
144            None
145        })
146        .collect::<Vec<String>>()
147        .join("\n");
148    doc
149}
150
151impl<'ast> Visit<'ast> for Documentation {
152    fn visit_impl_item_fn(&mut self, item_fn: &'ast ImplItemFn) {
153        for attr in &item_fn.attrs {
154            if attr.path().is_ident("scpi") {
155                let Some(cmd_name) = get_command_name(attr) else {
156                    continue;
157                };
158
159                let doc = get_command_doc(item_fn);
160
161                let Ok(cmd) = Command::try_from(cmd_name.as_str()) else {
162                    continue;
163                };
164
165                let (description, attributes) = parse_doc(doc.as_str());
166
167                let doc = CommandDocumentation {
168                    path: cmd.canonical_path(),
169                    name: cmd_name,
170                    is_query: cmd.is_query(),
171                    parts: cmd.parts,
172                    description,
173                    attributes,
174                };
175
176                self.add_command(doc);
177            }
178        }
179    }
180}