Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9a07192
feat: start new lint
DaAlbrecht May 8, 2025
a71caa7
feat: lint none tuple systems
DaAlbrecht May 18, 2025
a41af08
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht May 25, 2025
512f57e
fix: typos
DaAlbrecht May 25, 2025
8c7f11a
feat: support tuples in system registration and query data / filters,…
DaAlbrecht May 28, 2025
5592f90
doc: add lint module level doc
DaAlbrecht May 28, 2025
c3177bd
doc: add Motivaiton section
DaAlbrecht May 28, 2025
03b74d6
feat: skip lint if add_system is called in an external macro
DaAlbrecht May 29, 2025
718ab7d
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht May 30, 2025
d5c7b68
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht Jun 3, 2025
d8a1b8f
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht Jun 6, 2025
0cba6de
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht Jun 14, 2025
d81c11e
chore: remove empty line
DaAlbrecht Jun 21, 2025
c2a85f1
feat: improve lint description, feat: change visibility to pub(crate)
DaAlbrecht Jun 21, 2025
aaf04e6
feat: check if receiver type matches `bevy_app::app::App`
DaAlbrecht Jun 21, 2025
7a6b1fc
feat: update tests with new lint msg
DaAlbrecht Jun 21, 2025
4e346b0
feat: use array pattern
DaAlbrecht Jun 21, 2025
bde24fd
feat: improve lint help message
DaAlbrecht Jun 21, 2025
73e8e41
Merge branch 'main' into 105-camera-modification-fixed-update-lint
DaAlbrecht Jun 21, 2025
4449009
fix: logic and improve docs
BD103 Jun 25, 2025
d2f980f
Merge branch 'main' into 105-camera-modification-fixed-update-lint
BD103 Jun 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,204 changes: 1,174 additions & 30 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion bevy_lint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ toml_edit = { version = "0.22.22", default-features = false, features = [

[dev-dependencies]
# Used when running UI tests.
bevy = { version = "0.16.0", default-features = false, features = ["std"] }
bevy = { version = "0.16.0", default-features = false, features = [
"std",
# used for the `camera_modification_in_fixed_update` lint
"bevy_render",
] }

# Used to deserialize `--message-format=json` messages from Cargo.
serde_json = "1.0.140"
Expand Down
1 change: 1 addition & 0 deletions bevy_lint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
// This is a list of every single `rustc` crate used within this library. If you need another, add
// it here!
extern crate rustc_abi;
extern crate rustc_ast;
extern crate rustc_data_structures;
extern crate rustc_driver;
extern crate rustc_errors;
Expand Down
196 changes: 196 additions & 0 deletions bevy_lint/src/lints/nursery/camera_modification_in_fixed_update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Checks for systems added to the `FixedUpdate` schedule that mutably query entities with a
//! `Camera` component.
//!
//! # Motivation
//!
//! Modifying camera components in the `FixedUpdate` schedule can cause jittery or inconsistent
//! visuals because `FixedUpdate` runs at a fixed timestep mainly for physics and deterministic
//! logic. The camera should be updated in the last variable timestep before `FixedUpdate` which is
//! the `Update` schedule.
//!
//! # Known Issues
//!
//! - The lint only detects systems that explicitly use the `With<Camera>` query filter.
//!
//! # Example
//!
//! ```rust
//! use bevy::prelude::*;
//!
//! fn move_camera(mut query: Query<&mut Transform, With<Camera>>) {
//! // ...
//! }
//!
//! fn main() {
//! App::new()
//! .add_systems(FixedUpdate, move_camera);
//! }
//! ```
//!
//! Use instead:
//!
//! ```rust
//! use bevy::prelude::*;
//!
//! fn move_camera(mut query: Query<&mut Transform, With<Camera>>) {
//! // ...
//! }
//!
//! fn main() {
//! App::new()
//! .add_systems(Update, move_camera);
//! }
//! ```
use clippy_utils::{diagnostics::span_lint_and_help, sym, ty::match_type};
use rustc_hir::{ExprKind, QPath, def::Res};
use rustc_lint::{LateContext, LateLintPass};
use rustc_middle::ty::{Adt, GenericArgKind, TyKind};
use rustc_span::Symbol;

use crate::{declare_bevy_lint, declare_bevy_lint_pass, utils::hir_parse::MethodCall};

declare_bevy_lint! {
pub(crate) CAMERA_MODIFICATION_IN_FIXED_UPDATE,
super::Nursery,
"camera modified in the `FixedUpdate` schedule",
}

declare_bevy_lint_pass! {
pub(crate) CameraModificationInFixedUpdate => [CAMERA_MODIFICATION_IN_FIXED_UPDATE],
@default = {
add_systems: Symbol = sym!(add_systems),
},
}

impl<'tcx> LateLintPass<'tcx> for CameraModificationInFixedUpdate {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx rustc_hir::Expr<'tcx>) {
if expr.span.in_external_macro(cx.tcx.sess.source_map()) {
return;
}

let Some(MethodCall {
method_path,
args,
receiver,
..
}) = MethodCall::try_from(cx, expr)
else {
return;
};

let receiver_ty = cx.typeck_results().expr_ty(receiver).peel_refs();

// Match calls to `App::add_systems(schedule, systems)`
if match_type(cx, receiver_ty, &crate::paths::APP)
&& method_path.ident.name != self.add_systems
{
return;
}

let [schedule, systems] = args else {
return;
};

let schedule_ty = cx.typeck_results().expr_ty(schedule).peel_refs();

// Skip if the schedule is not `FixedUpdate`
if !match_type(cx, schedule_ty, &crate::paths::FIXED_UPDATE) {
return;
}

// Collect all added system expressions
let system_exprs = if let ExprKind::Tup(inner) = systems.kind {
inner.iter().collect()
} else {
vec![systems]
};

// Resolve the function definition for each system
for system_expr in system_exprs {
if let ExprKind::Path(QPath::Resolved(_, path)) = system_expr.kind
&& let Res::Def(_, def_id) = path.res
Comment on lines +140 to +141
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a follow-up: This doesn't handle nested tuples. Meaning I don't think this will lint on:

app.add_systems(FixedUpdate, (unrelated, (also_unrelated, modifies_camera)));
//                                                        ^^^^^^^^^^^^^^^ Won't see this

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! I think I want to solve this first before merging, otherwise it could be confusing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a nursery lint, so you can list it in "Known Issues" if you want this shipped first.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may be able to use detuple() for this as well! But again, we can definitely push this back to a follow-up.

{
let system_fn_sig = cx.tcx.fn_sig(def_id);
// Iterate over the function parameter types of the system function
for ty in system_fn_sig.skip_binder().inputs().skip_binder() {
let Adt(adt_def_id, args) = ty.kind() else {
continue;
};

// Check if the parameter is a `Query`
let adt_ty = cx.tcx.type_of(adt_def_id.did()).skip_binder();
if !match_type(cx, adt_ty, &crate::paths::QUERY) {
continue;
}

// Get the type arguments and ignore Lifetimes
let mut query_type_arguments =
args.iter()
.filter_map(|generic_arg| match generic_arg.unpack() {
GenericArgKind::Type(ty) => Some(ty),
_ => None,
});

let Some(query_data) = query_type_arguments.next() else {
return;
};

let Some(query_filters) = query_type_arguments.next() else {
return;
};

// Determine mutability of each queried component
let query_data_mutability = match query_data.kind() {
TyKind::Tuple(tys) => tys
.iter()
.filter_map(|ty| match ty.kind() {
TyKind::Ref(_, _, mutability) => Some(mutability),
_ => None,
})
.collect(),
TyKind::Ref(_, _, mutability) => vec![mutability],
_ => return,
};

// collect all query filters
let query_filters = if let TyKind::Tuple(inner) = query_filters.kind() {
inner.iter().collect()
} else {
vec![query_filters]
};

// Check for `With<Camera>` filter on a mutable query
for query_filter in query_filters {
// Check if the `With` `QueryFilter` was used.
if match_type(cx, query_filter, &crate::paths::WITH)
// Get the generic argument of the Filter
&& let TyKind::Adt(_, with_args) = query_filter.kind()
// There can only be exactly one argument
&& let Some(filter_component_arg) = with_args.iter().next()
// Get the type of the component the filter should filter for
&& let GenericArgKind::Type(filter_component_ty) =
filter_component_arg.unpack()
// Check if Filter is of type `Camera`
&& match_type(cx, filter_component_ty, &crate::paths::CAMERA)
// Emit lint if any `Camera` component is mutably borrowed
&& query_data_mutability.iter().any(|mutability|match mutability {
rustc_ast::Mutability::Not => false,
rustc_ast::Mutability::Mut => true,
})
{
span_lint_and_help(
cx,
CAMERA_MODIFICATION_IN_FIXED_UPDATE,
path.span,
CAMERA_MODIFICATION_IN_FIXED_UPDATE.desc,
None,
"insert the system in the `Update` schedule instead",
);
}
}
}
}
}
}
}
7 changes: 7 additions & 0 deletions bevy_lint/src/lints/nursery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use rustc_lint::{Level, Lint, LintStore};

use crate::lint::LintGroup;

pub mod camera_modification_in_fixed_update;
pub mod duplicate_bevy_dependencies;
pub mod zst_query;

Expand All @@ -15,11 +16,17 @@ impl LintGroup for Nursery {
const NAME: &str = "bevy::nursery";
const LEVEL: Level = Level::Allow;
const LINTS: &[&Lint] = &[
camera_modification_in_fixed_update::CAMERA_MODIFICATION_IN_FIXED_UPDATE,
duplicate_bevy_dependencies::DUPLICATE_BEVY_DEPENDENCIES,
zst_query::ZST_QUERY,
];

fn register_passes(store: &mut LintStore) {
store.register_late_pass(|_| {
Box::new(
camera_modification_in_fixed_update::CameraModificationInFixedUpdate::default(),
)
});
// `duplicate_bevy_dependencies` is a Cargo lint, so it does not have its own pass.
store.register_late_pass(|_| Box::new(zst_query::ZstQuery::default()));
}
Expand Down
6 changes: 6 additions & 0 deletions bevy_lint/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_app/src/app.rs#L78>
pub const APP: [&str; 3] = ["bevy_app", "app", "App"];
/// <https://github.com/bevyengine/bevy/blob/v0.16.0/crates/bevy_render/src/camera/camera.rs#L346>
pub const CAMERA: [&str; 4] = ["bevy_render", "camera", "camera", "Camera"];
/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_ecs/src/system/commands/command.rs#L48>
pub const COMMANDS: [&str; 4] = ["bevy_ecs", "system", "commands", "Commands"];
/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_ecs/src/component.rs#L456>
Expand All @@ -26,6 +28,10 @@ pub const EVENT: [&str; 4] = ["bevy_ecs", "event", "base", "Event"];
pub const EVENTS: [&str; 4] = ["bevy_ecs", "event", "collections", "Events"];
/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_ecs/src/world/entity_ref.rs#L3687>
pub const FILTERED_ENTITY_MUT: [&str; 4] = ["bevy_ecs", "world", "entity_ref", "FilteredEntityMut"];
/// <https://github.com/bevyengine/bevy/blob/v0.16.0/crates/bevy_app/src/main_schedule.rs#L132>
pub const FIXED_UPDATE: [&str; 3] = ["bevy_app", "main_schedule", "FixedUpdate"];
/// <https://github.com/bevyengine/bevy/blob/v0.16.0/crates/bevy_ecs/src/query/filter.rs#L137>
pub const WITH: [&str; 4] = ["bevy_ecs", "query", "filter", "With"];
/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_ecs/src/change_detection.rs#L920>
pub const MUT: [&str; 3] = ["bevy_ecs", "change_detection", "Mut"];
/// <https://github.com/bevyengine/bevy/blob/release-0.16.0/crates/bevy_ecs/src/change_detection.rs#L1020>
Expand Down
78 changes: 78 additions & 0 deletions bevy_lint/tests/ui/camera_modification_in_fixed_update/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#![feature(register_tool)]
#![register_tool(bevy)]
#![deny(bevy::camera_modification_in_fixed_update)]

use bevy::prelude::*;

#[derive(Component)]
struct Hp;

#[derive(Component)]
struct Player;

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, spawn_camera)
// This should not raise an error since its in `Startup`
.add_systems(Startup, move_camera)
.add_systems(FixedUpdate, move_camera)
//~^ ERROR: camera modified in the `FixedUpdate` schedule
//~| HELP: insert the system in the `Update` schedule instead
.add_systems(FixedUpdate, move_camera_tuple_data)
//~^ ERROR: camera modified in the `FixedUpdate` schedule
//~| HELP: insert the system in the `Update` schedule instead
// This should not raise an error since its in not mutably borrowing any data
.add_systems(FixedUpdate, dont_mut_camera)
//~| ERROR: camera modified in the `FixedUpdate` schedule
//~v HELP: insert the system in the `Update` schedule instead
.add_systems(FixedUpdate, (move_camera, move_camera_tuple_data))
//~^ ERROR: camera modified in the `FixedUpdate` schedule
//~| HELP: insert the system in the `Update` schedule instead
.add_systems(FixedUpdate, multiple_queries)
//~^ ERROR: camera modified in the `FixedUpdate` schedule
//~| HELP: insert the system in the `Update` schedule instead
.add_systems(FixedUpdate, multiple_none_mut_queries)
.add_systems(FixedUpdate, multiple_query_filters)
//~^ ERROR: camera modified in the `FixedUpdate` schedule
//~| HELP: insert the system in the `Update` schedule instead
.add_systems(FixedUpdate, multiple_none_mut_query_filters)
.run();
}

