use crate::lint::{Hit, Rule, Severity};
use crate::visit;

pub struct ForeachDeprecated;
pub struct GetnDeprecated;
pub struct MaxnDeprecated;
pub struct FreezeInLoop;
pub struct InsertWithPosition;
pub struct RemoveInIpairs;
pub struct PackOverLiteral;
pub struct ManualCopyLoop;
pub struct DeferredFieldAssignment;
pub struct IpairsOverNumericFor;
pub struct PolymorphicConstructor;
pub struct SortComparisonAllocation;
pub struct ClearVsNew;
pub struct TableMoveOverLoop;
pub struct ConcatWithSeparatorLoop;
pub struct PairsOverGeneralized;
pub struct NilFieldInConstructor;
pub struct RawsetInLoop;
pub struct NextTNilOverPairs;

impl Rule for ForeachDeprecated {
    fn id(&self) -> &'static str { "table::foreach_deprecated" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if visit::is_dot_call(call, "table", "foreach") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.foreach() is deprecated - use for k, v in pairs(t)".into(),
                });
            }
            if visit::is_dot_call(call, "table", "foreachi") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.foreachi() is deprecated - use for i, v in ipairs(t)".into(),
                });
            }
        });
        hits
    }
}

impl Rule for GetnDeprecated {
    fn id(&self) -> &'static str { "table::getn_deprecated" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if visit::is_dot_call(call, "table", "getn") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.getn() is deprecated - use #t".into(),
                });
            }
        });
        hits
    }
}

impl Rule for MaxnDeprecated {
    fn id(&self) -> &'static str { "table::maxn_deprecated" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if visit::is_dot_call(call, "table", "maxn") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.maxn() is deprecated - use #t or track max index manually".into(),
                });
            }
        });
        hits
    }
}

impl Rule for FreezeInLoop {
    fn id(&self) -> &'static str { "table::freeze_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, "table", "freeze") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.freeze() in loop - freeze tables once at creation, not per-iteration".into(),
                });
            }
        });
        hits
    }
}

impl Rule for InsertWithPosition {
    fn id(&self) -> &'static str { "table::insert_with_position" }
    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 visit::is_dot_call(call, "table", "insert") && visit::call_arg_count(call) == 3 {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.insert(t, pos, v) is O(n) shift + no FASTCALL - use 2-arg append or restructure".into(),
                });
            }
        });
        hits
    }
}

impl Rule for RemoveInIpairs {
    fn id(&self) -> &'static str { "table::remove_in_ipairs" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let remove_positions = visit::find_pattern_positions(source, "table.remove(");
        if remove_positions.is_empty() {
            return vec![];
        }

        let mut hits = Vec::new();
        for pos in remove_positions {
            let context_start = visit::floor_char(source, pos.saturating_sub(300));
            let context = &source[context_start..pos];
            if context.contains("ipairs(") || context.contains("in pairs(") {
                let has_loop_keyword = context.contains("\nfor ") || context.starts_with("for ");
                if has_loop_keyword {
                    hits.push(Hit {
                        pos,
                        msg: "table.remove() during ipairs/pairs iteration - corrupts iteration order, iterate backwards or collect removals".into(),
                    });
                }
            }
        }
        hits
    }
}

impl Rule for PackOverLiteral {
    fn id(&self) -> &'static str { "table::pack_over_literal" }
    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 visit::is_dot_call(call, "table", "pack") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "table.pack(...) - use {...} instead (table constructor is significantly faster)".into(),
                });
            }
        });
        hits
    }
}

impl Rule for ManualCopyLoop {
    fn id(&self) -> &'static str { "table::manual_copy_loop" }
    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, "in pairs(") {
            let context_start = visit::floor_char(source, pos.saturating_sub(30));
            let before = &source[context_start..pos];
            if !before.contains("for ") {
                continue;
            }
            let after_end = visit::ceil_char(source, (pos + 200).min(source.len()));
            let after = &source[pos..after_end];
            if after.contains("] = ") && after.contains("end") {
                hits.push(Hit {
                    pos,
                    msg: "manual table copy loop - use table.clone() instead (single C call, no iteration overhead)".into(),
                });
            }
        }
        hits
    }
}

