luauperf 0.1.6

A static performance linter for Luau
use crate::lint::{Hit, Rule, Severity};
use crate::visit;

pub struct GuiCreationInLoop;
pub struct BeamTrailInLoop;
pub struct ParticleEmitterInLoop;
pub struct BillboardGuiInLoop;
pub struct TransparencyChangeInLoop;
pub struct RichTextInLoop;
pub struct NeonGlassMaterialInLoop;
pub struct SurfaceGuiInLoop;
pub struct ImageLabelInLoop;
pub struct ScrollingFrameInLoop;

impl Rule for GuiCreationInLoop {
    fn id(&self) -> &'static str { "render::gui_creation_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, "Instance", "new") {
                return;
            }
            let src = format!("{call}");
            let gui_classes = [
                "ScreenGui", "Frame", "TextLabel", "TextButton", "TextBox",
                "ImageLabel", "ImageButton", "ScrollingFrame", "ViewportFrame",
                "SurfaceGui", "CanvasGroup", "UIListLayout", "UIGridLayout",
                "UIPadding", "UICorner", "UIStroke",
            ];
            for class in &gui_classes {
                if src.contains(class) {
                    hits.push(Hit {
                        pos: visit::call_pos(call),
                        msg: format!("GUI instance ({class}) created in loop - pre-create or use Clone()"),
                    });
                    return;
                }
            }
        });
        hits
    }
}

impl Rule for BeamTrailInLoop {
    fn id(&self) -> &'static str { "render::beam_trail_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, "Instance", "new") {
                return;
            }
            let src = format!("{call}");
            if src.contains("Beam") || src.contains("Trail") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "Beam/Trail created in loop - pre-create and reuse".into(),
                });
            }
        });
        hits
    }
}

impl Rule for ParticleEmitterInLoop {
    fn id(&self) -> &'static str { "render::particle_emitter_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, "Instance", "new") {
                return;
            }
            let src = format!("{call}");
            if src.contains("ParticleEmitter") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "ParticleEmitter created in loop - pre-create and reuse via :Emit()".into(),
                });
            }
        });
        hits
    }
}

impl Rule for BillboardGuiInLoop {
    fn id(&self) -> &'static str { "render::billboard_gui_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, "Instance", "new") {
                return;
            }
            let src = format!("{call}");
            if src.contains("BillboardGui") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "BillboardGui created in loop - pre-create template and Clone()".into(),
                });
            }
        });
        hits
    }
}

impl Rule for TransparencyChangeInLoop {
    fn id(&self) -> &'static str { "render::transparency_change_in_loop" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let patterns = [
            ".Transparency =",
            ".BackgroundTransparency =",
            ".TextTransparency =",
            ".ImageTransparency =",
        ];

        let loop_depth = build_loop_depth_map(source);
        let line_starts = line_start_offsets(source);

        let mut hits = Vec::new();
        for pattern in &patterns {
            for pos in visit::find_pattern_positions(source, pattern) {
                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: format!("{pattern} in loop - consider TweenService or NumberSequence for smooth transitions"),
                    });
                }
            }
        }
        hits
    }
}

impl Rule for RichTextInLoop {
    fn id(&self) -> &'static str { "render::rich_text_in_loop" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let loop_depth = build_loop_depth_map(source);
        let line_starts = line_start_offsets(source);
        let rich_patterns = ["<font", "<b>", "<i>", "<u>", "<stroke", "<sc>", "</font>", "</b>"];
        for pattern in &rich_patterns {
            let mut start = 0;
            while let Some(idx) = source[start..].find(pattern) {
                let pos = start + idx;
                let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
                if line < loop_depth.len() && loop_depth[line] > 0 {
                    return vec![Hit {
                        pos,
                        msg: "rich text string building in loop - pre-build rich text outside the loop if content is static".into(),
                    }];
                }
                start = pos + 1;
            }
        }
        vec![]
    }
}

