use crate::lint::{Hit, Rule, Severity};
use crate::visit;
pub struct MagnitudeOverSquared;
pub struct UncachedGetService;
pub struct TweenInfoInFunction;
pub struct RaycastParamsInFunction;
pub struct InstanceNewInLoop;
pub struct CFrameNewInLoop;
pub struct Vector3NewInLoop;
pub struct OverlapParamsInFunction;
pub struct NumberRangeInFunction;
pub struct NumberSequenceInFunction;
pub struct ColorSequenceInFunction;
pub struct TweenCreateInLoop;
pub struct GetAttributeInLoop;
pub struct Color3NewInLoop;
pub struct UDim2NewInLoop;
pub struct RepeatedMethodCall;
pub struct CurrentCameraUncached;
pub struct LocalPlayerUncached;
pub struct WorkspaceLookupInLoop;
pub struct RepeatedColor3;
pub struct EnumLookupInLoop;
pub struct BrickColorNewInLoop;
pub struct RegionNewInLoop;
impl Rule for MagnitudeOverSquared {
fn id(&self) -> &'static str { "cache::magnitude_over_squared" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
visit::find_pattern_positions(source, ".Magnitude")
.into_iter()
.filter(|&pos| {
let after_start = pos + ".Magnitude".len();
let after_end = visit::ceil_char(source, (after_start + 30).min(source.len()));
let after = source[after_start..after_end].trim_start();
after.starts_with('<') || after.starts_with('>')
|| after.starts_with("==") || after.starts_with("~=")
|| after.starts_with("then")
})
.map(|pos| Hit {
pos,
msg: ".Magnitude in comparison uses sqrt - compare squared distances with .Magnitude^2 or dot product".into(),
})
.collect()
}
}
impl Rule for UncachedGetService {
fn id(&self) -> &'static str { "cache::uncached_get_service" }
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_func && visit::is_method_call(call, "GetService") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":GetService() inside function body - cache at module level".into(),
});
}
});
hits
}
}
impl Rule for TweenInfoInFunction {
fn id(&self) -> &'static str { "cache::tween_info_in_function" }
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_func && visit::is_dot_call(call, "TweenInfo", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "TweenInfo.new() in function - cache as module-level constant".into(),
});
}
});
hits
}
}
impl Rule for RaycastParamsInFunction {
fn id(&self) -> &'static str { "cache::raycast_params_in_function" }
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_func && visit::is_dot_call(call, "RaycastParams", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "RaycastParams.new() in function - cache and reuse".into(),
});
}
});
hits
}
}
impl Rule for InstanceNewInLoop {
fn id(&self) -> &'static str { "cache::instance_new_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_hot_loop && visit::is_dot_call(call, "Instance", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "Instance.new() in loop - consider Clone() or pre-allocation".into(),
});
}
});
hits
}
}
impl Rule for CFrameNewInLoop {
fn id(&self) -> &'static str { "cache::cframe_new_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_hot_loop {
return;
}
let is_cframe = visit::is_dot_call(call, "CFrame", "new")
|| visit::is_dot_call(call, "CFrame", "lookAt")
|| visit::is_dot_call(call, "CFrame", "Angles")
|| visit::is_dot_call(call, "CFrame", "fromEulerAnglesXYZ")
|| visit::is_dot_call(call, "CFrame", "fromOrientation");
if is_cframe {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "CFrame constructor in loop - cache if arguments are loop-invariant".into(),
});
}
});
hits
}
}
impl Rule for Vector3NewInLoop {
fn id(&self) -> &'static str { "cache::vector3_new_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_hot_loop && visit::is_dot_call(call, "Vector3", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "Vector3.new() in loop - cache if arguments are loop-invariant".into(),
});
}
});
hits
}
}
impl Rule for OverlapParamsInFunction {
fn id(&self) -> &'static str { "cache::overlap_params_in_function" }
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_func && visit::is_dot_call(call, "OverlapParams", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "OverlapParams.new() in function - cache at module level and reuse".into(),
});
}
});
hits
}
}
impl Rule for NumberRangeInFunction {
fn id(&self) -> &'static str { "cache::number_range_in_function" }
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_func && visit::is_dot_call(call, "NumberRange", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "NumberRange.new() in function - cache as module-level constant".into(),
});
}
});
hits
}
}
impl Rule for NumberSequenceInFunction {
fn id(&self) -> &'static str { "cache::number_sequence_in_function" }
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_func && visit::is_dot_call(call, "NumberSequence", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "NumberSequence.new() in function - cache as module-level constant".into(),
});
}
});
hits
}
}
impl Rule for ColorSequenceInFunction {
fn id(&self) -> &'static str { "cache::color_sequence_in_function" }
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_func && visit::is_dot_call(call, "ColorSequence", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "ColorSequence.new() in function - cache as module-level constant".into(),
});
}
});
hits
}
}
impl Rule for TweenCreateInLoop {
fn id(&self) -> &'static str { "cache::tween_create_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_hot_loop && visit::is_method_call(call, "Create") {
let src = format!("{call}");
if src.contains("TweenService") || src.contains("tweenService") || src.contains("Tween") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "TweenService:Create() in loop - creates new tween object per iteration".into(),
});
}
}
});
hits
}
}
impl Rule for GetAttributeInLoop {
fn id(&self) -> &'static str { "cache::get_attribute_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_hot_loop && visit::is_method_call(call, "GetAttribute") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":GetAttribute() in loop - ~247ns bridge cost per call, cache outside loop".into(),
});
}
});
hits
}
}
impl Rule for Color3NewInLoop {
fn id(&self) -> &'static str { "cache::color3_new_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_hot_loop {
return;
}
let is_color3 = visit::is_dot_call(call, "Color3", "new")
|| visit::is_dot_call(call, "Color3", "fromRGB")
|| visit::is_dot_call(call, "Color3", "fromHSV")
|| visit::is_dot_call(call, "Color3", "fromHex");
if is_color3 {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "Color3 constructor in loop - cache if arguments are loop-invariant".into(),
});
}
});
hits
}
}
impl Rule for UDim2NewInLoop {
fn id(&self) -> &'static str { "cache::udim2_new_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();
visit::each_call(ast, |call, ctx| {
if !ctx.in_hot_loop {
return;
}
let is_udim2 = visit::is_dot_call(call, "UDim2", "new")
|| visit::is_dot_call(call, "UDim2", "fromScale")
|| visit::is_dot_call(call, "UDim2", "fromOffset");
if is_udim2 {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "UDim2 constructor in loop - cache if arguments are loop-invariant".into(),
});
}
});
hits
}
}
impl Rule for RepeatedMethodCall {
fn id(&self) -> &'static str { "cache::repeated_method_call" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let expensive_methods = [
":GetChildren()", ":GetDescendants()", ":GetAttributes()",
":GetTags()", ":GetBoundingBox()", ":GetPivot()",
];
let mut hits = Vec::new();
for method in &expensive_methods {
let positions = visit::find_pattern_positions(source, method);
if positions.len() < 2 {
continue;
}
let mut calls: Vec<(usize, String)> = Vec::new();
for &pos in &positions {
let before = &source[..pos];
let obj_end = before.len();
let obj_start = before.rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '.')
.map(|i| i + 1)
.unwrap_or(0);
let obj = &source[obj_start..obj_end];
if !obj.is_empty() && obj.chars().next().map(|c| c.is_alphabetic()).unwrap_or(false) {
calls.push((pos, obj.to_string()));
}
}
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for (pos, obj) in &calls {
if let Some(&first_pos) = seen.get(obj.as_str()) {
if pos - first_pos < 1000 {
let method_name = method.trim_start_matches(':').trim_end_matches("()");
hits.push(Hit {
pos: *pos,
msg: format!("duplicate {obj}:{method_name}() - cache in a local, each call creates a new table"),
});
}
} else {
seen.insert(obj, *pos);
}
}
}
hits
}
}
impl Rule for CurrentCameraUncached {
fn id(&self) -> &'static str { "cache::current_camera_uncached" }
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, "workspace.CurrentCamera");
if positions.len() < 2 {
return vec![];
}
positions[1..]
.iter()
.map(|&pos| Hit {
pos,
msg: "workspace.CurrentCamera accessed multiple times - cache in a local".into(),
})
.collect()
}
}
impl Rule for LocalPlayerUncached {
fn id(&self) -> &'static str { "cache::local_player_uncached" }
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, ".LocalPlayer");
let positions: Vec<_> = positions.into_iter().filter(|&p| {
let after = &source[p + ".LocalPlayer".len()..];
!after.starts_with("Uncached") && !after.starts_with("_")
}).collect();
if positions.len() < 2 {
return vec![];
}
positions[1..]
.iter()
.map(|&pos| Hit {
pos,
msg: "Players.LocalPlayer accessed multiple times - cache in a module-level local".into(),
})
.collect()
}
}
impl Rule for WorkspaceLookupInLoop {
fn id(&self) -> &'static str { "cache::workspace_lookup_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 tok = match visit::prefix_token(call) {
Some(t) => t,
None => return,
};
if visit::tok_text(tok) == "workspace" {
if visit::is_method_call(call, "FindFirstChild")
|| visit::is_method_call(call, "WaitForChild")
|| visit::is_method_call(call, "FindFirstChildOfClass")
|| visit::is_method_call(call, "FindFirstChildWhichIsA")
{
hits.push(Hit {
pos: visit::call_pos(call),
msg: "workspace lookup in loop - cache the result outside the loop".into(),
});
}
}
});
hits
}
}
impl Rule for RepeatedColor3 {
fn id(&self) -> &'static str { "cache::repeated_color3" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
let mut start = 0;
while let Some(idx) = source[start..].find("Color3.fromRGB(") {
let abs = start + idx;
let after = &source[abs + "Color3.fromRGB(".len()..];
if let Some(close) = after.find(')') {
let args = after[..close].to_string();
let entry = counts.entry(format!("Color3.fromRGB({})", args)).or_insert((0, abs));
entry.0 += 1;
}
start = abs + 1;
}
start = 0;
while let Some(idx) = source[start..].find("Color3.new(") {
let abs = start + idx;
let after = &source[abs + "Color3.new(".len()..];
if let Some(close) = after.find(')') {
let args = after[..close].to_string();
let entry = counts.entry(format!("Color3.new({})", args)).or_insert((0, abs));
entry.0 += 1;
}
start = abs + 1;
}
for (call, (count, pos)) in &counts {
if *count >= 4 {
hits.push(Hit {
pos: *pos,
msg: format!("{} repeated {} times - extract to a module-level constant", call, count),
});
}
}
hits
}
}
impl Rule for EnumLookupInLoop {
fn id(&self) -> &'static str { "cache::enum_lookup_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_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
let mut start = 0;
while let Some(idx) = source[start..].find("Enum.") {
let abs = start + idx;
let rest = &source[abs + 5..];
if rest.starts_with(|c: char| c.is_ascii_uppercase()) {
if let Some(dot2) = rest.find('.') {
let after_dot2 = &rest[dot2 + 1..];
if after_dot2.starts_with(|c: char| c.is_ascii_uppercase()) {
let line = line_starts.partition_point(|&s| s <= abs).saturating_sub(1);
if line < loop_depth.len() && loop_depth[line] > 0 {
let end = abs + 5 + dot2 + 1 + after_dot2.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(after_dot2.len());
let enum_val = &source[abs..end];
hits.push(Hit {
pos: abs,
msg: format!("{} in loop - cache enum value outside the loop", enum_val),
});
}
}
}
}
start = abs + 1;
}
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
}
impl Rule for BrickColorNewInLoop {
fn id(&self) -> &'static str { "cache::brick_color_new_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();
visit::each_call(ast, |call, ctx| {
if ctx.in_hot_loop && visit::is_dot_call(call, "BrickColor", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "BrickColor.new() in loop allocates each iteration - cache outside if arguments are loop-invariant".into(),
});
}
});
hits
}
}
impl Rule for RegionNewInLoop {
fn id(&self) -> &'static str { "cache::region_new_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_hot_loop && visit::is_dot_call(call, "Region3", "new") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "Region3.new() in loop allocates a Region3 each iteration - cache outside if bounds are loop-invariant".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 repeated_get_children_detected() {
let src = "local a = obj:GetChildren()\nfor _, v in obj:GetChildren() do end";
let ast = parse(src);
let hits = RepeatedMethodCall.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn single_get_children_ok() {
let src = "local children = obj:GetChildren()";
let ast = parse(src);
let hits = RepeatedMethodCall.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn different_objects_not_flagged() {
let src = "local a = obj1:GetChildren()\nlocal b = obj2:GetChildren()";
let ast = parse(src);
let hits = RepeatedMethodCall.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn current_camera_uncached_detected() {
let src = "local c1 = workspace.CurrentCamera\nlocal c2 = workspace.CurrentCamera";
let ast = parse(src);
let hits = CurrentCameraUncached.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn current_camera_single_ok() {
let src = "local cam = workspace.CurrentCamera";
let ast = parse(src);
let hits = CurrentCameraUncached.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn local_player_uncached_detected() {
let src = "local p = Players.LocalPlayer\nlocal n = Players.LocalPlayer.Name";
let ast = parse(src);
let hits = LocalPlayerUncached.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn local_player_single_ok() {
let src = "local player = Players.LocalPlayer";
let ast = parse(src);
let hits = LocalPlayerUncached.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn workspace_lookup_in_loop_detected() {
let src = "for i = 1, 10 do\n workspace:FindFirstChild(\"Part\")\nend";
let ast = parse(src);
let hits = WorkspaceLookupInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn workspace_lookup_outside_loop_ok() {
let src = "local p = workspace:FindFirstChild(\"Part\")";
let ast = parse(src);
let hits = WorkspaceLookupInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn repeated_color3_detected() {
let src = "local a = Color3.fromRGB(255, 0, 0)\nlocal b = Color3.fromRGB(255, 0, 0)\nlocal c = Color3.fromRGB(255, 0, 0)\nlocal d = Color3.fromRGB(255, 0, 0)";
let ast = parse(src);
let hits = RepeatedColor3.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn unique_color3_ok() {
let src = "local a = Color3.fromRGB(255, 0, 0)\nlocal b = Color3.fromRGB(0, 255, 0)";
let ast = parse(src);
let hits = RepeatedColor3.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn enum_lookup_in_loop_detected() {
let src = "while true do\n part.Material = Enum.Material.SmoothPlastic\nend";
let ast = parse(src);
let hits = EnumLookupInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn enum_lookup_outside_loop_ok() {
let src = "part.Material = Enum.Material.SmoothPlastic";
let ast = parse(src);
let hits = EnumLookupInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn brick_color_in_loop_detected() {
let src = "for i = 1, 10 do\n local bc = BrickColor.new(\"Really red\")\nend";
let ast = parse(src);
let hits = BrickColorNewInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn brick_color_outside_loop_ok() {
let src = "local bc = BrickColor.new(\"Really red\")";
let ast = parse(src);
let hits = BrickColorNewInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn region_new_in_loop_detected() {
let src = "for i = 1, 10 do\n local r = Region3.new(min, max)\nend";
let ast = parse(src);
let hits = RegionNewInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn region_new_outside_loop_ok() {
let src = "local r = Region3.new(min, max)";
let ast = parse(src);
let hits = RegionNewInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
}