impl Rule for DeferredFieldAssignment {
    fn id(&self) -> &'static str { "table::deferred_field_assignment" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let empty_positions = visit::find_pattern_positions(source, "= {}");
        if empty_positions.is_empty() {
            return vec![];
        }

        let mut hits = Vec::new();
        for &pos in &empty_positions {
            let line_start = source[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
            let line_prefix = source[line_start..pos].trim();
            let var_name = if let Some(rest) = line_prefix.strip_prefix("local ") {
                rest.trim()
            } else {
                line_prefix
            };
            if var_name.is_empty() || var_name.contains(' ') || var_name.contains('.') {
                continue;
            }

            let after_start = pos + "= {}".len();
            let after_end = visit::ceil_char(source, (after_start + 300).min(source.len()));
            let after = &source[after_start..after_end];

            let field_pattern = format!("{var_name}.");
            let mut field_count = 0;
            for line in after.lines().take(10) {
                let trimmed = line.trim();
                if trimmed.is_empty() || trimmed.starts_with("--") {
                    continue;
                }
                if trimmed.starts_with(&field_pattern) && trimmed.contains(" = ") {
                    field_count += 1;
                } else if field_count > 0 {
                    break;
                }
            }

            if field_count >= 3 {
                hits.push(Hit {
                    pos,
                    msg: "empty {} then field assignments - use table literal {x = ..., y = ...} for table template optimization".into(),
                });
            }
        }
        hits
    }
}

impl Rule for IpairsOverNumericFor {
    fn id(&self) -> &'static str { "table::ipairs_over_numeric_for" }
    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, "for ") {
            let after_end = visit::ceil_char(source, (pos + 300).min(source.len()));
            let after = &source[pos..after_end];

            let trimmed = after.trim();
            if !trimmed.starts_with("for ") {
                continue;
            }
            let rest = &trimmed[4..];

            let eq_idx = match rest.find(" = ") {
                Some(i) => i,
                None => continue,
            };
            let iter_var = rest[..eq_idx].trim();
            if iter_var.is_empty() || !iter_var.chars().all(|c| c.is_alphanumeric() || c == '_') {
                continue;
            }

            let after_eq = &rest[eq_idx + 3..];
            if !after_eq.starts_with("1, #") && !after_eq.starts_with("1,#") {
                continue;
            }

            let hash_idx = after_eq.find('#').unwrap();
            let after_hash = &after_eq[hash_idx + 1..];
            let table_name_end = after_hash.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(after_hash.len());
            let table_name = &after_hash[..table_name_end];
            if table_name.is_empty() {
                continue;
            }

            let bracket_access = format!("{table_name}[{iter_var}]");
            if after.contains(&bracket_access) {
                hits.push(Hit {
                    pos,
                    msg: format!("for {iter_var} = 1, #{table_name} with {bracket_access} - use ipairs() for FORGPREP_INEXT fast-path (~2x faster)"),
                });
            }
        }
        hits
    }
}

impl Rule for PolymorphicConstructor {
    fn id(&self) -> &'static str { "table::polymorphic_constructor" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        use std::collections::HashSet;

        let mut hits = Vec::new();
        let constructor_positions = visit::find_pattern_positions(source, "= {");
        if constructor_positions.len() < 2 {
            return hits;
        }