fn spawn_camera(mut commands: Commands) {
commands.spawn((Name::new("Camera"), Camera::default()));
}

fn move_camera(mut _query: Query<&mut Transform, With<Camera>>) {}

fn dont_mut_camera(_query: Query<&Transform, With<Camera>>) {}

fn move_camera_tuple_data(
mut _query: Query<(&mut Transform, &Hp, Entity), With<Camera>>,
mut _commands: Commands,
_time: Res<Time>,
) {
}

fn multiple_queries(
mut _query: Query<(&mut Transform, Entity), With<Camera>>,
mut _query2: Query<(&mut Hp, Entity), With<Player>>,
) {
}

fn multiple_none_mut_queries(
_query: Query<(&Transform, Entity), With<Camera>>,
mut _query2: Query<(&mut Hp, Entity), With<Player>>,
) {
}

fn multiple_query_filters(
mut _query: Query<(&mut Transform, Entity), (With<Camera>, Without<Player>)>,
) {
}

fn multiple_none_mut_query_filters(
_query: Query<(&Transform, Entity), (With<Camera>, Without<Player>)>,
) {
}
55 changes: 55 additions & 0 deletions bevy_lint/tests/ui/camera_modification_in_fixed_update/main.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:36:35
|
36 | .add_systems(FixedUpdate, multiple_query_filters)
| ^^^^^^^^^^^^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead
note: the lint level is defined here
--> tests/ui/camera_modification_in_fixed_update/main.rs:3:9
|
3 | #![deny(bevy::camera_modification_in_fixed_update)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:32:35
|
32 | .add_systems(FixedUpdate, multiple_queries)
| ^^^^^^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead

error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:29:36
|
29 | .add_systems(FixedUpdate, (move_camera, move_camera_tuple_data))
| ^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead

error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:29:49
|
29 | .add_systems(FixedUpdate, (move_camera, move_camera_tuple_data))
| ^^^^^^^^^^^^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead

error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:22:35
|
22 | .add_systems(FixedUpdate, move_camera_tuple_data)
| ^^^^^^^^^^^^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead

error: camera modified in the `FixedUpdate` schedule
--> tests/ui/camera_modification_in_fixed_update/main.rs:19:35
|
19 | .add_systems(FixedUpdate, move_camera)
| ^^^^^^^^^^^
|
= help: insert the system in the `Update` schedule instead

error: aborting due to 6 previous errors