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}