        let mut constructors: Vec<(usize, HashSet<String>)> = Vec::new();
        for &pos in &constructor_positions {
            let after_start = pos + "= {".len();
            let after_end = visit::ceil_char(source, (after_start + 500).min(source.len()));
            let after = &source[after_start..after_end];

            let trimmed = after.trim_start();
            if trimmed.starts_with('}') {
                continue;
            }

            let mut keys = HashSet::new();
            let mut depth = 1i32;
            let mut end_idx = after.len();
            for (i, ch) in after.char_indices() {
                if ch == '{' { depth += 1; }
                if ch == '}' {
                    depth -= 1;
                    if depth <= 0 {
                        end_idx = i;
                        break;
                    }
                }
            }
            let content = &after[..end_idx];
            for segment in content.split(',') {
                let segment = segment.trim();
                if let Some(eq_pos) = segment.find(" = ") {
                    let key = segment[..eq_pos].trim();
                    if !key.is_empty()
                        && !key.starts_with('[')
                        && key.chars().all(|c| c.is_alphanumeric() || c == '_')
                    {
                        keys.insert(key.to_string());
                    }
                }
            }

            if keys.len() >= 2 {
                constructors.push((pos, keys));
            }
        }

        for i in 0..constructors.len() {
            for j in (i + 1)..constructors.len() {
                let (pos_a, keys_a) = &constructors[i];
                let (pos_b, keys_b) = &constructors[j];
                if pos_b - pos_a > 2000 {
                    break;
                }
                if keys_a != keys_b && !keys_a.is_disjoint(keys_b) && keys_a.len() >= 2 && keys_b.len() >= 2 {
                    hits.push(Hit {
                        pos: *pos_b,
                        msg: "table constructors with different key sets in same scope - defeats inline caching (~27% overhead), use consistent shapes".into(),
                    });
                    break; // Only flag once per pair
                }
            }
        }
        hits
    }
}

impl Rule for SortComparisonAllocation {
    fn id(&self) -> &'static str { "table::sort_comparison_allocation" }
    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, "table.sort(") {
            let after = &source[pos + "table.sort(".len()..];
            if after.contains("function(") {
                let paren_end = after.find("function(").unwrap_or(0);
                let between = &after[..paren_end];
                if between.contains(',') {
                    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: "table.sort with inline function in loop - allocates comparator each iteration, hoist the function".into(),
                        });
                    }
                }
            }
        }
        hits
    }
}

impl Rule for ClearVsNew {
    fn id(&self) -> &'static str { "table::clear_vs_new" }
    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, "= {}") {
            let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
            if line < loop_depth.len() && loop_depth[line] > 0 {
                let before = &source[source[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0)..pos];
                let trimmed = before.trim();
                if !trimmed.starts_with("local ") && !trimmed.is_empty() {
                    hits.push(Hit {
                        pos,
                        msg: "reassigning to {} in loop - use table.clear() to reuse the table allocation".into(),
                    });
                }
            }
        }
        hits
    }
}

impl Rule for TableMoveOverLoop {
    fn id(&self) -> &'static str { "table::move_over_loop" }
    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, "for ") {
            let after_end = visit::ceil_char(source, (pos + 200).min(source.len()));
            let after = &source[pos..after_end];
            let trimmed = after.trim();
            if !trimmed.starts_with("for ") {
                continue;
            }
            if let Some(eq_pos) = trimmed.find(" = ") {
                let rest = &trimmed[eq_pos + 3..];
                if rest.starts_with("1,") || rest.starts_with("1 ,") {
                    if after.contains("] = ") && after.contains("[") {
                        let assigns: Vec<&str> = after.lines()
                            .filter(|l| l.contains("] = ") && l.contains("["))
                            .collect();
                        if assigns.len() == 1 {
                            hits.push(Hit {
                                pos,
                                msg: "element-by-element array copy - use table.move(src, 1, #src, 1, dst) instead".into(),
                            });
                        }
                    }
                }
            }
        }
        hits
    }
}

