use crate::lint::{Hit, Rule, Severity};
use crate::visit;
pub struct StringConcatInLoop;
pub struct StringFormatInLoop;
pub struct ClosureInLoop;
pub struct RepeatedGsub;
pub struct TostringInLoop;
pub struct TableCreatePreferred;
pub struct ExcessiveStringSplit;
pub struct CoroutineWrapInLoop;
pub struct TableCreateForDict;
pub struct MutableUpvalueClosure;
pub struct UnpackInLoop;
pub struct RepeatedStringByte;
pub struct StringInterpInLoop;
pub struct SelectInLoop;
pub struct TableInsertKnownSize;
pub struct BufferOverStringPack;
pub struct TaskSpawnInLoop;
pub struct GsubFunctionInLoop;
pub struct TypeofInLoop;
pub struct SetmetatableInLoop;
impl Rule for ClosureInLoop {
fn id(&self) -> &'static str { "alloc::closure_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let func_positions = visit::find_pattern_positions(source, "function(");
if func_positions.is_empty() {
return vec![];
}
let loop_depth = build_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
func_positions
.into_iter()
.filter(|&pos| {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line >= loop_depth.len() || loop_depth[line] == 0 {
return false;
}
let line_start = line_starts[line];
let before_match = source[line_start..pos].trim();
if before_match.is_empty() {
return false;
}
let before_char = source[..pos].trim_end();
if before_char.ends_with('(') || before_char.ends_with(',') {
return false;
}
true
})
.map(|pos| Hit {
pos,
msg: "closure created in loop - allocates each iteration, extract outside loop".into(),
})
.collect()
}
}
impl Rule for StringConcatInLoop {
fn id(&self) -> &'static str { "alloc::string_concat_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let concat_positions = visit::find_pattern_positions(source, "..");
if concat_positions.is_empty() {
return vec![];
}
let loop_depth = build_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
concat_positions
.into_iter()
.filter(|&pos| {
if pos + 2 < source.len() && source.as_bytes()[pos + 2] == b'.' {
return false;
}
if pos > 0 && source.as_bytes()[pos - 1] == b'.' {
return false;
}
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
line < loop_depth.len() && loop_depth[line] > 0
})
.map(|pos| Hit {
pos,
msg: "string concatenation (..) in loop - use table.concat or buffer".into(),
})
.collect()
}
}
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
}
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 StringFormatInLoop {
fn id(&self) -> &'static str { "alloc::string_format_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, "string", "format") || visit::is_method_call(call, "format")) {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "string.format() in loop - allocates a new string each iteration".into(),
});
}
});
hits
}
}
impl Rule for RepeatedGsub {
fn id(&self) -> &'static str { "alloc::repeated_gsub" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let gsub_positions = visit::find_pattern_positions(source, ":gsub(");
if gsub_positions.len() < 2 {
return vec![];
}
let line_starts = line_start_offsets(source);
let mut hits = Vec::new();
let mut prev_line = usize::MAX;
for &pos in &gsub_positions {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if prev_line != usize::MAX && (line == prev_line || line == prev_line + 1) {
hits.push(Hit {
pos,
msg: "chained :gsub() calls - each allocates a new string, consider string.gsub with pattern alternation or buffer".into(),
});
}
prev_line = line;
}
hits
}
}
impl Rule for TostringInLoop {
fn id(&self) -> &'static str { "alloc::tostring_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_bare_call(call, "tostring") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "tostring() in loop - allocates a new string each call".into(),
});
}
});
hits
}
}
impl Rule for TableCreatePreferred {
fn id(&self) -> &'static str { "alloc::table_create_preferred" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let loop_depth = build_hot_loop_depth_map(source);
let line_starts = line_start_offsets(source);
let mut hits = Vec::new();
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 {
hits.push(Hit {
pos,
msg: "{} in loop - use table.create(n) with pre-allocated size if array size is known".into(),
});
}
}
hits
}
}
impl Rule for ExcessiveStringSplit {
fn id(&self) -> &'static str { "alloc::excessive_string_split" }
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, "string", "split") || visit::is_method_call(call, "split")) {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "string.split() in loop - allocates new table per call, split once outside loop".into(),
});
}
});
hits
}
}
impl Rule for CoroutineWrapInLoop {
fn id(&self) -> &'static str { "alloc::coroutine_wrap_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, "coroutine", "wrap") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "coroutine.wrap() in loop - ~200x slower than a closure, allocates coroutine per iteration".into(),
});
}
});
hits
}
}
impl Rule for TableCreateForDict {
fn id(&self) -> &'static str { "alloc::table_create_for_dict" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let create_positions = visit::find_pattern_positions(source, "table.create(");
if create_positions.is_empty() {
return vec![];
}
let mut hits = Vec::new();
for &pos in &create_positions {
let after_start = pos;
let after_end = visit::ceil_char(source, (after_start + 400).min(source.len()));
let after = &source[after_start..after_end];
let before_start = visit::floor_char(source, pos.saturating_sub(60));
let before = &source[before_start..pos];
let has_assignment = before.contains("= ") || before.contains("=\t");
if !has_assignment {
continue;
}
let mut string_key_count = 0;
for line in after.lines().skip(1).take(10) {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("--") {
continue;
}
if trimmed == "end" || trimmed.starts_with("return") {
break;
}
if (trimmed.contains('.') && trimmed.contains(" = "))
|| (trimmed.contains("[\"") && trimmed.contains("\"] = "))
{
string_key_count += 1;
}
}
if string_key_count >= 2 {
hits.push(Hit {
pos,
msg: "table.create(n) followed by string-key assignments - table.create only preallocates array part, useless for dicts".into(),
});
}
}
hits
}
}
impl Rule for MutableUpvalueClosure {
fn id(&self) -> &'static str { "alloc::mutable_upvalue_closure" }
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, "function(") {
let before_start = visit::floor_char(source, pos.saturating_sub(500));
let before = &source[before_start..pos];
let mut reassigned_locals: Vec<&str> = Vec::new();
for line in before.lines() {
let trimmed = line.trim();
if trimmed.starts_with("local ") || trimmed.starts_with("--") || trimmed.is_empty() {
continue;
}
if let Some(eq_pos) = trimmed.find(" = ") {
let lhs = trimmed[..eq_pos].trim();
if !lhs.is_empty()
&& !lhs.contains('.')
&& !lhs.contains('[')
&& !lhs.contains(':')
&& !lhs.contains('(')
&& lhs.chars().all(|c| c.is_alphanumeric() || c == '_')
{
let decl_pattern = format!("local {lhs}");
if before.contains(&decl_pattern) {
reassigned_locals.push(lhs);
}
}
}
}
if reassigned_locals.is_empty() {
continue;
}
let func_end = visit::ceil_char(source, (pos + 500).min(source.len()));
let func_body = &source[pos..func_end];
for local_name in &reassigned_locals {
if func_body.contains(local_name) {
hits.push(Hit {
pos,
msg: format!("closure captures reassigned local '{local_name}' - forces NEWCLOSURE (allocation) instead of DUPCLOSURE (free)"),
});
break;
}
}
}
hits
}
}
impl Rule for UnpackInLoop {
fn id(&self) -> &'static str { "alloc::unpack_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;
}
if visit::is_bare_call(call, "unpack") || visit::is_dot_call(call, "table", "unpack") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "unpack() in loop - creates temporary values on stack each iteration, cache results outside loop".into(),
});
}
});
hits
}
}
impl Rule for RepeatedStringByte {
fn id(&self) -> &'static str { "alloc::repeated_string_byte" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let byte_positions = visit::find_pattern_positions(source, "string.byte(");
if byte_positions.len() < 2 {
return vec![];
}
let loop_depth = build_loop_depth_map(source);
let line_starts = line_start_offsets(source);
let mut hits = Vec::new();
let mut loop_calls: Vec<(usize, String)> = Vec::new();
for &pos in &byte_positions {
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line >= loop_depth.len() || loop_depth[line] == 0 {
continue;
}
let after = &source[pos + "string.byte(".len()..];
if let Some(comma_or_close) = after.find(|c: char| c == ',' || c == ')') {
let first_arg = after[..comma_or_close].trim().to_string();
if !first_arg.is_empty() {
loop_calls.push((pos, first_arg));
}
}
}
let mut counts: std::collections::HashMap<&str, (usize, usize)> = std::collections::HashMap::new();
for (pos, arg) in &loop_calls {
let entry = counts.entry(arg.as_str()).or_insert((0, *pos));
entry.0 += 1;
}
for (arg, (count, pos)) in &counts {
if *count >= 3 {
hits.push(Hit {
pos: *pos,
msg: format!("string.byte({arg}, i) called {count}x in loop - use string.byte({arg}, 1, -1) once to get all bytes"),
});
}
}
hits
}
}
impl Rule for StringInterpInLoop {
fn id(&self) -> &'static str { "alloc::string_interp_in_loop" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let backtick_positions = visit::find_pattern_positions(source, "`");
if backtick_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();
let mut skip_until = 0usize;
for &pos in &backtick_positions {
if pos < skip_until {
continue;
}
let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
if line >= loop_depth.len() || loop_depth[line] == 0 {
continue;
}
let after_end = visit::ceil_char(source, (pos + 200).min(source.len()));
let after = &source[pos + 1..after_end];
if let Some(close) = after.find('`') {
let interp = &after[..close];
if interp.contains('{') {
hits.push(Hit {
pos,
msg: "string interpolation in loop - allocates a new string each iteration, same as concatenation".into(),
});
skip_until = pos + 1 + close + 1;
}
}
}
hits
}
}
impl Rule for SelectInLoop {
fn id(&self) -> &'static str { "alloc::select_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_bare_call(call, "select") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "select() in loop - O(n) per call on varargs, cache results outside loop".into(),
});
}
});
hits
}
}
impl Rule for TableInsertKnownSize {
fn id(&self) -> &'static str { "alloc::table_insert_known_size" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let lines: Vec<&str> = source.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with("for ") || !trimmed.contains(" = ") {
continue;
}
let has_empty_table = if i > 0 {
let prev = lines[i - 1].trim();
prev.contains("= {}") || prev.contains("= { }")
} else {
false
};
if !has_empty_table {
continue;
}
for j in (i + 1)..lines.len().min(i + 20) {
let inner = lines[j].trim();
if inner == "end" || inner.starts_with("end ") {
break;
}
if inner.contains("table.insert(") {
let byte_pos: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
hits.push(Hit {
pos: byte_pos,
msg: "table.insert in numeric for with known bounds - use table.create(n) + index assignment for preallocation".into(),
});
break;
}
}
}
hits
}
}
impl Rule for BufferOverStringPack {
fn id(&self) -> &'static str { "alloc::buffer_over_string_pack" }
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_dot_call(call, "string", "pack") || visit::is_dot_call(call, "string", "unpack")) {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "string.pack/unpack in loop allocates a string per call - use buffer library (buffer.writeu32/readu32) for zero-allocation binary I/O".into(),
});
}
});
hits
}
}
impl Rule for TaskSpawnInLoop {
fn id(&self) -> &'static str { "alloc::task_spawn_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; }
if visit::is_dot_call(call, "task", "spawn") || visit::is_dot_call(call, "task", "defer") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "task.spawn/defer in loop creates a new coroutine per iteration (~247x overhead vs direct call) - call the function directly if it doesn't need to yield".into(),
});
}
});
hits
}
}
impl Rule for GsubFunctionInLoop {
fn id(&self) -> &'static str { "alloc::gsub_function_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);
let patterns = [":gsub(", "string.gsub("];
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 {
let after = &source[pos + pat.len()..];
if let Some(comma1) = after.find(", ") {
let rest = &after[comma1 + 2..];
if rest.trim_start().starts_with("function") {
hits.push(Hit {
pos,
msg: "gsub with function replacement in loop - each call invokes the function per match + allocates closure, consider caching or using buffer".into(),
});
}
}
}
}
}
hits
}
}
impl Rule for TypeofInLoop {
fn id(&self) -> &'static str { "alloc::typeof_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, "typeof") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "typeof() in loop crosses the Lua-C++ bridge each call - cache outside if checking the same value: local t = typeof(obj)".into(),
});
}
});
hits
}
}
impl Rule for SetmetatableInLoop {
fn id(&self) -> &'static str { "alloc::setmetatable_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_bare_call(call, "setmetatable") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "setmetatable() in loop creates a new metatable-linked object per iteration - consider a constructor pattern or object pooling".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 string_concat_in_loop_detected() {
let src = "for i = 1, 10 do\n local s = a .. b\nend";
let ast = parse(src);
let hits = StringConcatInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn varargs_not_flagged_as_concat() {
let src = "for i = 1, 10 do\n local args = ...\nend";
let ast = parse(src);
let hits = StringConcatInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn string_concat_outside_loop_ok() {
let src = "local s = a .. b";
let ast = parse(src);
let hits = StringConcatInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn closure_in_loop_callback_ok() {
let src = "for i = 1, 10 do\n event:Connect(function() end)\nend";
let ast = parse(src);
let hits = ClosureInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn coroutine_wrap_in_loop_detected() {
let src = "for i = 1, 10 do\n local co = coroutine.wrap(fn)\nend";
let ast = parse(src);
let hits = CoroutineWrapInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn coroutine_wrap_outside_loop_ok() {
let src = "local co = coroutine.wrap(fn)";
let ast = parse(src);
let hits = CoroutineWrapInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn table_create_for_dict_detected() {
let src = "local t = table.create(10)\nt.name = \"foo\"\nt.value = 42";
let ast = parse(src);
let hits = TableCreateForDict.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn table_create_for_array_ok() {
let src = "local t = table.create(10)\nt[1] = \"foo\"\nt[2] = 42";
let ast = parse(src);
let hits = TableCreateForDict.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn mutable_upvalue_detected() {
let src = "local count = 0\ncount = count + 1\nlocal fn = function() return count end";
let ast = parse(src);
let hits = MutableUpvalueClosure.check(src, &ast);
assert_eq!(hits.len(), 1);
assert!(hits[0].msg.contains("NEWCLOSURE"));
}
#[test]
fn immutable_upvalue_ok() {
let src = "local count = 0\nlocal fn = function() return count end";
let ast = parse(src);
let hits = MutableUpvalueClosure.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn unpack_in_loop_detected() {
let src = "for i = 1, 10 do\n local a, b = unpack(t)\nend";
let ast = parse(src);
let hits = UnpackInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn unpack_outside_loop_ok() {
let src = "local a, b = unpack(t)";
let ast = parse(src);
let hits = UnpackInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn table_unpack_in_loop_detected() {
let src = "for i = 1, 10 do\n local a, b = table.unpack(t)\nend";
let ast = parse(src);
let hits = UnpackInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn string_interp_in_loop_detected() {
let src = "for i = 1, 10 do\n local s = `hello {name}`\nend";
let ast = parse(src);
let hits = StringInterpInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn string_interp_outside_loop_ok() {
let src = "local s = `hello {name}`";
let ast = parse(src);
let hits = StringInterpInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn backtick_no_interp_not_flagged() {
let src = "for i = 1, 10 do\n local s = `hello world`\nend";
let ast = parse(src);
let hits = StringInterpInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn select_in_loop_detected() {
let src = "for i = 1, n do\n local v = select(i, ...)\nend";
let ast = parse(src);
let hits = SelectInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn select_outside_loop_ok() {
let src = "local n = select(\"#\", ...)";
let ast = parse(src);
let hits = SelectInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn table_insert_known_size_detected() {
let src = "local t = {}\nfor i = 1, 100 do\n table.insert(t, i)\nend";
let ast = parse(src);
let hits = TableInsertKnownSize.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn table_insert_generic_loop_ok() {
let src = "for _, v in items do\n table.insert(t, v)\nend";
let ast = parse(src);
let hits = TableInsertKnownSize.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn buffer_over_string_pack_detected() {
let src = "for i = 1, 10 do\n local s = string.pack(\"I4\", i)\nend";
let ast = parse(src);
let hits = BufferOverStringPack.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn buffer_over_string_pack_outside_loop_ok() {
let src = "local s = string.pack(\"I4\", 42)";
let ast = parse(src);
let hits = BufferOverStringPack.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn task_spawn_in_loop_detected() {
let src = "for i = 1, 10 do\n task.spawn(doWork, i)\nend";
let ast = parse(src);
let hits = TaskSpawnInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn task_spawn_outside_loop_ok() {
let src = "task.spawn(doWork, 42)";
let ast = parse(src);
let hits = TaskSpawnInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn gsub_function_in_loop_detected() {
let src = "for i = 1, 10 do\n s:gsub(\"%w+\", function(w) return w:upper() end)\nend";
let ast = parse(src);
let hits = GsubFunctionInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn gsub_string_replacement_in_loop_ok() {
let src = "for i = 1, 10 do\n s:gsub(\"%w+\", \"X\")\nend";
let ast = parse(src);
let hits = GsubFunctionInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn typeof_in_loop_detected() {
let src = "for _, v in items do\n if typeof(v) == \"Instance\" then end\nend";
let ast = parse(src);
let hits = TypeofInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn typeof_outside_loop_ok() {
let src = "local t = typeof(obj)";
let ast = parse(src);
let hits = TypeofInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn setmetatable_in_loop_detected() {
let src = "for i = 1, 10 do\n local obj = setmetatable({}, MT)\nend";
let ast = parse(src);
let hits = SetmetatableInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn setmetatable_outside_loop_ok() {
let src = "local obj = setmetatable({}, MT)";
let ast = parse(src);
let hits = SetmetatableInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
}