impl Rule for NeonGlassMaterialInLoop {
    fn id(&self) -> &'static str { "render::neon_glass_material_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 = ["Enum.Material.Neon", "Enum.Material.Glass"];
        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: format!("{} in loop - Neon/Glass have expensive rendering passes (glow/refraction), cache material outside loop", pat),
                    });
                }
            }
        }
        hits
    }
}

impl Rule for SurfaceGuiInLoop {
    fn id(&self) -> &'static str { "render::surface_gui_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);
        for pos in visit::find_pattern_positions(source, "\"SurfaceGui\"") {
            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: "SurfaceGui creation in loop allocates a 3D-to-2D rendering context per iteration - pre-create and use :Clone()".into(),
                });
            }
        }
        hits
    }
}

impl Rule for ImageLabelInLoop {
    fn id(&self) -> &'static str { "render::image_label_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);
        for pat in ["\"ImageLabel\"", "\"ImageButton\""] {
            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 {
                    let line_start = line_starts[line];
                    let line_text = &source[line_start..source[line_start..].find('\n').map(|p| line_start + p).unwrap_or(source.len())];
                    if line_text.contains("Instance.new") {
                        hits.push(Hit {
                            pos,
                            msg: "ImageLabel/ImageButton creation in loop - each one loads an image asset, pre-create a template and :Clone()".into(),
                        });
                    }
                }
            }
        }
        hits
    }
}

impl Rule for ScrollingFrameInLoop {
    fn id(&self) -> &'static str { "render::scrolling_frame_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);
        for pos in visit::find_pattern_positions(source, "\"ScrollingFrame\"") {
            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: "ScrollingFrame creation in loop - expensive layout computation per instance, pre-create and :Clone()".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 gui_creation_in_loop_detected() {
        let src = "for i = 1, 10 do\n  local f = Instance.new(\"Frame\")\nend";
        let ast = parse(src);
        let hits = GuiCreationInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn gui_creation_outside_loop_ok() {
        let src = "local f = Instance.new(\"Frame\")";
        let ast = parse(src);
        let hits = GuiCreationInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn non_gui_in_loop_not_flagged() {
        let src = "for i = 1, 10 do\n  local p = Instance.new(\"Part\")\nend";
        let ast = parse(src);
        let hits = GuiCreationInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn transparency_in_loop_detected() {
        let src = "for i = 1, 10 do\n  part.Transparency = i / 10\nend";
        let ast = parse(src);
        let hits = TransparencyChangeInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn transparency_outside_loop_ok() {
        let src = "part.Transparency = 0.5";
        let ast = parse(src);
        let hits = TransparencyChangeInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn rich_text_in_loop_detected() {
        let src = "for i = 1, 10 do\n  label.Text = \"<b>Hello</b>\"\nend";
        let ast = parse(src);
        let hits = RichTextInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn rich_text_outside_loop_ok() {
        let src = "label.Text = \"<b>Hello</b>\"";
        let ast = parse(src);
        let hits = RichTextInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn neon_material_in_loop_detected() {
        let src = "for _, part in parts do\n  part.Material = Enum.Material.Neon\nend";
        let ast = parse(src);
        let hits = NeonGlassMaterialInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn neon_material_outside_loop_ok() {
        let src = "part.Material = Enum.Material.Neon";
        let ast = parse(src);
        let hits = NeonGlassMaterialInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn surface_gui_in_loop_detected() {
        let src = "for i = 1, 10 do\n  local sg = Instance.new(\"SurfaceGui\")\nend";
        let ast = parse(src);
        let hits = SurfaceGuiInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn surface_gui_outside_loop_ok() {
        let src = "local sg = Instance.new(\"SurfaceGui\")";
        let ast = parse(src);
        let hits = SurfaceGuiInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn image_label_in_loop_detected() {
        let src = "for i = 1, 10 do\n  local img = Instance.new(\"ImageLabel\")\nend";
        let ast = parse(src);
        let hits = ImageLabelInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn image_label_outside_loop_ok() {
        let src = "local img = Instance.new(\"ImageLabel\")";
        let ast = parse(src);
        let hits = ImageLabelInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }
}

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_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
}