impl Rule for ConcatWithSeparatorLoop {
    fn id(&self) -> &'static str { "table::concat_with_separator_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, " = ") {
            let line_idx = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
            if line_idx >= loop_depth.len() || loop_depth[line_idx] == 0 {
                continue;
            }
            let line_start = line_starts[line_idx];
            let line_end = source[pos..].find('\n').map(|p| pos + p).unwrap_or(source.len());
            let line = &source[line_start..line_end];
            let trimmed = line.trim();
            if trimmed.contains(" .. ") && trimmed.split(" .. ").count() >= 3 {
                let lhs = trimmed.split(" = ").next().unwrap_or("");
                let rhs = trimmed.split(" = ").nth(1).unwrap_or("");
                if rhs.contains(lhs.trim()) && rhs.contains(" .. ") {
                    hits.push(Hit {
                        pos: line_start,
                        msg: "string accumulation with .. in loop - use table.insert + table.concat for O(n) instead of O(n^2)".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_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 PairsOverGeneralized {
    fn id(&self) -> &'static str { "table::pairs_over_generalized" }
    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, "in pairs(") {
            hits.push(Hit {
                pos,
                msg: "pairs() is unnecessary - use generalized iteration: for k, v in t do (same bytecode, no function call)".into(),
            });
        }
        for pos in visit::find_pattern_positions(source, "in ipairs(") {
            hits.push(Hit {
                pos,
                msg: "ipairs() is unnecessary - use generalized iteration: for i, v in t do (same bytecode, no function call)".into(),
            });
        }
        hits
    }
}

impl Rule for NilFieldInConstructor {
    fn id(&self) -> &'static str { "table::nil_field_in_constructor" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        for (i, line) in source.lines().enumerate() {
            let trimmed = line.trim();
            if trimmed.contains("= nil") && (trimmed.contains("= nil,") || trimmed.contains("= nil;") || trimmed.ends_with("= nil")) {
                let before = &source[..source.lines().take(i).map(|l| l.len() + 1).sum::<usize>()];
                let open_braces = before.matches('{').count();
                let close_braces = before.matches('}').count();
                if open_braces > close_braces {
                    let byte_pos: usize = source.lines().take(i).map(|l| l.len() + 1).sum();
                    hits.push(Hit {
                        pos: byte_pos,
                        msg: "nil field in table constructor defeats Luau's table template optimization - omit nil fields (they default to nil)".into(),
                    });
                }
            }
        }
        hits
    }
}

impl Rule for RawsetInLoop {
    fn id(&self) -> &'static str { "table::rawset_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_loop && visit::is_bare_call(call, "rawset") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "rawset() in loop bypasses __newindex metamethod but is not a FASTCALL builtin - regular t[k] = v is faster when no metatable is set".into(),
                });
            }
        });
        hits
    }
}

