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