Skip to main content

cfg_version/
lib.rs

1//! Conditional compilation based on dependency versions.
2//!
3//! Parses `Cargo.lock` at macro expansion time to determine the resolved version of any dependency,
4//! then conditionally includes or excludes the annotated item. When a dependency appears multiple
5//! times in the lock file (e.g., `syn` 1.x and 2.x), only the version actually used by the current
6//! crate is considered.
7//!
8//! # Usage
9//!
10//! ```toml
11//! # Cargo.toml
12//! [dependencies]
13//! cfg-version = "0.1"
14//! ```
15//!
16//! ```rust,ignore
17//! use cfg_version::cfg_version;
18//!
19//! // Include only when indexmap >= 2.8.0, < 3.0.0
20//! #[cfg_version(indexmap = "^2.8")]
21//! fn needs_indexmap_2_8() { }
22//!
23//! // Include only when indexmap >= 2.0.0
24//! #[cfg_version(indexmap = ">=2")]
25//! fn needs_indexmap_2() { }
26//!
27//! // Include only when indexmap < 2.8.0
28//! #[cfg_version(indexmap = "<2.8")]
29//! fn old_indexmap_fallback() { }
30//! ```
31//!
32//! Version requirements use [Cargo's semver syntax](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html):
33//! `^`, `~`, `*`, `>=`, `>`, `<`, `<=`, `=`, and comma-separated combinations.
34
35use std::collections::HashMap;
36use std::sync::OnceLock;
37
38use proc_macro::TokenStream;
39use proc_macro2::{Delimiter, TokenStream as TokenStream2, TokenTree};
40use quote::quote;
41use semver::{Version, VersionReq};
42use syn::LitStr;
43use syn::parse::{Parse, ParseStream};
44
45/// A resolved package from Cargo.lock.
46struct Package {
47    name: String,
48    version: Version,
49    /// Direct dependencies. Each entry is either `"name"` or `"name version"`.
50    deps: Vec<String>,
51}
52
53/// Parsed Cargo.lock: all packages + index by name.
54struct LockFile {
55    packages: Vec<Package>,
56}
57
58impl LockFile {
59    fn parse(content: &str) -> Self {
60        let mut packages = Vec::new();
61        let mut current_name: Option<String> = None;
62        let mut current_version: Option<Version> = None;
63        let mut current_deps: Vec<String> = Vec::new();
64        let mut in_deps = false;
65
66        let flush = |name: &mut Option<String>,
67                     version: &mut Option<Version>,
68                     deps: &mut Vec<String>,
69                     packages: &mut Vec<Package>| {
70            if let (Some(name), Some(version)) = (name.take(), version.take()) {
71                packages.push(Package {
72                    name,
73                    version,
74                    deps: std::mem::take(deps),
75                });
76            }
77        };
78
79        for line in content.lines() {
80            let trimmed = line.trim();
81
82            if trimmed == "[[package]]" {
83                flush(
84                    &mut current_name,
85                    &mut current_version,
86                    &mut current_deps,
87                    &mut packages,
88                );
89                in_deps = false;
90                continue;
91            }
92
93            if let Some(rest) = trimmed.strip_prefix("name = ") {
94                current_name = Some(rest.trim_matches('"').to_string());
95                in_deps = false;
96            } else if let Some(rest) = trimmed.strip_prefix("version = ") {
97                let ver_str = rest.trim_matches('"');
98                current_version = Version::parse(ver_str).ok();
99                in_deps = false;
100            } else if trimmed == "dependencies = [" {
101                in_deps = true;
102            } else if in_deps && trimmed == "]" {
103                in_deps = false;
104            } else if in_deps {
105                // Lines like: "indexmap", or "syn 1.0.109",
106                let dep = trimmed.trim_matches(['"', ',', ' '].as_slice());
107                if !dep.is_empty() {
108                    current_deps.push(dep.to_string());
109                }
110            }
111        }
112
113        flush(
114            &mut current_name,
115            &mut current_version,
116            &mut current_deps,
117            &mut packages,
118        );
119
120        LockFile { packages }
121    }
122
123    /// Resolve the dependency versions for a given crate.
124    ///
125    /// For each dependency in the crate's `dependencies` list, find the matching
126    /// package in the lock file and return `(dep_name, dep_version)`.
127    fn resolve_deps_for(&self, pkg_name: &str) -> Vec<(String, Version)> {
128        // Build index: name -> list of versions
129        let mut by_name: HashMap<&str, Vec<&Package>> = HashMap::new();
130        for pkg in &self.packages {
131            by_name.entry(&pkg.name).or_default().push(pkg);
132        }
133
134        // Find the current crate's package entry
135        let current = match by_name.get(pkg_name).and_then(|pkgs| pkgs.first()) {
136            Some(pkg) => pkg,
137            None => return Vec::new(),
138        };
139
140        let mut result = Vec::new();
141        for dep_entry in &current.deps {
142            // dep_entry is either "name" or "name version"
143            let mut parts = dep_entry.splitn(2, ' ');
144            let dep_name = parts.next().unwrap();
145            let dep_version = parts.next();
146
147            if let Some(candidates) = by_name.get(dep_name) {
148                let resolved = if let Some(ver_str) = dep_version {
149                    // Exact version specified (disambiguation)
150                    candidates.iter().find(|p| p.version.to_string() == ver_str)
151                } else if candidates.len() == 1 {
152                    // Only one version, no ambiguity
153                    Some(&candidates[0])
154                } else {
155                    // Multiple versions but no disambiguation — shouldn't happen
156                    // in a well-formed Cargo.lock, but fall back to first
157                    candidates.first()
158                };
159
160                if let Some(pkg) = resolved {
161                    result.push((dep_name.to_string(), pkg.version.clone()));
162                }
163            }
164        }
165
166        result
167    }
168}
169
170// Cache only the lockfile parse (shared across crates in one compilation session).
171// Do NOT cache the per-crate dep resolution — CARGO_PKG_NAME changes between crates.
172static LOCK_CACHE: OnceLock<LockFile> = OnceLock::new();
173
174fn lockfile() -> &'static LockFile {
175    LOCK_CACHE.get_or_init(load_lockfile)
176}
177
178fn load_lockfile() -> LockFile {
179    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
180        Ok(d) => d,
181        Err(_) => {
182            return LockFile { packages: Vec::new() };
183        }
184    };
185
186    let mut dir = std::path::PathBuf::from(manifest_dir);
187    loop {
188        let candidate = dir.join("Cargo.lock");
189        if let Ok(content) = std::fs::read_to_string(&candidate) {
190            return LockFile::parse(&content);
191        }
192        if !dir.pop() {
193            return LockFile { packages: Vec::new() };
194        }
195    }
196}
197
198fn dep_matches(name: &str, req: &VersionReq) -> bool {
199    let pkg_name = std::env::var("CARGO_PKG_NAME").unwrap_or_default();
200    lockfile()
201        .resolve_deps_for(&pkg_name)
202        .iter()
203        .any(|(n, v)| n == name && req.matches(v))
204}
205
206struct Args {
207    name: String,
208    req: VersionReq,
209}
210
211impl Parse for Args {
212    fn parse(input: ParseStream) -> syn::Result<Self> {
213        let mut name = String::new();
214        loop {
215            if input.is_empty() {
216                return Err(input.error("expected `=` after crate name"));
217            }
218            let tt: TokenTree = input.parse()?;
219            if let TokenTree::Punct(ref p) = tt
220                && p.as_char() == '='
221            {
222                break;
223            }
224            name.push_str(&tt.to_string());
225        }
226        if name.is_empty() {
227            return Err(input.error("expected a crate name"));
228        }
229
230        let lit: LitStr = input.parse()?;
231        let req = VersionReq::parse(&lit.value())
232            .map_err(|e| syn::Error::new(lit.span(), format!("invalid version requirement: {e}")))?;
233        Ok(Args { name, req })
234    }
235}
236
237fn flatten_transparent_group(ts: TokenStream2) -> TokenStream2 {
238    let mut output = TokenStream2::new();
239    for tt in ts {
240        match tt {
241            TokenTree::Group(ref g) if g.delimiter() == Delimiter::None => {
242                output.extend(flatten_transparent_group(g.stream()));
243            }
244            other => output.extend(std::iter::once(other)),
245        }
246    }
247    output
248}
249
250/// Conditionally include an item based on a dependency's resolved version.
251///
252/// Uses [Cargo's semver syntax](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html)
253/// for version requirements. Only considers direct dependencies of the current crate.
254///
255/// # Examples
256///
257/// ```rust,ignore
258/// use cfg_version::cfg_version;
259///
260/// #[cfg_version(indexmap = "^2.8")]
261/// fn needs_indexmap_2_8() { }
262///
263/// #[cfg_version(indexmap = ">=2, <3")]
264/// fn needs_indexmap_2_x() { }
265///
266/// #[cfg_version(indexmap = "<2.8")]
267/// fn old_indexmap_fallback() { }
268/// ```
269///
270/// If the dependency is not present in the current crate's dependencies, the item is excluded.
271#[proc_macro_attribute]
272pub fn cfg_version(args: TokenStream, input: TokenStream) -> TokenStream {
273    let args = match syn::parse2::<Args>(flatten_transparent_group(args.into())) {
274        Ok(args) => args,
275        Err(e) => return e.to_compile_error().into(),
276    };
277
278    let keep = dep_matches(&args.name, &args.req);
279
280    if keep {
281        let input: TokenStream2 = input.into();
282        quote! {
283            #[allow(clippy::incompatible_msrv)]
284            #input
285        }
286        .into()
287    } else {
288        TokenStream::new()
289    }
290}