document_metrics/
main.rs

1#![doc(
2    html_logo_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png",
3    html_favicon_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png"
4)]
5
6use std::collections::HashMap;
7use std::fs::{self, File};
8use std::io::{self, Write};
9use std::path::PathBuf;
10
11use anyhow::{Context, Result};
12use clap::{Parser, ValueEnum};
13use serde::Serialize;
14use syn::{Expr, Lit, LitStr};
15
16#[derive(Clone, Copy, Debug, ValueEnum)]
17enum SchemaFormat {
18    Json,
19    Yaml,
20}
21
22#[derive(Clone, Copy, Debug, Serialize)]
23enum MetricType {
24    Timer,
25    Counter,
26    Histogram,
27    Set,
28    Gauge,
29}
30
31#[derive(Debug, PartialEq, Eq, Hash)]
32struct MetricPath(syn::Ident, syn::Ident);
33
34#[derive(Debug, Serialize)]
35struct Metric {
36    #[serde(rename = "type")]
37    ty: MetricType,
38    name: String,
39    description: String,
40    features: Vec<String>,
41}
42
43/// Returns the value of a matching attribute in form `#[name = "value"]`.
44fn get_attr(name: &str, nv: &syn::MetaNameValue) -> Option<String> {
45    let Expr::Lit(lit) = &nv.value else {
46        return None;
47    };
48    match &lit.lit {
49        syn::Lit::Str(s) if nv.path.is_ident(name) => Some(s.value()),
50        _ => None,
51    }
52}
53
54/// Adds a line to the string if the attribute is a doc attribute.
55fn add_doc_line(docs: &mut String, nv: &syn::MetaNameValue) {
56    if let Some(line) = get_attr("doc", nv) {
57        if !docs.is_empty() {
58            docs.push('\n');
59        }
60        docs.push_str(line.trim());
61    }
62}
63
64/// Adds the name of the feature if the given attribute is a `cfg(feature)` attribute.
65fn add_feature(features: &mut Vec<String>, l: &syn::MetaList) -> Result<()> {
66    if l.path.is_ident("cfg") {
67        l.parse_nested_meta(|meta| process_meta_item(features, &meta))?;
68    }
69    Ok(())
70}
71
72/// Recursively processes a meta item and its nested items.
73fn process_meta_item(
74    features: &mut Vec<String>,
75    meta: &syn::meta::ParseNestedMeta,
76) -> syn::Result<()> {
77    if meta.path.is_ident("feature") {
78        let s = meta.value()?.parse::<LitStr>()?;
79        features.push(s.value());
80    } else if meta.path.is_ident("all") {
81        meta.parse_nested_meta(|nested_meta| process_meta_item(features, &nested_meta))?;
82    } else if let Some(ident) = meta.path.get_ident() {
83        features.push(ident.to_string());
84    } else if !meta.input.peek(syn::Token![,]) {
85        let _ = meta.value()?.parse::<Lit>()?;
86    }
87    Ok(())
88}
89
90/// Returns metric information from metric enum variant attributes.
91fn parse_variant_parts(attrs: &[syn::Attribute]) -> Result<(String, Vec<String>)> {
92    let mut features = Vec::new();
93    let mut docs = String::new();
94
95    for attribute in attrs {
96        match &attribute.meta {
97            syn::Meta::NameValue(nv) => add_doc_line(&mut docs, nv),
98            syn::Meta::List(l) => add_feature(&mut features, l)?,
99            _ => (),
100        }
101    }
102
103    Ok((docs, features))
104}
105
106/// Returns the final type name from a path in format `path::to::Type`.
107fn get_path_type(mut path: syn::Path) -> Option<syn::Ident> {
108    let last_segment = path.segments.pop()?;
109    Some(last_segment.into_value().ident)
110}
111
112/// Returns the variant name and string value of a match arm in format `Type::Variant => "value"`.
113fn get_match_pair(arm: syn::Arm) -> Option<(syn::Ident, String)> {
114    let variant = match arm.pat {
115        syn::Pat::Path(path) => get_path_type(path.path)?,
116        _ => return None,
117    };
118
119    if let syn::Expr::Lit(lit) = *arm.body {
120        if let syn::Lit::Str(s) = lit.lit {
121            return Some((variant, s.value()));
122        }
123    }
124
125    None
126}
127
128/// Returns the metric type of a trait implementation.
129fn get_metric_type(imp: &mut syn::ItemImpl) -> Option<MetricType> {
130    let (_, path, _) = imp.trait_.take()?;
131    let trait_name = get_path_type(path)?;
132
133    if trait_name == "TimerMetric" {
134        Some(MetricType::Timer)
135    } else if trait_name == "CounterMetric" {
136        Some(MetricType::Counter)
137    } else if trait_name == "HistogramMetric" {
138        Some(MetricType::Histogram)
139    } else if trait_name == "SetMetric" {
140        Some(MetricType::Set)
141    } else if trait_name == "GaugeMetric" {
142        Some(MetricType::Gauge)
143    } else {
144        None
145    }
146}
147
148/// Returns the type name of a type in format `path::to::Type`.
149fn get_type_name(ty: syn::Type) -> Option<syn::Ident> {
150    match ty {
151        syn::Type::Path(path) => get_path_type(path.path),
152        _ => None,
153    }
154}
155
156/// Resolves match arms in the implementation of the `name` method.
157fn find_name_arms(items: Vec<syn::ImplItem>) -> Option<Vec<syn::Arm>> {
158    for item in items {
159        let method = match item {
160            syn::ImplItem::Fn(method) if method.sig.ident == "name" => method,
161            _ => continue,
162        };
163
164        for stmt in method.block.stmts {
165            if let syn::Stmt::Expr(syn::Expr::Match(mat), _) = stmt {
166                return Some(mat.arms);
167            }
168        }
169    }
170
171    None
172}
173
174/// Parses metrics information from an impl block.
175fn parse_impl_parts(mut imp: syn::ItemImpl) -> Option<(MetricType, syn::Ident, Vec<syn::Arm>)> {
176    let ty = get_metric_type(&mut imp)?;
177    let type_name = get_type_name(*imp.self_ty)?;
178    let arms = find_name_arms(imp.items)?;
179    Some((ty, type_name, arms))
180}
181
182fn sort_metrics(metrics: &mut [Metric]) {
183    metrics.sort_by(|a, b| a.name.cmp(&b.name));
184}
185
186/// Parses metrics from the given source code.
187fn parse_metrics(source: &str) -> Result<Vec<Metric>> {
188    let ast = syn::parse_file(source).with_context(|| "failed to parse metrics file")?;
189
190    let mut variant_parts = HashMap::new();
191    let mut impl_parts = HashMap::new();
192
193    for item in ast.items {
194        match item {
195            syn::Item::Enum(enum_item) => {
196                for variant in enum_item.variants {
197                    let path = MetricPath(enum_item.ident.clone(), variant.ident);
198                    let (description, features) = parse_variant_parts(&variant.attrs)?;
199                    variant_parts.insert(path, (description, features));
200                }
201            }
202            syn::Item::Impl(imp) => {
203                if let Some((ty, type_name, arms)) = parse_impl_parts(imp) {
204                    for (variant, name) in arms.into_iter().filter_map(get_match_pair) {
205                        let path = MetricPath(type_name.clone(), variant);
206                        impl_parts.insert(path, (name, ty));
207                    }
208                }
209            }
210            _ => (),
211        }
212    }
213
214    let mut metrics = Vec::with_capacity(impl_parts.len());
215
216    for (path, (name, ty)) in impl_parts {
217        let (description, features) = variant_parts.remove(&path).unwrap();
218        metrics.push(Metric {
219            ty,
220            name,
221            description,
222            features,
223        });
224    }
225
226    sort_metrics(&mut metrics);
227    Ok(metrics)
228}
229
230/// Prints documentation for metrics.
231#[derive(Debug, Parser)]
232#[command(verbatim_doc_comment)]
233struct Cli {
234    /// The format to output the documentation in.
235    #[arg(value_enum, short, long, default_value = "json")]
236    format: SchemaFormat,
237
238    /// Optional output path. By default, documentation is printed on stdout.
239    #[arg(short, long)]
240    output: Option<PathBuf>,
241
242    /// Paths to source files declaring metrics.
243    #[arg(required = true)]
244    paths: Vec<PathBuf>,
245}
246
247impl Cli {
248    fn write_metrics<W: Write>(&self, writer: W, metrics: &[Metric]) -> Result<()> {
249        match self.format {
250            SchemaFormat::Json => serde_json::to_writer_pretty(writer, metrics)?,
251            SchemaFormat::Yaml => serde_yaml::to_writer(writer, metrics)?,
252        };
253
254        Ok(())
255    }
256
257    pub fn run(self) -> Result<()> {
258        let mut metrics = Vec::new();
259        for path in &self.paths {
260            metrics.extend(parse_metrics(&fs::read_to_string(path)?)?);
261        }
262        sort_metrics(&mut metrics);
263
264        match self.output {
265            Some(ref path) => self.write_metrics(File::create(path)?, &metrics)?,
266            None => self.write_metrics(io::stdout(), &metrics)?,
267        }
268
269        Ok(())
270    }
271}
272
273fn print_error(error: &anyhow::Error) {
274    eprintln!("Error: {error}");
275
276    let mut cause = error.source();
277    while let Some(ref e) = cause {
278        eprintln!("  caused by: {e}");
279        cause = e.source();
280    }
281}
282
283fn main() {
284    let cli = Cli::parse();
285
286    match cli.run() {
287        Ok(()) => (),
288        Err(error) => {
289            print_error(&error);
290            std::process::exit(1);
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_parse_metrics() -> Result<()> {
301        let source = r#"
302            /// A metric collection used for testing.
303            pub enum TestSets {
304                /// The metric we test.
305                UniqueSet,
306                /// The metric we test.
307                #[cfg(feature = "conditional")]
308                ConditionalSet,
309                /// Another metric we test.
310                #[cfg(cfg_flag)]
311                ConditionalCompileSet,
312                /// Yet another metric we test.
313                #[cfg(all(cfg_flag, feature = "conditional"))]
314                MultiConditionalCompileSet,
315            }
316
317            impl SetMetric for TestSets {
318                fn name(&self) -> &'static str {
319                    match self {
320                        Self::UniqueSet => "test.unique",
321                        #[cfg(feature = "conditional")]
322                        Self::ConditionalSet => "test.conditional",
323                        #[cfg(cfg_flag)]
324                        Self::ConditionalCompileSet => "test.conditional_compile",
325                        #[cfg(all(cfg_flag, feature = "conditional"))]
326                        Self::MultiConditionalCompileSet => "test.multi_conditional_compile"
327                    }
328                }
329            }
330        "#;
331
332        let metrics = parse_metrics(source)?;
333        insta::assert_debug_snapshot!(metrics, @r#"
334        [
335            Metric {
336                ty: Set,
337                name: "test.conditional",
338                description: "The metric we test.",
339                features: [
340                    "conditional",
341                ],
342            },
343            Metric {
344                ty: Set,
345                name: "test.conditional_compile",
346                description: "Another metric we test.",
347                features: [
348                    "cfg_flag",
349                ],
350            },
351            Metric {
352                ty: Set,
353                name: "test.multi_conditional_compile",
354                description: "Yet another metric we test.",
355                features: [
356                    "cfg_flag",
357                    "conditional",
358                ],
359            },
360            Metric {
361                ty: Set,
362                name: "test.unique",
363                description: "The metric we test.",
364                features: [],
365            },
366        ]
367        "#);
368
369        Ok(())
370    }
371}