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