use super::is_test_file;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
const MAX_MODULE_LEVEL_INDENT: usize = 4;
const SUBSCRIPTION_CONTEXT_WINDOW: usize = 50;
const INTERVAL_CONTEXT_WINDOW: usize = 30;
const EVENT_LISTENER_CONTEXT_WINDOW: usize = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryLintIssue {
pub file: String,
pub line: usize,
pub column: usize,
pub rule: String,
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MemoryLintRule {
ModuleCacheUnbounded,
SubscriptionLeak,
GlobalInterval,
GlobalEventListener,
}
impl MemoryLintRule {
pub fn as_str(&self) -> &'static str {
match self {
Self::ModuleCacheUnbounded => "mem/module-cache-unbounded",
Self::SubscriptionLeak => "mem/subscription-leak",
Self::GlobalInterval => "mem/global-interval",
Self::GlobalEventListener => "mem/global-event-listener",
}
}
pub fn severity(&self) -> &'static str {
match self {
Self::ModuleCacheUnbounded => "medium",
Self::SubscriptionLeak => "high",
Self::GlobalInterval => "high",
Self::GlobalEventListener => "medium",
}
}
pub fn message(&self) -> &'static str {
match self {
Self::ModuleCacheUnbounded => {
"Module-level Map/Set without size limit can grow unbounded"
}
Self::SubscriptionLeak => {
"Subscription created without corresponding unsubscribe - potential memory leak"
}
Self::GlobalInterval => "setInterval in non-React file without cleanup mechanism",
Self::GlobalEventListener => {
"addEventListener outside React lifecycle - ensure cleanup exists"
}
}
}
pub fn suggestion(&self) -> &'static str {
match self {
Self::ModuleCacheUnbounded => {
"Consider using LRU cache with max size, or implement eviction logic"
}
Self::SubscriptionLeak => {
"Store subscription and call .unsubscribe() when done, or use takeUntil pattern"
}
Self::GlobalInterval => "Store interval ID and call clearInterval() in cleanup logic",
Self::GlobalEventListener => {
"Ensure removeEventListener is called when listener is no longer needed"
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MemoryLintSummary {
pub total_issues: usize,
pub by_severity: HashMap<String, usize>,
pub by_rule: HashMap<String, usize>,
pub affected_files: usize,
}
static MODULE_CACHE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?:const|let|var)\s+\w+\s*=\s*new\s+(?:Map|Set)\s*\(\s*\)").unwrap()
});
static SUBSCRIBE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.subscribe\s*\(").unwrap());
static UNSUBSCRIBE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.unsubscribe\s*\(").unwrap());
static SET_INTERVAL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\bsetInterval\s*\(").unwrap());
static CLEAR_INTERVAL_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\bclearInterval\s*\(").unwrap());
static ADD_EVENT_LISTENER_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\.addEventListener\s*\(").unwrap());
static REMOVE_EVENT_LISTENER_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\.removeEventListener\s*\(").unwrap());
static USE_EFFECT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\buseEffect\s*\(").unwrap());
fn is_react_file(path: &str) -> bool {
path.ends_with(".tsx") || path.ends_with(".jsx")
}
fn is_service_worker(path: &str) -> bool {
let p = path.to_lowercase();
p.ends_with("sw.js")
|| p.ends_with("service-worker.js")
|| p.ends_with("serviceworker.js")
|| p.contains("/sw/")
|| p.contains("workbox")
}
fn has_use_effect(content: &str) -> bool {
USE_EFFECT_REGEX.is_match(content)
}
fn has_cache_limit_pattern(content: &str) -> bool {
let lower = content.to_lowercase();
lower.contains("lru")
|| lower.contains("maxsize")
|| lower.contains("max_size")
|| lower.contains("maxentries")
|| lower.contains("max_entries")
|| lower.contains(".delete(") || lower.contains(".clear(") }
pub fn lint_memory_file(path: &Path, content: &str) -> Vec<MemoryLintIssue> {
let mut issues = Vec::new();
let relative_path = path.to_string_lossy().to_string();
if is_test_file(&relative_path) {
return issues;
}
if is_service_worker(&relative_path) {
return issues;
}
let is_react = is_react_file(&relative_path);
let uses_effect = has_use_effect(content);
check_module_cache(content, &relative_path, &mut issues);
check_subscription_leaks(content, &relative_path, &mut issues);
if !is_react || !uses_effect {
check_global_intervals(content, &relative_path, &mut issues);
}
if !is_react {
check_global_event_listeners(content, &relative_path, &mut issues);
}
issues
}
fn check_module_cache(content: &str, file: &str, issues: &mut Vec<MemoryLintIssue>) {
if has_cache_limit_pattern(content) {
return;
}
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if indent > MAX_MODULE_LEVEL_INDENT {
continue;
}
if MODULE_CACHE_REGEX.is_match(line) {
let context_start = line_num.saturating_sub(2);
let context_end = (line_num + 3).min(content.lines().count());
let context: String = content
.lines()
.skip(context_start)
.take(context_end - context_start)
.collect::<Vec<_>>()
.join("\n");
if has_cache_limit_pattern(&context) {
continue;
}
let col = line.find("new").unwrap_or(0) + 1;
issues.push(MemoryLintIssue {
file: file.to_string(),
line: line_num + 1,
column: col,
rule: MemoryLintRule::ModuleCacheUnbounded.as_str().to_string(),
severity: MemoryLintRule::ModuleCacheUnbounded.severity().to_string(),
message: MemoryLintRule::ModuleCacheUnbounded.message().to_string(),
suggestion: Some(
MemoryLintRule::ModuleCacheUnbounded
.suggestion()
.to_string(),
),
});
}
}
}
fn has_zustand_unsubscribe_pattern(content: &str) -> bool {
let lower = content.to_lowercase();
(lower.contains("unsubscribe") || lower.contains("unsub"))
&& (lower.contains("unsubscribe()")
|| lower.contains("unsub()")
|| lower.contains("unsubscribe?.()"))
}
fn has_use_sync_external_store_pattern(content: &str) -> bool {
content.contains("useSyncExternalStore")
}
fn check_subscription_leaks(content: &str, file: &str, issues: &mut Vec<MemoryLintIssue>) {
if has_zustand_unsubscribe_pattern(content) {
return; }
if has_use_sync_external_store_pattern(content) {
return; }
let subscribe_count = SUBSCRIBE_REGEX.find_iter(content).count();
let unsubscribe_count = UNSUBSCRIBE_REGEX.find_iter(content).count();
if subscribe_count > unsubscribe_count {
let unmatched = subscribe_count - unsubscribe_count;
let mut found = 0;
for (line_num, line) in content.lines().enumerate() {
if found >= unmatched {
break;
}
if SUBSCRIBE_REGEX.is_match(line) {
if line.contains("unsubscribe") || line.contains("unsub") {
continue; }
let context_start = line_num.saturating_sub(5);
let context_end =
(line_num + SUBSCRIPTION_CONTEXT_WINDOW).min(content.lines().count());
let context: String = content
.lines()
.skip(context_start)
.take(context_end - context_start)
.collect::<Vec<_>>()
.join("\n");
if UNSUBSCRIBE_REGEX.is_match(&context)
|| context.contains("takeUntil")
|| context.contains("take(1)")
|| context.contains("first()")
{
continue;
}
let col = line.find(".subscribe").unwrap_or(0) + 1;
issues.push(MemoryLintIssue {
file: file.to_string(),
line: line_num + 1,
column: col,
rule: MemoryLintRule::SubscriptionLeak.as_str().to_string(),
severity: MemoryLintRule::SubscriptionLeak.severity().to_string(),
message: MemoryLintRule::SubscriptionLeak.message().to_string(),
suggestion: Some(MemoryLintRule::SubscriptionLeak.suggestion().to_string()),
});
found += 1;
}
}
}
}
fn check_global_intervals(content: &str, file: &str, issues: &mut Vec<MemoryLintIssue>) {
let interval_count = SET_INTERVAL_REGEX.find_iter(content).count();
let clear_count = CLEAR_INTERVAL_REGEX.find_iter(content).count();
if interval_count > clear_count {
for (line_num, line) in content.lines().enumerate() {
if SET_INTERVAL_REGEX.is_match(line) {
let context_start = line_num.saturating_sub(5);
let context_end = (line_num + INTERVAL_CONTEXT_WINDOW).min(content.lines().count());
let context: String = content
.lines()
.skip(context_start)
.take(context_end - context_start)
.collect::<Vec<_>>()
.join("\n");
if CLEAR_INTERVAL_REGEX.is_match(&context) {
continue;
}
let col = line.find("setInterval").unwrap_or(0) + 1;
issues.push(MemoryLintIssue {
file: file.to_string(),
line: line_num + 1,
column: col,
rule: MemoryLintRule::GlobalInterval.as_str().to_string(),
severity: MemoryLintRule::GlobalInterval.severity().to_string(),
message: MemoryLintRule::GlobalInterval.message().to_string(),
suggestion: Some(MemoryLintRule::GlobalInterval.suggestion().to_string()),
});
}
}
}
}
fn check_global_event_listeners(content: &str, file: &str, issues: &mut Vec<MemoryLintIssue>) {
let add_count = ADD_EVENT_LISTENER_REGEX.find_iter(content).count();
let remove_count = REMOVE_EVENT_LISTENER_REGEX.find_iter(content).count();
if add_count > remove_count {
for (line_num, line) in content.lines().enumerate() {
if ADD_EVENT_LISTENER_REGEX.is_match(line) {
let context_start = line_num.saturating_sub(5);
let context_end =
(line_num + EVENT_LISTENER_CONTEXT_WINDOW).min(content.lines().count());
let context: String = content
.lines()
.skip(context_start)
.take(context_end - context_start)
.collect::<Vec<_>>()
.join("\n");
if REMOVE_EVENT_LISTENER_REGEX.is_match(&context) {
continue;
}
let col = line.find(".addEventListener").unwrap_or(0) + 1;
issues.push(MemoryLintIssue {
file: file.to_string(),
line: line_num + 1,
column: col,
rule: MemoryLintRule::GlobalEventListener.as_str().to_string(),
severity: MemoryLintRule::GlobalEventListener.severity().to_string(),
message: MemoryLintRule::GlobalEventListener.message().to_string(),
suggestion: Some(MemoryLintRule::GlobalEventListener.suggestion().to_string()),
});
}
}
}
}
pub fn calculate_summary(issues: &[MemoryLintIssue]) -> MemoryLintSummary {
let mut by_severity: HashMap<String, usize> = HashMap::new();
let mut by_rule: HashMap<String, usize> = HashMap::new();
let mut files: std::collections::HashSet<&str> = std::collections::HashSet::new();
for issue in issues {
*by_severity.entry(issue.severity.clone()).or_insert(0) += 1;
*by_rule.entry(issue.rule.clone()).or_insert(0) += 1;
files.insert(&issue.file);
}
MemoryLintSummary {
total_issues: issues.len(),
by_severity,
by_rule,
affected_files: files.len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn lint(code: &str) -> Vec<MemoryLintIssue> {
lint_memory_file(&PathBuf::from("test.ts"), code)
}
fn lint_tsx(code: &str) -> Vec<MemoryLintIssue> {
lint_memory_file(&PathBuf::from("test.tsx"), code)
}
#[test]
fn test_module_cache_unbounded() {
let code = r#"
const cache = new Map();
export function getData(key: string) {
if (!cache.has(key)) {
cache.set(key, fetchData(key));
}
return cache.get(key);
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "mem/module-cache-unbounded");
}
#[test]
fn test_module_cache_with_lru_ok() {
let code = r#"
import { LRUCache } from 'lru-cache';
const cache = new Map(); // Has LRU in file
export function getData(key: string) {
return cache.get(key);
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_module_cache_with_delete_ok() {
let code = r#"
const cache = new Map();
export function getData(key: string) {
if (cache.size > 100) {
cache.delete(cache.keys().next().value);
}
return cache.get(key);
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_subscription_leak() {
let code = r#"
import { fromEvent } from 'rxjs';
export function setupListener() {
fromEvent(document, 'click').subscribe(event => {
console.log(event);
});
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "mem/subscription-leak");
}
#[test]
fn test_subscription_with_unsubscribe_ok() {
let code = r#"
import { fromEvent, Subscription } from 'rxjs';
let sub: Subscription;
export function setupListener() {
sub = fromEvent(document, 'click').subscribe(event => {
console.log(event);
});
}
export function cleanup() {
sub.unsubscribe();
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_subscription_with_take_until_ok() {
let code = r#"
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const destroy$ = new Subject();
export function setupListener() {
fromEvent(document, 'click')
.pipe(takeUntil(destroy$))
.subscribe(event => console.log(event));
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_global_interval_in_ts() {
let code = r#"
export function startPolling() {
setInterval(() => {
fetchData();
}, 5000);
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "mem/global-interval");
}
#[test]
fn test_global_interval_with_clear_ok() {
let code = r#"
let intervalId: number;
export function startPolling() {
intervalId = setInterval(() => {
fetchData();
}, 5000);
}
export function stopPolling() {
clearInterval(intervalId);
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_global_event_listener_in_ts() {
let code = r#"
export function init() {
window.addEventListener('resize', handleResize);
}
function handleResize() {
console.log('resized');
}
"#;
let issues = lint(code);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "mem/global-event-listener");
}
#[test]
fn test_skip_test_files() {
let code = r#"
const cache = new Map();
setInterval(() => {}, 1000);
"#;
let issues = lint_memory_file(&PathBuf::from("test.test.ts"), code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_tsx_with_use_effect_skips_interval() {
let code = r#"
import { useEffect } from 'react';
export function Component() {
useEffect(() => {
setInterval(() => tick(), 1000);
}, []);
return <div>Hello</div>;
}
"#;
let issues = lint_tsx(code);
assert!(issues.iter().all(|i| i.rule != "mem/global-interval"));
}
#[test]
fn test_summary_calculation() {
let issues = vec![
MemoryLintIssue {
file: "a.ts".to_string(),
line: 1,
column: 1,
rule: "mem/subscription-leak".to_string(),
severity: "high".to_string(),
message: "test".to_string(),
suggestion: None,
},
MemoryLintIssue {
file: "b.ts".to_string(),
line: 1,
column: 1,
rule: "mem/module-cache-unbounded".to_string(),
severity: "medium".to_string(),
message: "test".to_string(),
suggestion: None,
},
];
let summary = calculate_summary(&issues);
assert_eq!(summary.total_issues, 2);
assert_eq!(summary.affected_files, 2);
assert_eq!(summary.by_severity.get("high"), Some(&1));
assert_eq!(summary.by_severity.get("medium"), Some(&1));
}
#[test]
fn test_skip_service_worker() {
let code = r#"
self.addEventListener('install', handleInstall);
self.addEventListener('fetch', handleFetch);
self.addEventListener('activate', handleActivate);
"#;
let issues = lint_memory_file(&PathBuf::from("public/sw.js"), code);
assert_eq!(issues.len(), 0);
}
#[test]
fn test_zustand_style_unsubscribe_ok() {
let code = r#"
let unsubscribeStore: (() => void) | null = null;
export function setupSync() {
if (unsubscribeStore) {
unsubscribeStore();
unsubscribeStore = null;
}
unsubscribeStore = voiceStore.subscribe(() => {
syncState();
});
}
export function cleanup() {
if (unsubscribeStore) {
unsubscribeStore();
}
}
"#;
let issues = lint(code);
assert!(issues.iter().all(|i| i.rule != "mem/subscription-leak"));
}
#[test]
fn test_use_sync_external_store_ok() {
let code = r#"
import { useSyncExternalStore, useCallback } from 'react';
export const useProfileSnapshot = () => {
const store = useProfileStore();
const subscribe = useCallback(
(listener: () => void) => store.subscribe(listener),
[store]
);
return useSyncExternalStore(subscribe, store.getSnapshot, store.getSnapshot);
};
"#;
let issues = lint_tsx(code);
assert!(issues.iter().all(|i| i.rule != "mem/subscription-leak"));
}
}