cargo_gazelle/
context.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Any context outside of `Cargo.toml` that is necessary for generating BUILD targets.
11
12use std::collections::BTreeSet;
13use std::fs::File;
14use std::io::Read;
15use std::path::Path;
16
17use anyhow::{Context, anyhow};
18use camino::Utf8PathBuf;
19use guppy::graph::{BuildTargetId, PackageMetadata};
20use quote::ToTokens;
21
22use crate::config::{CrateConfig, GlobalConfig};
23
24#[derive(Default, Debug, Clone)]
25pub struct CrateContext {
26    /// Context from the crate's build script, if one exists.
27    pub build_script: Option<BuildScriptContext>,
28}
29
30impl CrateContext {
31    /// Generates necessary external (non-`Cargo.toml`) context for a crate.
32    pub fn generate(
33        config: &GlobalConfig,
34        crate_config: &CrateConfig,
35        metadata: &PackageMetadata<'_>,
36    ) -> Result<CrateContext, anyhow::Error> {
37        tracing::debug!(name = metadata.name(), "generating context");
38
39        let mut context = CrateContext::default();
40
41        if let Some(build) = metadata.build_target(&BuildTargetId::BuildScript) {
42            if !crate_config.build().skip_proto_search() {
43                let build_script_context =
44                    BuildScriptContext::generate(config, build.path()).context("build script")?;
45                context.build_script = Some(build_script_context);
46            }
47        }
48
49        Ok(context)
50    }
51}
52
53#[derive(Default, Debug, Clone)]
54pub struct BuildScriptContext {
55    /// What protobuf files, if any, this build script generates bindings for.
56    pub generated_protos: Vec<String>,
57    /// Relative paths of dependencies for the protobuf files we generate.
58    pub proto_dependencies: BTreeSet<Utf8PathBuf>,
59}
60
61impl BuildScriptContext {
62    pub fn generate(
63        config: &GlobalConfig,
64        build_script_path: impl AsRef<Path>,
65    ) -> Result<BuildScriptContext, anyhow::Error> {
66        let build_script_path = build_script_path.as_ref();
67        tracing::debug!(?build_script_path, "generating build script context");
68
69        let mut context = BuildScriptContext::default();
70
71        let mut file = File::open(build_script_path)?;
72        let mut content = String::new();
73        file.read_to_string(&mut content)?;
74
75        // Parse the AST of the build script to find any invocations of protobuf generation and the
76        // files we're attempting to generate.
77        let ast = syn::parse_file(&content)?;
78        let proto_files: Vec<String> = ast
79            .items
80            .iter()
81            .filter_map(|item| match item {
82                syn::Item::Fn(func) => Some(func),
83                _ => None,
84            })
85            .flat_map(|func| {
86                func.block.stmts.iter().filter_map(|stmt| match stmt {
87                    syn::Stmt::Expr(expr) | syn::Stmt::Semi(expr, _) => Some(expr),
88                    _ => None,
89                })
90            })
91            .filter(|expr| find_proto_build(config, expr))
92            .map(|expr| find_proto_files(config, expr.to_token_stream()))
93            .flat_map(|files| files.into_iter())
94            // Remove the first component of the path.
95            .map(|s| {
96                tracing::debug!(path = s, "protobuf path");
97                let mut path = Utf8PathBuf::new();
98                for c in camino::Utf8PathBuf::from(s).components().skip(1) {
99                    path.push(c);
100                }
101                path.to_string()
102            })
103            .collect();
104
105        // Parse the protobuf files we just found to determine dependencies.
106        let crate_root_path = build_script_path
107            .parent()
108            .ok_or_else(|| anyhow!("build script at the root of the filesystem?"))?;
109        let proto_dependencies: BTreeSet<_> = proto_files
110            .iter()
111            .map(|sub_path| {
112                tracing::debug!(?sub_path, "protobuf dependency");
113                let full_path = crate_root_path.join(sub_path);
114                parse_proto_dependencies(&full_path).context("parsing build script proto")
115            })
116            .collect::<Result<Vec<_>, _>>()?
117            .into_iter()
118            .flat_map(|paths| paths.into_iter())
119            .collect();
120
121        context.generated_protos = proto_files;
122        context.proto_dependencies = proto_dependencies;
123
124        Ok(context)
125    }
126}
127
128// TODO(parkmycar): There is almost definitely a better way to do this AST parsing.
129fn find_proto_build(config: &GlobalConfig, expr: &syn::Expr) -> bool {
130    match expr {
131        syn::Expr::Path(func_path) => {
132            let calls_proto_gen = func_path.path.segments.iter().any(|segment| {
133                config
134                    .proto_build_crates
135                    .iter()
136                    .any(|proto_crate_names| segment.ident == proto_crate_names)
137            });
138            calls_proto_gen
139        }
140        syn::Expr::Call(call) => find_proto_build(config, &call.func),
141        syn::Expr::MethodCall(call) => find_proto_build(config, &call.receiver),
142        syn::Expr::Block(inner) => inner.block.stmts.iter().any(|s| match &s {
143            syn::Stmt::Semi(expr, _) | syn::Stmt::Expr(expr) => find_proto_build(config, expr),
144            _ => false,
145        }),
146        syn::Expr::Try(inner) => find_proto_build(config, &inner.expr),
147        _ => false,
148    }
149}
150
151fn find_proto_files(_config: &GlobalConfig, tokens: proc_macro2::TokenStream) -> Vec<String> {
152    let mut files = Vec::new();
153    for token in tokens {
154        match token {
155            proc_macro2::TokenTree::Literal(val) => {
156                // Parse the token as a LitStr to remove the `"` around the literal val.
157                let val = syn::parse2::<syn::LitStr>(val.to_token_stream())
158                    .expect("shouldn't fail")
159                    .value();
160                if val.ends_with(".proto") {
161                    files.push(val);
162                }
163            }
164            proc_macro2::TokenTree::Group(group) => {
165                let inner_files = find_proto_files(_config, group.stream());
166                files.extend(inner_files);
167            }
168            _ => (),
169        }
170    }
171    files
172}
173
174/// Opens and parses the provided [`Path`] to a protobuf file, returning all
175/// imports from the file.
176fn parse_proto_dependencies(
177    full_path: &Path,
178) -> Result<BTreeSet<camino::Utf8PathBuf>, anyhow::Error> {
179    tracing::debug!(proto = ?full_path, "reading deps");
180
181    let mut proto_file = File::open(full_path)?;
182    let mut content = String::new();
183    proto_file.read_to_string(&mut content)?;
184
185    let proto_desc = protobuf_parse::pure::parse_dependencies(&content).context("parsing proto")?;
186    let dependencies: BTreeSet<_> = proto_desc
187        .dependency
188        .into_iter()
189        .map(camino::Utf8PathBuf::from)
190        .collect();
191
192    Ok(dependencies)
193}