use memchr::memmem;
use crate::diagnostic::{LintDiagnostic, Severity};
use super::{ScriptLintResult, ScriptRule, ScriptRuleMeta};
static META: ScriptRuleMeta = ScriptRuleMeta {
name: "script/no-top-level-ref-in-script",
description: "Disallow top-level ref/reactive to prevent Cross-Request State Pollution",
default_severity: Severity::Error,
};
pub struct NoTopLevelRefInScript;
impl ScriptRule for NoTopLevelRefInScript {
fn meta(&self) -> &'static ScriptRuleMeta {
&META
}
fn check(&self, source: &str, offset: usize, result: &mut ScriptLintResult) {
let bytes = source.as_bytes();
let reactive_patterns = [
("ref(", "ref"),
("reactive(", "reactive"),
("computed(", "computed"),
("shallowRef(", "shallowRef"),
("shallowReactive(", "shallowReactive"),
];
for (pattern, name) in reactive_patterns {
self.check_top_level_usage(source, bytes, offset, pattern, name, result);
}
}
}
impl NoTopLevelRefInScript {
fn check_top_level_usage(
&self,
source: &str,
bytes: &[u8],
offset: usize,
pattern: &str,
_name: &str,
result: &mut ScriptLintResult,
) {
let finder = memmem::Finder::new(pattern.as_bytes());
let mut search_start = 0;
while let Some(pos) = finder.find(&bytes[search_start..]) {
let abs_pos = search_start + pos;
search_start = abs_pos + pattern.len();
if abs_pos > 0 {
let prev_char = bytes[abs_pos - 1];
if prev_char.is_ascii_alphanumeric() || prev_char == b'_' {
continue;
}
}
let before = &source[..abs_pos];
let mut brace_depth: i32 = 0;
let mut in_string = false;
let mut string_char = ' ';
for c in before.chars() {
if in_string {
if c == string_char && !before.ends_with('\\') {
in_string = false;
}
} else {
match c {
'"' | '\'' | '`' => {
in_string = true;
string_char = c;
}
'{' => brace_depth += 1,
'}' => brace_depth = brace_depth.saturating_sub(1),
_ => {}
}
}
}
if brace_depth == 0 {
let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
let line = before[line_start..].trim();
if line.starts_with("const ") || line.starts_with("let ") {
result.add_diagnostic(
LintDiagnostic::error(
META.name,
"Top-level reactive state in <script> can cause Cross-Request State Pollution in SSR",
(offset + abs_pos) as u32,
(offset + abs_pos + pattern.len()) as u32,
)
.with_help(
"Move reactive state inside setup() or use <script setup>. \
Top-level state is shared across requests in SSR.",
),
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::NoTopLevelRefInScript;
use crate::rules::script::ScriptLinter;
fn create_linter() -> ScriptLinter {
let mut linter = ScriptLinter::new();
linter.add_rule(Box::new(NoTopLevelRefInScript));
linter
}
#[test]
fn test_valid_inside_setup() {
let linter = create_linter();
let result = linter.lint(
r#"export default {
setup() {
const count = ref(0)
return { count }
}
}"#,
0,
);
assert_eq!(result.error_count, 0);
}
#[test]
fn test_valid_inside_function() {
let linter = create_linter();
let result = linter.lint(
r#"function createState() {
return reactive({ count: 0 })
}"#,
0,
);
assert_eq!(result.error_count, 0);
}
#[test]
fn test_invalid_top_level_ref() {
let linter = create_linter();
let result = linter.lint("const count = ref(0)", 0);
assert_eq!(result.error_count, 1);
insta::assert_debug_snapshot!(result.diagnostics);
}
#[test]
fn test_invalid_top_level_reactive() {
let linter = create_linter();
let result = linter.lint("const state = reactive({ count: 0 })", 0);
assert_eq!(result.error_count, 1);
}
#[test]
fn test_valid_const_string() {
let linter = create_linter();
let result = linter.lint("const API_URL = 'https://api.example.com'", 0);
assert_eq!(result.error_count, 0);
}
}