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    // no reason to continue the check if there is only one instance of `bevy` required
104    if find_crates(cx.tcx, sym::bevy).len() == 1 {
105        return;
106    }
107
108    // Load the `Cargo.toml` into the `SourceMap` this is  necessary to get the `Span` of the
109    // `Cargo.toml` file.
110    if let Ok(file) = cx.tcx.sess.source_map().load_file(Path::new("Cargo.toml"))
111        && let Some(src) = file.src.as_deref()
112        // Parse the `Cargo.toml` file into a `CargoToml` struct, this helps getting the correct span and not just
113        // the root span of the `Cargo.toml` file.
114        && let Ok(cargo_toml) = toml::from_str::<CargoToml>(src)
115    {
116        let local_name = cx.tcx.crate_name(LOCAL_CRATE);
117
118        // get the package name and the corresponding version of `bevy` that they depend on
119        let mut bevy_dependents = BTreeMap::default();
120        for package in &metadata.packages {
121            for dependency in &package.dependencies {
122                if dependency.name.as_str() == "bevy"
123                    && package.name.as_str() != local_name.as_str()
124                {
125                    bevy_dependents.insert(package.name.as_str(), dependency.req.clone());
126                }
127            }
128        }
129
130        // If `bevy` is listed as a direct dependency, use its version as the target version for all
131        // other crates, and check for any dependents that use a different version.
132        // If `bevy` is not listed as a direct dependency, check if multiple versions of `bevy` are
133        // resolved. If so, report a single lint error.
134        match cargo_toml.dependencies.get("bevy") {
135            Some(bevy_cargo) => {
136                lint_with_target_version(cx, &cargo_toml, &file, bevy_cargo, &bevy_dependents);
137            }
138
139            None => {
140                if let Some(resolve) = &metadata.resolve {
141                    minimal_lint(cx, &bevy_dependents, resolve);
142                }
143            }
144        }
145    }
146}
147
148fn lint_with_target_version(
149    cx: &LateContext<'_>,
150    cargo_toml: &CargoToml,
151    file: &Arc<SourceFile>,
152    bevy_cargo: &Spanned<toml::Value>,
153    bevy_dependents: &BTreeMap<&str, VersionReq>,
154) {
155    let bevy_cargo_toml_span = toml_span(bevy_cargo.span(), file);
156
157    // Semver only supports checking if a given `VersionReq` matches a `Version` and not if two
158    // `VersionReq` can successfully resolve to one `Version`. Therefore we try to parse the
159    // `Version` from the `bevy` dependency in the `Cargo.toml` file. This only works if a
160    // single version  of `bevy` is specified and not a range.
161    let Ok(target_version) = get_version_from_toml(bevy_cargo.as_ref()) else {
162        cx.tcx.dcx().span_warn(
163            bevy_cargo_toml_span,
164            "specified version format is not supported, use a fixed version or disable `bevy::duplicate_bevy_dependencies`",
165        );
166        return;
167    };
168
169    let mismatching_dependencies = bevy_dependents
170        .iter()
171        .filter(|dependency| !dependency.1.matches(&target_version));
172
173    for mismatching_dependency in mismatching_dependencies {
174        if let Some(cargo_toml_reference) = cargo_toml.dependencies.get(*mismatching_dependency.0) {
175            span_lint_and_then(
176                cx,
177                DUPLICATE_BEVY_DEPENDENCIES,
178                toml_span(cargo_toml_reference.span(), file),
179                DUPLICATE_BEVY_DEPENDENCIES.desc,
180                |diag| {
181                    diag.span_help(
182                        bevy_cargo_toml_span,
183                        format!("expected all crates to use `bevy` {target_version}, but `{}` uses `bevy` {}", mismatching_dependency.0, mismatching_dependency.1),
184                    );
185                },
186            );
187        }
188    }
189}
190
191fn minimal_lint(
192    cx: &LateContext<'_>,
193    bevy_dependents: &BTreeMap<&str, VersionReq>,
194    resolved: &Resolve,
195) {
196    // Examples of the underlying string representation of resolved crates
197    // "id": "file:///path/to/my-package#0.1.0",
198    // "id": "registry+https://github.com/rust-lang/crates.io-index#bevy@0.9.1",
199    let mut resolved_bevy_versions: Vec<&str> = resolved
200        .nodes
201        .iter()
202        .filter_map(|node| {
203            // Extract version from local crates
204            if node.id.repr.starts_with("file:///") {
205                return node.id.repr.split('#').nth(1).map(|version| vec![version]);
206            }
207            // Extract versions from external crates
208            if let Some((id, _)) = node.id.repr.split_once('@')
209                && bevy_dependents
210                    .keys()
211                    .any(|crate_name| id.ends_with(crate_name))
212            {
213                return Some(
214                    node.dependencies
215                        .iter()
216                        .filter_map(|dep| dep.repr.split_once('@'))
217                        .filter_map(|(name, version)| (name.contains("bevy")).then_some(version))
218                        .collect(),
219                );
220            }
221
222            None
223        })
224        .flatten()
225        .collect();
226
227    resolved_bevy_versions.sort_unstable();
228    resolved_bevy_versions.dedup();
229
230    if resolved_bevy_versions.len() > 1 {
231        span_lint(
232            cx,
233            DUPLICATE_BEVY_DEPENDENCIES,
234            rustc_span::DUMMY_SP,
235            "found multiple versions of bevy",
236        );
237    }
238}
239
240/// Extracts the `version` field from a [`toml::Value`] and parses it into a [`Version`]
241/// There are two possible formats:
242/// 1. A toml-string `<crate> = <version>`
243/// 2. A toml-table `<crate> = { version = <version> , ... }`
244///
245/// Cargo supports specifying version ranges, but [`parse_version()`] can only parse exact versions
246/// and not ranges.
247fn get_version_from_toml(table: &toml::Value) -> anyhow::Result<Version> {
248    match table {
249        toml::Value::String(version) => parse_version(version),
250        toml::Value::Table(table) => table
251            .get("version")
252            .and_then(toml::Value::as_str)
253            .ok_or_else(|| anyhow::anyhow!("The 'version' field is required."))
254            .map(parse_version)?,
255        _ => Err(anyhow::anyhow!(
256            "Unexpected TOML format: expected a toml-string '<crate> = <version>' or a toml-table with '<crate> = {{ version = <version> }} '"
257        )),
258    }
259}
260
261/// Parse a Version that does not contain any ranges.
262/// In contrast to `cargo_metadata::semver::Version::from_str` this also supports versions in the
263/// format of `1.1` by just setting the patch level to 0.
264fn parse_version(version: &str) -> anyhow::Result<Version> {
265    // split at '-' in order to not include the pre release version in the patch if one is present.
266    let mut iter = version.split('-');
267
268    // create a copy so we can validate that each part of the semver
269    // is a number without consuming the iterator.
270    let semver_parts = iter
271        .next()
272        .ok_or(anyhow::anyhow!("A version string is required"))?
273        .split('.')
274        .collect::<Vec<&str>>();
275
276    // check if each part of the semver only contains numbers.
277    if !semver_parts
278        .iter()
279        .all(|part| part.chars().all(|c| c.is_ascii_digit()))
280    {
281        return Err(anyhow::anyhow!("Version ranges are not yet supported"));
282    }
283
284    let pre = iter.next();
285
286    let major = semver_parts
287        .first()
288        .and_then(|v| v.parse::<u64>().ok())
289        .unwrap_or(0);
290
291    let minor = semver_parts
292        .get(1)
293        .and_then(|v| v.parse::<u64>().ok())
294        .unwrap_or(0);
295
296    let patch = semver_parts
297        .get(2)
298        .and_then(|v| v.parse::<u64>().ok())
299        .unwrap_or(0);
300
301    let mut version = Version::new(major, minor, patch);
302
303    if let Some(pre) = pre {
304        version.pre = Prerelease::new(pre).unwrap();
305    }
306    Ok(version)
307}
308
309#[cfg(test)]
310mod tests {
311    use cargo_metadata::semver::{Prerelease, Version};
312
313    use super::parse_version;
314
315    #[test]
316    fn parse_version_req() {
317        assert_eq!(Version::new(0, 16, 0), parse_version("0.16").unwrap());
318        assert_eq!(Version::new(0, 16, 1), parse_version("0.16.1").unwrap());
319        assert_eq!(Version::new(1, 16, 10), parse_version("1.16.10").unwrap());
320        let mut version_with_pre = Version::new(0, 16, 0);
321        version_with_pre.pre = Prerelease::new("rc.1").unwrap();
322        assert_eq!(version_with_pre, parse_version("0.16.0-rc.1").unwrap());
323        // This should fail since we specified a version range
324        assert!(parse_version("0.*").is_err());
325    }
326}