use crate::lint::{Hit, Rule, Severity};
use crate::visit;
pub struct SpatialQueryInLoop;
pub struct MoveToInLoop;
pub struct TouchedWithoutDebounce;
pub struct SetNetworkOwnerInLoop;
pub struct PreciseCollisionFidelity;
pub struct CollisionGroupStringInLoop;
pub struct AnchoredWithVelocity;
pub struct RaycastParamsInLoop;
pub struct CFrameAssignInLoop;
pub struct CanTouchQueryNotDisabled;
pub struct WeldConstraintInLoop;
pub struct MasslessNotSet;
pub struct AssemblyVelocityInLoop;
impl Rule for SpatialQueryInLoop {
fn id(&self) -> &'static str { "physics::spatial_query_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
visit::each_call(ast, |call, ctx| {
if !ctx.in_loop {
return;
}
let is_spatial = visit::is_method_call(call, "Raycast")
|| visit::is_method_call(call, "GetPartBoundsInBox")
|| visit::is_method_call(call, "GetPartBoundsInRadius")
|| visit::is_method_call(call, "GetPartsInPart")
|| visit::is_method_call(call, "Blockcast")
|| visit::is_method_call(call, "Spherecast")
|| visit::is_method_call(call, "Shapecast");
if is_spatial {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "spatial query in loop - expensive physics operation, consider batching or caching".into(),
});
}
});
hits
}
}
impl Rule for MoveToInLoop {
fn id(&self) -> &'static str { "physics::move_to_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
visit::each_call(ast, |call, ctx| {
if ctx.in_loop && visit::is_method_call(call, "MoveTo") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":MoveTo() in loop - consider workspace:BulkMoveTo() for batch part movement".into(),
});
}
});
hits
}
}
impl Rule for TouchedWithoutDebounce {
fn id(&self) -> &'static str { "physics::touched_without_debounce" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
for pos in visit::find_pattern_positions(source, ".Touched:Connect(") {
let after_start = pos + ".Touched:Connect(".len();
let after_end = visit::ceil_char(source, (after_start + 300).min(source.len()));
let callback = &source[after_start..after_end];
let body_lines: Vec<&str> = callback.lines().take(8).collect();
let early_body = body_lines.join("\n");
let has_debounce = early_body.contains("debounce")
|| early_body.contains("cooldown")
|| early_body.contains("if not ")
|| early_body.contains("if db")
|| early_body.contains("tick()")
|| early_body.contains("os.clock()")
|| early_body.contains("task.wait");
if !has_debounce {
hits.push(Hit {
pos,
msg: ".Touched fires at ~240Hz per contact pair - add a debounce/cooldown check".into(),
});
}
}
hits
}
}
impl Rule for SetNetworkOwnerInLoop {
fn id(&self) -> &'static str { "physics::set_network_owner_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
visit::each_call(ast, |call, ctx| {
if ctx.in_loop && visit::is_method_call(call, "SetNetworkOwner") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":SetNetworkOwner() in loop - expensive network ownership change per iteration".into(),
});
}
});
hits
}
}
impl Rule for PreciseCollisionFidelity {
fn id(&self) -> &'static str { "physics::precise_collision_fidelity" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
for pos in visit::find_pattern_positions(source, "PreciseConvexDecomposition") {
hits.push(Hit {
pos,
msg: "PreciseConvexDecomposition is the most expensive collision fidelity - use Box, Hull, or Default when possible".into(),
});
}
hits
}
}
impl Rule for CollisionGroupStringInLoop {
fn id(&self) -> &'static str { "physics::collision_group_string_in_loop" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let loop_depth = build_loop_depth_map(source);
let line_starts = line_start_offsets(source);
for pos in visit::find_pattern_positions(source, ".CollisionGroup = ") {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line < loop_depth.len() && loop_depth[line] > 0 {
hits.push(Hit {
pos,
msg: ".CollisionGroup string assignment in loop - cache the string value outside".into(),
});
}
}
hits
}
}
impl Rule for AnchoredWithVelocity {
fn id(&self) -> &'static str { "physics::anchored_with_velocity" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
for pos in visit::find_pattern_positions(source, "Anchored = true") {
let context_start = visit::floor_char(source, pos.saturating_sub(200));
let context_end = visit::ceil_char(source, (pos + 200).min(source.len()));
let context = &source[context_start..context_end];
if context.contains("Velocity") || context.contains("AssemblyLinearVelocity")
|| context.contains("AssemblyAngularVelocity")
{
hits.push(Hit {
pos,
msg: "Anchored = true with velocity/force properties - anchored parts ignore physics forces".into(),
});
}
}
hits
}
}
impl Rule for RaycastParamsInLoop {
fn id(&self) -> &'static str { "physics::raycast_params_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
visit::each_call(ast, |call, ctx| {
if ctx.in_loop && visit::is_dot_call(call, "RaycastParams", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "RaycastParams.new() in loop - allocates new params each iteration, create once and reuse".into(),
});
}
});
hits
}
}
impl Rule for CFrameAssignInLoop {
fn id(&self) -> &'static str { "physics::cframe_assign_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let positions = visit::find_pattern_positions(source, ".CFrame =");
if positions.is_empty() {
return vec![];
}
let loop_depth = build_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
let mut hits = Vec::new();
for pos in positions {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line < loop_depth.len() && loop_depth[line] > 0 {
hits.push(Hit {
pos,
msg: ".CFrame assignment in loop - each triggers physics + replication, use workspace:BulkMoveTo() to batch".into(),
});
}
}
hits
}
}
impl Rule for CanTouchQueryNotDisabled {
fn id(&self) -> &'static str { "physics::can_touch_query_not_disabled" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
for pos in visit::find_pattern_positions(source, ".CanCollide = false") {
let context_end = (pos + 300).min(source.len());
let context = &source[pos..context_end];
let has_can_touch = context.contains(".CanTouch = false");
let has_can_query = context.contains(".CanQuery = false");
if !has_can_touch || !has_can_query {
hits.push(Hit {
pos,
msg: "CanCollide = false without CanTouch/CanQuery = false - physics engine still evaluates collision pairs for Touched events and spatial queries".into(),
});
}
}
hits
}
}
impl Rule for WeldConstraintInLoop {
fn id(&self) -> &'static str { "physics::weld_constraint_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let loop_depth = build_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
for pos in visit::find_pattern_positions(source, "\"WeldConstraint\"") {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line < loop_depth.len() && loop_depth[line] > 0 {
hits.push(Hit {
pos,
msg: "WeldConstraint creation in loop - each creates a physics constraint that the solver must evaluate, pre-create or use WeldConstraint pooling".into(),
});
}
}
hits
}
}
fn line_start_offsets(source: &str) -> Vec<usize> {
let mut starts = vec![0];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' { starts.push(i + 1); }
}
starts
}
fn build_hot_loop_depth_map(source: &str) -> Vec<u32> {
let mut depth: u32 = 0;
let mut depths = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("while ") || trimmed.starts_with("repeat") {
depth += 1;
} else if trimmed.starts_with("for ") && !trimmed.contains(" in ") {
depth += 1;
}
depths.push(depth);
if trimmed == "end" || trimmed.starts_with("end ") || trimmed.starts_with("until ") || trimmed == "until" {
depth = depth.saturating_sub(1);
}
}
depths
}
fn build_loop_depth_map(source: &str) -> Vec<u32> {
let mut depth: u32 = 0;
let mut depths = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("for ") || trimmed.starts_with("while ") || trimmed.starts_with("repeat") {
depth += 1;
}
depths.push(depth);
if trimmed == "end" || trimmed.starts_with("end ") || trimmed.starts_with("until ") || trimmed == "until" {
depth = depth.saturating_sub(1);
}
}
depths
}
impl Rule for MasslessNotSet {
fn id(&self) -> &'static str { "physics::massless_not_set" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
for pos in visit::find_pattern_positions(source, ".Massless = true") {
let line_start = source[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
let line = &source[line_start..source[pos..].find('\n').map(|p| pos + p).unwrap_or(source.len())];
let trimmed = line.trim();
if trimmed.contains(".Massless = true") {
let around = &source[pos.saturating_sub(200)..source.len().min(pos + 200)];
if !around.contains("Anchored") && around.contains("Weld") {
hits.push(Hit {
pos,
msg: "Massless only works on parts welded to a non-massless assembly root - verify the part is welded, otherwise Massless has no effect".into(),
});
}
}
}
hits
}
}
impl Rule for AssemblyVelocityInLoop {
fn id(&self) -> &'static str { "physics::assembly_velocity_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let loop_depth = build_loop_depth_map(source);
let line_starts = line_start_offsets(source);
let patterns = [".AssemblyLinearVelocity =", ".AssemblyAngularVelocity ="];
for pat in &patterns {
for pos in visit::find_pattern_positions(source, pat) {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line < loop_depth.len() && loop_depth[line] > 0 {
hits.push(Hit {
pos,
msg: "setting AssemblyVelocity in a loop crosses the Lua-C++ bridge per call and fights the physics solver - use constraints (LinearVelocity) instead".into(),
});
}
}
}
hits
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint::Rule;
fn parse(src: &str) -> full_moon::ast::Ast {
full_moon::parse(src).unwrap()
}
#[test]
fn spatial_query_in_loop_detected() {
let src = "for i = 1, 10 do\n workspace:Raycast(origin, dir)\nend";
let ast = parse(src);
let hits = SpatialQueryInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn spatial_query_outside_loop_ok() {
let src = "local result = workspace:Raycast(origin, dir)";
let ast = parse(src);
let hits = SpatialQueryInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn move_to_in_loop_detected() {
let src = "for _, part in parts do\n part:MoveTo(pos)\nend";
let ast = parse(src);
let hits = MoveToInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn move_to_outside_loop_ok() {
let src = "model:MoveTo(pos)";
let ast = parse(src);
let hits = MoveToInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn touched_without_debounce_detected() {
let src = "part.Touched:Connect(function(hit)\n hit:Destroy()\nend)";
let ast = parse(src);
let hits = TouchedWithoutDebounce.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn touched_with_debounce_ok() {
let src = "part.Touched:Connect(function(hit)\n if not debounce then\n debounce = true\n end\nend)";
let ast = parse(src);
let hits = TouchedWithoutDebounce.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn set_network_owner_in_loop_detected() {
let src = "for _, part in parts do\n part:SetNetworkOwner(player)\nend";
let ast = parse(src);
let hits = SetNetworkOwnerInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn set_network_owner_outside_loop_ok() {
let src = "part:SetNetworkOwner(player)";
let ast = parse(src);
let hits = SetNetworkOwnerInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn precise_collision_fidelity_detected() {
let src = "part.CollisionFidelity = Enum.CollisionFidelity.PreciseConvexDecomposition";
let ast = parse(src);
let hits = PreciseCollisionFidelity.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn collision_group_in_loop_detected() {
let src = "for _, part in parts do\n part.CollisionGroup = \"Players\"\nend";
let ast = parse(src);
let hits = CollisionGroupStringInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn collision_group_outside_loop_ok() {
let src = "part.CollisionGroup = \"Default\"";
let ast = parse(src);
let hits = CollisionGroupStringInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn anchored_with_velocity_detected() {
let src = "part.Anchored = true\npart.AssemblyLinearVelocity = Vector3.new(0, 10, 0)";
let ast = parse(src);
let hits = AnchoredWithVelocity.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn anchored_without_velocity_ok() {
let src = "part.Anchored = true\npart.Position = Vector3.new(0, 5, 0)";
let ast = parse(src);
let hits = AnchoredWithVelocity.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn raycast_params_in_loop_detected() {
let src = "for i = 1, 10 do\n local p = RaycastParams.new()\nend";
let ast = parse(src);
let hits = RaycastParamsInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn raycast_params_outside_loop_ok() {
let src = "local p = RaycastParams.new()";
let ast = parse(src);
let hits = RaycastParamsInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn cframe_assign_in_loop_detected() {
let src = "while true do\n part.CFrame = cf\nend";
let ast = parse(src);
let hits = CFrameAssignInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn cframe_assign_outside_loop_ok() {
let src = "part.CFrame = cf";
let ast = parse(src);
let hits = CFrameAssignInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn can_touch_query_not_disabled_detected() {
let src = "part.CanCollide = false\npart.Parent = workspace";
let ast = parse(src);
let hits = CanTouchQueryNotDisabled.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn can_touch_query_disabled_ok() {
let src = "part.CanCollide = false\npart.CanTouch = false\npart.CanQuery = false";
let ast = parse(src);
let hits = CanTouchQueryNotDisabled.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn weld_constraint_in_loop_detected() {
let src = "while true do\n local w = Instance.new(\"WeldConstraint\")\nend";
let ast = parse(src);
let hits = WeldConstraintInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn weld_constraint_outside_loop_ok() {
let src = "local w = Instance.new(\"WeldConstraint\")";
let ast = parse(src);
let hits = WeldConstraintInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn assembly_velocity_in_loop_detected() {
let src = "for _, part in parts do\n part.AssemblyLinearVelocity = Vector3.new(0, 10, 0)\nend";
let ast = parse(src);
let hits = AssemblyVelocityInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn assembly_velocity_outside_loop_ok() {
let src = "part.AssemblyLinearVelocity = Vector3.new(0, 10, 0)";
let ast = parse(src);
let hits = AssemblyVelocityInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
}