impl Rule for NextTNilOverPairs {
    fn id(&self) -> &'static str { "table::next_t_nil_over_pairs" }
    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, "next(") {
            let after = &source[pos + 5..];
            if let Some(close) = after.find(')') {
                let args = &after[..close];
                if args.contains(", nil") || args.ends_with(",nil") || args.trim() == args.split(',').next().unwrap_or("").trim() {
                    if source[pos..].starts_with("next(") {
                        let before = source[..pos].trim_end();
                        if before.ends_with("==") || before.ends_with("~=") {
                            continue;
                        }
                        if args.contains(',') && (args.contains("nil") || args.trim().split(',').nth(1).map(|s| s.trim()) == Some("nil")) {
                            hits.push(Hit {
                                pos,
                                msg: "next(t, nil) == next(t) - omit the nil second argument for cleaner empty-table check".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 pack_detected() {
        let src = "local t = table.pack(a, b, c)";
        let ast = parse(src);
        let hits = PackOverLiteral.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn manual_copy_detected() {
        let src = "for k, v in pairs(src) do dst[k] = v end";
        let ast = parse(src);
        let hits = ManualCopyLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn non_copy_pairs_not_flagged() {
        let src = "for k, v in pairs(src) do print(v) end";
        let ast = parse(src);
        let hits = ManualCopyLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn deferred_field_assignment_detected() {
        let src = "local t = {}\nt.x = 1\nt.y = 2\nt.z = 3";
        let ast = parse(src);
        let hits = DeferredFieldAssignment.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn inline_constructor_not_flagged() {
        let src = "local t = {x = 1, y = 2, z = 3}";
        let ast = parse(src);
        let hits = DeferredFieldAssignment.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn ipairs_over_numeric_for_detected() {
        let src = "for i = 1, #items do\n  local item = items[i]\nend";
        let ast = parse(src);
        let hits = IpairsOverNumericFor.check(src, &ast);
        assert_eq!(hits.len(), 1);
        assert!(hits[0].msg.contains("FORGPREP_INEXT"));
    }

    #[test]
    fn ipairs_already_used_not_flagged() {
        let src = "for i, v in ipairs(items) do\n  print(v)\nend";
        let ast = parse(src);
        let hits = IpairsOverNumericFor.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn polymorphic_constructor_detected() {
        let src = "local a = {\n  name = \"x\",\n  health = 100,\n}\nlocal b = {\n  name = \"y\",\n  damage = 50,\n}";
        let ast = parse(src);
        let hits = PolymorphicConstructor.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn uniform_constructor_not_flagged() {
        let src = "local a = {\n  name = \"x\",\n  health = 100,\n}\nlocal b = {\n  name = \"y\",\n  health = 50,\n}";
        let ast = parse(src);
        let hits = PolymorphicConstructor.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn foreach_detected() {
        let src = "table.foreach(t, fn)";
        let ast = parse(src);
        let hits = ForeachDeprecated.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn pairs_loop_not_flagged_as_foreach() {
        let src = "for k, v in pairs(t) do fn(k, v) end";
        let ast = parse(src);
        let hits = ForeachDeprecated.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn insert_with_position_detected() {
        let src = "table.insert(t, 1, value)";
        let ast = parse(src);
        let hits = InsertWithPosition.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn insert_append_ok() {
        let src = "table.insert(t, value)";
        let ast = parse(src);
        let hits = InsertWithPosition.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn freeze_in_loop_detected() {
        let src = "for i = 1, 10 do\n  table.freeze(t)\nend";
        let ast = parse(src);
        let hits = FreezeInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn freeze_outside_loop_ok() {
        let src = "table.freeze(config)";
        let ast = parse(src);
        let hits = FreezeInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn sort_comparison_in_loop_detected() {
        let src = "for i = 1, 10 do\n  table.sort(t, function(a, b) return a < b end)\nend";
        let ast = parse(src);
        let hits = SortComparisonAllocation.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn sort_outside_loop_ok() {
        let src = "table.sort(t, function(a, b) return a < b end)";
        let ast = parse(src);
        let hits = SortComparisonAllocation.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

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

    #[test]
    fn local_new_table_in_loop_ok() {
        let src = "for i = 1, 10 do\n  local t = {}\nend";
        let ast = parse(src);
        let hits = ClearVsNew.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn concat_with_separator_loop_detected() {
        let src = "for _, v in items do\n  result = result .. \", \" .. v\nend";
        let ast = parse(src);
        let hits = ConcatWithSeparatorLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn concat_outside_loop_ok() {
        let src = "local s = a .. \", \" .. b";
        let ast = parse(src);
        let hits = ConcatWithSeparatorLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn pairs_over_generalized_detected() {
        let src = "for k, v in pairs(t) do end";
        let ast = parse(src);
        let hits = PairsOverGeneralized.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn generalized_iteration_ok() {
        let src = "for k, v in t do end";
        let ast = parse(src);
        let hits = PairsOverGeneralized.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn ipairs_detected() {
        let src = "for i, v in ipairs(t) do end";
        let ast = parse(src);
        let hits = PairsOverGeneralized.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn nil_field_in_constructor_detected() {
        let src = "local t = {\n  name = \"test\",\n  value = nil,\n}";
        let ast = parse(src);
        let hits = NilFieldInConstructor.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn no_nil_field_ok() {
        let src = "local t = {\n  name = \"test\",\n  value = 42,\n}";
        let ast = parse(src);
        let hits = NilFieldInConstructor.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn rawset_in_loop_detected() {
        let src = "for i = 1, 10 do\n  rawset(t, i, val)\nend";
        let ast = parse(src);
        let hits = RawsetInLoop.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn rawset_outside_loop_ok() {
        let src = "rawset(t, \"key\", val)";
        let ast = parse(src);
        let hits = RawsetInLoop.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }
}
