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