use crate::lint::{Hit, Rule, Severity};
use crate::visit;
pub struct FireInLoop;
pub struct InvokeServerInLoop;
pub struct LargeRemoteData;
pub struct FireClientPerPlayer;
pub struct RemoteEventStringData;
pub struct DataStoreInLoop;
pub struct DictKeysInRemoteData;
pub struct UnreliableRemotePreferred;
pub struct InvokeClientDangerous;
pub struct HttpServiceInLoop;
pub struct MarketplaceInfoInLoop;
impl Rule for FireInLoop {
fn id(&self) -> &'static str { "network::fire_in_loop" }
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 !ctx.in_loop {
return;
}
let is_remote_fire = visit::is_method_call(call, "FireServer")
|| visit::is_method_call(call, "FireClient")
|| visit::is_method_call(call, "FireAllClients");
if is_remote_fire {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "remote event fired in loop - batch into a single call".into(),
});
}
});
hits
}
}
impl Rule for InvokeServerInLoop {
fn id(&self) -> &'static str { "network::invoke_server_in_loop" }
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 !ctx.in_loop {
return;
}
if visit::is_method_call(call, "InvokeServer") || visit::is_method_call(call, "InvokeClient") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "remote function invoked in loop - yields per iteration, batch into single call".into(),
});
}
});
hits
}
}
impl Rule for LargeRemoteData {
fn id(&self) -> &'static str { "network::large_remote_data" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let fire_methods = [":FireServer(", ":FireClient(", ":FireAllClients(", ":InvokeServer("];
let mut hits = Vec::new();
for method in &fire_methods {
for pos in visit::find_pattern_positions(source, method) {
let after_start = pos + method.len();
let after_end = visit::ceil_char(source, (after_start + 500).min(source.len()));
let args = &source[after_start..after_end];
let open_braces = args.chars().take_while(|&c| c != ')').filter(|&c| c == '{').count();
if open_braces >= 3 {
hits.push(Hit {
pos,
msg: "deeply nested table in remote call - large payloads cause network lag, flatten or compress data".into(),
});
}
}
}
hits
}
}
impl Rule for FireClientPerPlayer {
fn id(&self) -> &'static str { "network::fire_client_per_player" }
fn severity(&self) -> Severity { Severity::Warn }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let fire_positions = visit::find_pattern_positions(source, ":FireClient(");
for &pos in &fire_positions {
let context_start = visit::floor_char(source, pos.saturating_sub(200));
let context = &source[context_start..pos];
if context.contains("GetPlayers()") || context.contains("in pairs(") || context.contains("in ipairs(") {
let has_loop = context.contains("\nfor ") || context.trim_start().starts_with("for ");
if has_loop {
hits.push(Hit {
pos,
msg: ":FireClient() in loop over players - use :FireAllClients() to send a single message".into(),
});
}
}
}
hits
}
}
impl Rule for RemoteEventStringData {
fn id(&self) -> &'static str { "network::remote_event_string_data" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let fire_methods = [":FireServer(", ":FireClient(", ":FireAllClients("];
let mut hits = Vec::new();
for method in &fire_methods {
for pos in visit::find_pattern_positions(source, method) {
let after_start = pos + method.len();
let after_end = visit::ceil_char(source, (after_start + 200).min(source.len()));
let args = &source[after_start..after_end];
let close = args.find(')').unwrap_or(args.len());
let arg_str = &args[..close];
if arg_str.contains("tostring(") || arg_str.contains("string.format(") {
hits.push(Hit {
pos,
msg: "string conversion in remote fire args - consider sending raw values and formatting on the receiving end".into(),
});
}
}
}
hits
}
}
impl Rule for DataStoreInLoop {
fn id(&self) -> &'static str { "network::datastore_in_loop" }
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 !ctx.in_loop {
return;
}
let is_ds = visit::is_method_call(call, "GetAsync")
|| visit::is_method_call(call, "SetAsync")
|| visit::is_method_call(call, "UpdateAsync")
|| visit::is_method_call(call, "RemoveAsync")
|| visit::is_method_call(call, "IncrementAsync");
if is_ds {
let src = format!("{call}");
if src.contains("DataStore") || src.contains("dataStore") || src.contains("data_store") || src.contains("store") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: "DataStore operation in loop - yields per call with rate limits (60 + numPlayers*10/min), batch operations".into(),
});
}
}
});
hits
}
}
impl Rule for DictKeysInRemoteData {
fn id(&self) -> &'static str { "network::dict_keys_in_remote_data" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let patterns = [":FireServer({", ":FireClient(", ":FireAllClients({"];
for pat in &patterns {
for pos in visit::find_pattern_positions(source, pat) {
let open = if pat.ends_with('{') { pos + pat.len() - 1 } else {
let rest = &source[pos + pat.len()..];
if let Some(p) = rest.find('{') { pos + pat.len() + p } else { continue }
};
let after = &source[open..(open + 500).min(source.len())];
let has_dict_key = after.lines().next().map(|l| l.contains(" = ")).unwrap_or(false) || after[1..after.len().min(200)].contains(" = ");
if has_dict_key {
let callback_check = &source[pos.saturating_sub(200)..pos];
if callback_check.contains("Heartbeat:Connect") || callback_check.contains("RenderStepped:Connect") || callback_check.contains("Stepped:Connect") {
hits.push(Hit {
pos,
msg: "dictionary keys in high-frequency remote data - string keys add bytes per packet, use array-indexed tables for bandwidth savings".into(),
});
}
}
}
}
hits
}
}
impl Rule for UnreliableRemotePreferred {
fn id(&self) -> &'static str { "network::unreliable_remote_preferred" }
fn severity(&self) -> Severity { Severity::Allow }
fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
let mut hits = Vec::new();
let patterns = [":FireAllClients(", ":FireClient("];
for pat in &patterns {
for pos in visit::find_pattern_positions(source, pat) {
let before = &source[pos.saturating_sub(300)..pos];
let is_in_heartbeat = before.contains("Heartbeat:Connect") || before.contains("RenderStepped:Connect") || before.contains("Stepped:Connect");
if is_in_heartbeat {
let line_start = source[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line = &source[line_start..pos];
if !line.contains("unreliable") && !line.contains("Unreliable") {
hits.push(Hit {
pos,
msg: "reliable RemoteEvent in per-frame callback - use UnreliableRemoteEvent for high-frequency updates to avoid bandwidth throttling".into(),
});
}
}
}
}
hits
}
}
impl Rule for InvokeClientDangerous {
fn id(&self) -> &'static str { "network::invoke_client_dangerous" }
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_method_call(call, "InvokeClient") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":InvokeClient() yields the server thread until client responds - a malicious/lagging client can stall the server indefinitely, use FireClient instead".into(),
});
}
});
hits
}
}
impl Rule for HttpServiceInLoop {
fn id(&self) -> &'static str { "network::http_service_in_loop" }
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 !ctx.in_loop { return; }
let methods = ["GetAsync", "PostAsync", "RequestAsync", "JSONEncode", "JSONDecode"];
for m in &methods {
if visit::is_method_call(call, m) {
if *m == "JSONEncode" || *m == "JSONDecode" {
hits.push(Hit {
pos: visit::call_pos(call),
msg: format!(":{m}() in loop serializes/deserializes per iteration - cache results outside if data doesn't change"),
});
} else {
hits.push(Hit {
pos: visit::call_pos(call),
msg: format!(":{m}() in loop makes an HTTP request per iteration - batch requests or process asynchronously"),
});
}
return;
}
}
});
hits
}
}
impl Rule for MarketplaceInfoInLoop {
fn id(&self) -> &'static str { "network::marketplace_info_in_loop" }
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 ctx.in_loop && visit::is_method_call(call, "GetProductInfo") {
hits.push(Hit {
pos: visit::call_pos(call),
msg: ":GetProductInfo() in loop makes an HTTP request per iteration - cache results in a table".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 fire_in_loop_detected() {
let src = "for _, player in players do\n remote:FireClient(player, data)\nend";
let ast = parse(src);
let hits = FireInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn fire_outside_loop_ok() {
let src = "remote:FireServer(data)";
let ast = parse(src);
let hits = FireInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn invoke_in_loop_detected() {
let src = "for i = 1, 10 do\n remote:InvokeServer(i)\nend";
let ast = parse(src);
let hits = InvokeServerInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn invoke_outside_loop_ok() {
let src = "local result = remote:InvokeServer(data)";
let ast = parse(src);
let hits = InvokeServerInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn large_remote_data_detected() {
let src = "remote:FireServer({a = {b = {c = 1}}})";
let ast = parse(src);
let hits = LargeRemoteData.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn simple_remote_data_ok() {
let src = "remote:FireServer(\"hello\")";
let ast = parse(src);
let hits = LargeRemoteData.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn fire_client_per_player_detected() {
let src = "for _, player in pairs(Players:GetPlayers()) do\n remote:FireClient(player, data)\nend";
let ast = parse(src);
let hits = FireClientPerPlayer.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn fire_client_single_ok() {
let src = "remote:FireClient(player, data)";
let ast = parse(src);
let hits = FireClientPerPlayer.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn datastore_in_loop_detected() {
let src = "for _, key in keys do\n local data = dataStore:GetAsync(key)\nend";
let ast = parse(src);
let hits = DataStoreInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn datastore_outside_loop_ok() {
let src = "local data = dataStore:GetAsync(key)";
let ast = parse(src);
let hits = DataStoreInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn invoke_client_detected() {
let src = "remote:InvokeClient(player, data)";
let ast = parse(src);
let hits = InvokeClientDangerous.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn fire_client_ok() {
let src = "remote:FireClient(player, data)";
let ast = parse(src);
let hits = InvokeClientDangerous.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn http_service_in_loop_detected() {
let src = "for _, url in urls do\n local res = http:GetAsync(url)\nend";
let ast = parse(src);
let hits = HttpServiceInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn http_service_outside_loop_ok() {
let src = "local res = http:GetAsync(url)";
let ast = parse(src);
let hits = HttpServiceInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
#[test]
fn marketplace_in_loop_detected() {
let src = "for _, id in ids do\n local info = marketplace:GetProductInfo(id)\nend";
let ast = parse(src);
let hits = MarketplaceInfoInLoop.check(src, &ast);
assert_eq!(hits.len(), 1);
}
#[test]
fn marketplace_outside_loop_ok() {
let src = "local info = marketplace:GetProductInfo(id)";
let ast = parse(src);
let hits = MarketplaceInfoInLoop.check(src, &ast);
assert_eq!(hits.len(), 0);
}
}