bevy_lint/lints/nursery/
duplicate_bevy_dependencies.rs

1//! Checks for multiple versions of the `bevy` crate in your project's dependencies.
2//!
3//! This lint will prevent you from accidentally using multiple versions of the Bevy game engine at
4//! the same time by scanning your dependency tree for the `bevy` crate. If your project or its
5//! dependencies use different versions of `bevy`, this lint will emit a warning.
6//!
7//! You may also be interested in [`cargo-deny`], which can detect duplicate dependencies as well,
8//! and is far more powerful and configurable.
9//!
10//! [`cargo-deny`]: https://github.com/EmbarkStudios/cargo-deny
11//!
12//! # Motivation
13//!
14//! Cargo allows there to be multiple major versions of a crate in your project's dependency
15//! tree[^semver-compatibility]. Although the crates and their types are _named_ the same, they are
16//! treated as distinct by the compiler. This can lead to confusing error messages that only appear
17//! if you try to mix the types from the two versions of the crate.
18//!
19//! With Bevy, these errors become particularly easy to encounter when you add a plugin that pulls
20//! in a different version of the Bevy engine. (This isn't immediately obvious, however, unless you
21//! look at `Cargo.lock` or the plugin's engine compatibility table.)
22//!
23//! [^semver-compatibility]: The rules for dependency unification and duplication are specified
24//!     [here](https://doc.rust-lang.org/cargo/reference/resolver.html#semver-compatibility).
25//!
26//! # Known Issues
27//!
28//! This lint only works if a specific version of `bevy` is declared. If a version range is
29//! specified, this lint will be skipped. For example:
30//!
31//! ```toml
32//! [dependencies]
33//! # This will not be linted, since it is a version range.
34//! bevy = ">=0.15"
35//! ```
36//!
37//! # Example
38//!
39//! ```toml
40//! [package]
41//! name = "foo"
42//! edition = "2024"
43//!
44//! [dependencies]
45//! bevy = "0.15"
46//! # This depends on Bevy 0.14, not 0.15! This will cause duplicate versions of the engine.
47//! leafwing-input-manager = "0.15"
48//! ```
49//!
50//! Use instead:
51//!
52//! ```toml
53//! [package]
54//! name = "foo"
55//! edition = "2024"
56//!
57//! [dependencies]
58//! bevy = "0.15"
59//! # Update to a newer version of the plugin, which supports Bevy 0.15.
60//! leafwing-input-manager = "0.16"
61//! ```
62
63use std::{collections::BTreeMap, ops::Range, path::Path, sync::Arc};
64
65use cargo_metadata::{
66    Metadata, Resolve,
67    semver::{Prerelease, Version, VersionReq},
68};
69use clippy_utils::{
70    diagnostics::{span_lint, span_lint_and_then},
71    paths::find_crates,
72};
73use rustc_hir::def_id::LOCAL_CRATE;
74use rustc_lint::LateContext;
75use rustc_span::{BytePos, Pos, SourceFile, Span, SyntaxContext};
76use serde::Deserialize;
77use toml::Spanned;
78
79use crate::{declare_bevy_lint, sym};
80
81declare_bevy_lint! {
82    pub(crate) DUPLICATE_BEVY_DEPENDENCIES,
83    super::Nursery,
84    "multiple versions of the `bevy` crate found",
85    @crate_level_only = true,
86}
87
88#[derive(Deserialize, Debug)]
89struct CargoToml {
90    dependencies: BTreeMap<Spanned<String>, Spanned<toml::Value>>,
91}
92
93fn toml_span(range: Range<usize>, file: &SourceFile) -> Span {
94    Span::new(
95        file.start_pos + BytePos::from_usize(range.start),
96        file.start_pos + BytePos::from_usize(range.end),
97        SyntaxContext::root(),
98        None,
99    )
100}
101
102pub(crate) fn check(cx: &LateContext<'_>, metadata: &Metadata) {
103    // Check if there are 2 or more crates named `bevy` being used. `bevy` will only be reported as
104    // used if it:
105    //
106    // 1. Is directly imported by the crate being linted. (ex. `use bevy::prelude::*;` or `extern
107    //    crate bevy;`)
108    // 2. Is transitively imported by another used crate. (ex. `use leafwing_input_manager::*;`,
109    //    which imports `bevy` internally)
110    //
111    // Simple adding `bevy = "*"` to `Cargo.toml` will not make it appear in `find_crates()`'s
112    // output.
113    if find_crates(cx.tcx, sym::bevy).len() <= 1 {
114        return;
115    }
116
117    // Load the `Cargo.toml` into the `SourceMap` this is  necessary to get the `Span` of the
118    // `Cargo.toml` file.
119    if let Ok(file) = cx.tcx.sess.source_map().load_file(Path::new("Cargo.toml"))
120        && let Some(src) = file.src.as_deref()
121        // Parse the `Cargo.toml` file into a `CargoToml` struct, this helps getting the correct span and not just
122        // the root span of the `Cargo.toml` file.
123        && let Ok(cargo_toml) = toml::from_str::<CargoToml>(src)
124    {
125        let local_name = cx.tcx.crate_name(LOCAL_CRATE);
126
127        // get the package name and the corresponding version of `bevy` that they depend on
128        let mut bevy_dependents = BTreeMap::default();
129        for package in &metadata.packages {
130            for dependency in &package.dependencies {
131                if dependency.name.as_str() == "bevy"
132                    && package.name.as_str() != local_name.as_str()
133                {
134                    bevy_dependents.insert(package.name.as_str(), dependency.req.clone());
135                }
136            }
137        }
138
139        // If `bevy` is listed as a direct dependency, use its version as the target version for all
140        // other crates, and check for any dependents that use a different version.
141        // If `bevy` is not listed as a direct dependency, check if multiple versions of `bevy` are
142        // resolved. If so, report a single lint error.
143        match cargo_toml.dependencies.get("bevy") {
144            Some(bevy_cargo) => {
145                lint_with_target_version(cx, &cargo_toml, &file, bevy_cargo, &bevy_dependents);
146            }
147
148            None => {
149                if let Some(resolve) = &metadata.resolve {
150                    minimal_lint(cx, &bevy_dependents, resolve);
151                }
152            }
153        }
154    }
155}
156
157fn lint_with_target_version(
158    cx: &LateContext<'_>,
159    cargo_toml: &CargoToml,
160    file: &Arc<SourceFile>,
161    bevy_cargo: &Spanned<toml::Value>,
162    bevy_dependents: &BTreeMap<&str, VersionReq>,
163) {
164    let bevy_cargo_toml_span = toml_span(bevy_cargo.span(), file);
165
166    // Semver only supports checking if a given `VersionReq` matches a `Version` and not if two
167    // `VersionReq` can successfully resolve to one `Version`. Therefore we try to parse the
168    // `Version` from the `bevy` dependency in the `Cargo.toml` file. This only works if a
169    // single version  of `bevy` is specified and not a range.
170    let Ok(target_version) = get_version_from_toml(bevy_cargo.as_ref()) else {
171        cx.tcx.dcx().span_warn(
172            bevy_cargo_toml_span,
173            "specified version format is not supported, use a fixed version or disable `bevy::duplicate_bevy_dependencies`",
174        );
175        return;
176    };
177
178    let mismatching_dependencies = bevy_dependents
179        .iter()
180        .filter(|dependency| !dependency.1.matches(&target_version));
181
182    for mismatching_dependency in mismatching_dependencies {
183        if let Some(cargo_toml_reference) = cargo_toml.dependencies.get(*mismatching_dependency.0) {
184            span_lint_and_then(
185                cx,
186                DUPLICATE_BEVY_DEPENDENCIES,
187                toml_span(cargo_toml_reference.span(), file),
188                DUPLICATE_BEVY_DEPENDENCIES.desc,
189                |diag| {
190                    diag.span_help(
191                        bevy_cargo_toml_span,
192                        format!("expected all crates to use `bevy` {target_version}, but `{}` uses `bevy` {}", mismatching_dependency.0, mismatching_dependency.1),
193                    );
194                },
195            );
196        }
197    }
198}
199
200fn minimal_lint(
201    cx: &LateContext<'_>,
202    bevy_dependents: &BTreeMap<&str, VersionReq>,
203    resolved: &Resolve,
204) {
205    // Examples of the underlying string representation of resolved crates
206    // "id": "file:///path/to/my-package#0.1.0",
207    // "id": "registry+https://github.com/rust-lang/crates.io-index#bevy@0.9.1",
208    let mut resolved_bevy_versions: Vec<&str> = resolved
209        .nodes
210        .iter()
211        .filter_map(|node| {
212            // Extract version from local crates
213            if node.id.repr.starts_with("file:///") {
214                return node.id.repr.split('#').nth(1).map(|version| vec![version]);
215            }
216            // Extract versions from external crates
217            if let Some((id, _)) = node.id.repr.split_once('@')
218                && bevy_dependents
219                    .keys()
220                    .any(|crate_name| id.ends_with(crate_name))
221            {
222                return Some(
223                    node.dependencies
224                        .iter()
225                        .filter_map(|dep| dep.repr.split_once('@'))
226                        .filter_map(|(name, version)| (name.contains("bevy")).then_some(version))
227                        .collect(),
228                );
229            }
230
231            None
232        })
233        .flatten()
234        .collect();
235
236    resolved_bevy_versions.sort_unstable();
237    resolved_bevy_versions.dedup();
238
239    if resolved_bevy_versions.len() > 1 {
240        span_lint(
241            cx,
242            DUPLICATE_BEVY_DEPENDENCIES,
243            rustc_span::DUMMY_SP,
244            "found multiple versions of bevy",
245        );
246    }
247}
248
249/// Extracts the `version` field from a [`toml::Value`] and parses it into a [`Version`]
250/// There are two possible formats:
251/// 1. A toml-string `<crate> = <version>`
252/// 2. A toml-table `<crate> = { version = <version> , ... }`
253///
254/// Cargo supports specifying version ranges, but [`parse_version()`] can only parse exact versions
255/// and not ranges.
256fn get_version_from_toml(table: &toml::Value) -> anyhow::Result<Version> {
257    match table {
258        toml::Value::String(version) => parse_version(version),
259        toml::Value::Table(table) => table
260            .get("version")
261            .and_then(toml::Value::as_str)
262            .ok_or_else(|| anyhow::anyhow!("The 'version' field is required."))
263            .map(parse_version)?,
264        _ => Err(anyhow::anyhow!(
265            "Unexpected TOML format: expected a toml-string '<crate> = <version>' or a toml-table with '<crate> = {{ version = <version> }} '"
266        )),
267    }
268}
269
270/// Parse a Version that does not contain any ranges.
271/// In contrast to `cargo_metadata::semver::Version::from_str` this also supports versions in the
272/// format of `1.1` by just setting the patch level to 0.
273fn parse_version(version: &str) -> anyhow::Result<Version> {
274    // split at '-' in order to not include the pre release version in the patch if one is present.
275    let mut iter = version.split('-');
276
277    // create a copy so we can validate that each part of the semver
278    // is a number without consuming the iterator.
279    let semver_parts = iter
280        .next()
281        .ok_or(anyhow::anyhow!("A version string is required"))?
282        .split('.')
283        .collect::<Vec<&str>>();
284
285    // check if each part of the semver only contains numbers.
286    if !semver_parts
287        .iter()
288        .all(|part| part.chars().all(|c| c.is_ascii_digit()))
289    {
290        return Err(anyhow::anyhow!("Version ranges are not yet supported"));
291    }
292
293    let pre = iter.next();
294
295    let major = semver_parts
296        .first()
297        .and_then(|v| v.parse::<u64>().ok())
298        .unwrap_or(0);
299
300    let minor = semver_parts
301        .get(1)
302        .and_then(|v| v.parse::<u64>().ok())
303        .unwrap_or(0);
304
305    let patch = semver_parts
306        .get(2)
307        .and_then(|v| v.parse::<u64>().ok())
308        .unwrap_or(0);
309
310    let mut version = Version::new(major, minor, patch);
311
312    if let Some(pre) = pre {
313        version.pre = Prerelease::new(pre).unwrap();
314    }
315    Ok(version)
316}
317
318#[cfg(test)]
319mod tests {
320    use cargo_metadata::semver::{Prerelease, Version};
321
322    use super::parse_version;
323
324    #[test]
325    fn parse_version_req() {
326        assert_eq!(Version::new(0, 16, 0), parse_version("0.16").unwrap());
327        assert_eq!(Version::new(0, 16, 1), parse_version("0.16.1").unwrap());
328        assert_eq!(Version::new(1, 16, 10), parse_version("1.16.10").unwrap());
329        let mut version_with_pre = Version::new(0, 16, 0);
330        version_with_pre.pre = Prerelease::new("rc.1").unwrap();
331        assert_eq!(version_with_pre, parse_version("0.16.0-rc.1").unwrap());
332        // This should fail since we specified a version range
333        assert!(parse_version("0.*").is_err());
334    }
335}