use crate::context::LintContext;
use crate::diagnostic::Severity;
use crate::rule::{Rule, RuleCategory, RuleMeta};
use vize_relief::ast::{ElementNode, ExpressionNode, InterpolationNode, RootNode};
use vize_relief::BindingType;
const BROWSER_GLOBALS: &[&str] = &[
"window",
"self",
"globalThis", "document",
"navigator",
"location",
"history",
"localStorage",
"sessionStorage",
"indexedDB",
"requestAnimationFrame",
"cancelAnimationFrame",
"requestIdleCallback",
"cancelIdleCallback",
"ResizeObserver",
"IntersectionObserver",
"MutationObserver",
"PerformanceObserver",
"HTMLElement",
"Element",
"Node",
"Event",
"CustomEvent",
"MouseEvent",
"KeyboardEvent",
"TouchEvent",
"DragEvent",
"Audio",
"Image",
"MediaRecorder",
"MediaSource",
"MediaStream",
"CanvasRenderingContext2D",
"WebGLRenderingContext",
"WebGL2RenderingContext",
"geolocation",
"screen",
"innerWidth",
"innerHeight",
"outerWidth",
"outerHeight",
"scrollX",
"scrollY",
"pageXOffset",
"pageYOffset",
"clipboard",
"speechSynthesis",
"SpeechRecognition",
"Notification",
"Worker",
"SharedWorker",
"ServiceWorker",
"alert",
"confirm",
"prompt",
"open",
"close",
"print",
"frames",
"parent",
"top",
"opener",
"CSS",
"CSSStyleSheet",
"getComputedStyle",
"matchMedia",
];
static META: RuleMeta = RuleMeta {
name: "ssr/no-browser-globals-in-ssr",
description: "Disallow browser-only globals in SSR context",
category: RuleCategory::Recommended,
fixable: false,
default_severity: Severity::Warning,
};
pub struct NoBrowserGlobalsInSsr;
impl NoBrowserGlobalsInSsr {
#[inline]
fn is_browser_global_static(name: &str) -> bool {
BROWSER_GLOBALS.contains(&name)
}
#[inline]
fn is_browser_global_binding(ctx: &LintContext<'_>, name: &str) -> bool {
if let Some(binding_type) = ctx.get_binding_type(name) {
matches!(binding_type, BindingType::JsGlobalBrowser)
} else {
Self::is_browser_global_static(name)
}
}
fn extract_identifiers(expr: &str) -> Vec<&str> {
let mut identifiers = Vec::new();
let bytes = expr.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut after_dot = false;
while i < len {
let b = bytes[i];
if b == b'\'' || b == b'"' || b == b'`' {
let quote = b;
i += 1;
while i < len {
if bytes[i] == b'\\' {
i += 2; continue;
}
if bytes[i] == quote {
i += 1;
break;
}
i += 1;
}
after_dot = false;
continue;
}
if b == b'.' {
after_dot = true;
i += 1;
continue;
}
if b.is_ascii_alphabetic() || b == b'_' || b == b'$' {
let start = i;
i += 1;
while i < len
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'$')
{
i += 1;
}
let ident = &expr[start..i];
if after_dot {
after_dot = false;
continue;
}
let mut j = i;
while j < len && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < len && bytes[j] == b':' && (j + 1 >= len || bytes[j + 1] != b':') {
after_dot = false;
continue;
}
identifiers.push(ident);
after_dot = false;
continue;
}
if b.is_ascii_digit() {
i += 1;
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'.') {
i += 1;
}
after_dot = false;
continue;
}
if !b.is_ascii_whitespace() {
after_dot = false;
}
i += 1;
}
identifiers
}
}
impl Rule for NoBrowserGlobalsInSsr {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn run_on_template<'a>(&self, _ctx: &mut LintContext<'a>, _root: &RootNode<'a>) {
}
fn check_interpolation<'a>(
&self,
ctx: &mut LintContext<'a>,
interpolation: &InterpolationNode<'a>,
) {
if !ctx.is_ssr_enabled() {
return;
}
let content = match &interpolation.content {
ExpressionNode::Simple(s) => s.content.as_str(),
ExpressionNode::Compound(_) => return, };
let identifiers = Self::extract_identifiers(content);
for ident in identifiers {
if ctx.is_variable_defined(ident) {
continue;
}
if Self::is_browser_global_binding(ctx, ident) || Self::is_browser_global_static(ident)
{
ctx.warn_with_help(
ctx.t_fmt("ssr/no-browser-globals-in-ssr.message", &[("name", ident)]),
&interpolation.loc,
ctx.t("ssr/no-browser-globals-in-ssr.help"),
);
}
}
}
fn check_directive<'a>(
&self,
ctx: &mut LintContext<'a>,
_element: &ElementNode<'a>,
directive: &vize_relief::ast::DirectiveNode<'a>,
) {
if !ctx.is_ssr_enabled() {
return;
}
if let Some(exp) = &directive.exp {
let content = match exp {
ExpressionNode::Simple(s) => s.content.as_str(),
ExpressionNode::Compound(_) => return, };
let identifiers = Self::extract_identifiers(content);
for ident in identifiers {
if ctx.is_variable_defined(ident) {
continue;
}
if Self::is_browser_global_binding(ctx, ident)
|| Self::is_browser_global_static(ident)
{
ctx.warn_with_help(
ctx.t_fmt("ssr/no-browser-globals-in-ssr.message", &[("name", ident)]),
&directive.loc,
ctx.t("ssr/no-browser-globals-in-ssr.help"),
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::NoBrowserGlobalsInSsr;
use crate::context::{LintContext, SsrMode};
use crate::rule::{Rule, RuleRegistry};
use crate::Linter;
fn lint_with_ssr(source: &str) -> Vec<String> {
let mut registry = RuleRegistry::new();
registry.add(Box::new(NoBrowserGlobalsInSsr));
let _linter = Linter::with_registry(registry);
use vize_carton::Allocator;
let allocator = Allocator::with_capacity(1024);
let mut ctx = LintContext::with_locale(
&allocator,
source,
"test.vue",
crate::Linter::default().locale(),
);
ctx.set_ssr_mode(SsrMode::Enabled);
let parser = vize_armature::Parser::new(allocator.as_bump(), source);
let (root, _) = parser.parse();
let rules: Vec<Box<dyn Rule>> = vec![Box::new(NoBrowserGlobalsInSsr)];
let mut visitor = crate::visitor::LintVisitor::new(&mut ctx, &rules);
visitor.visit_root(&root);
ctx.into_diagnostics()
.into_iter()
.map(|d| d.message.to_string())
.collect()
}
#[test]
fn test_detects_window_in_interpolation() {
let result = lint_with_ssr("<div>{{ window.innerWidth }}</div>");
insta::assert_debug_snapshot!(result);
}
#[test]
fn test_detects_document_in_interpolation() {
let result = lint_with_ssr("<div>{{ document.title }}</div>");
insta::assert_debug_snapshot!(result);
}
#[test]
fn test_detects_navigator_in_directive() {
let result = lint_with_ssr("<div :class=\"navigator.userAgent\"></div>");
insta::assert_debug_snapshot!(result);
}
#[test]
fn test_allows_local_variable() {
let result = lint_with_ssr("<div v-for=\"window in windows\">{{ window }}</div>");
insta::assert_debug_snapshot!(result);
}
#[test]
fn test_detects_localstorage() {
let result = lint_with_ssr("<div>{{ localStorage.getItem('key') }}</div>");
insta::assert_debug_snapshot!(result);
}
#[test]
fn test_ignores_css_property_names_in_style_object() {
let result =
lint_with_ssr(r#"<div :style="{ position: 'absolute', top: 0, left: 0 }"></div>"#);
assert!(
result.is_empty(),
"Should not flag CSS property names in style objects, got: {:?}",
result
);
}
#[test]
fn test_ignores_string_literal_values() {
let result = lint_with_ssr(r#"<div :class="'window'"></div>"#);
assert!(
result.is_empty(),
"Should not flag string literals, got: {:?}",
result
);
}
#[test]
fn test_ignores_property_access() {
let result = lint_with_ssr(r#"<div>{{ obj.top }}</div>"#);
assert!(
result.is_empty(),
"Should not flag property accesses, got: {:?}",
result
);
}
#[test]
fn test_detects_actual_global_in_style_value() {
let result = lint_with_ssr(r#"<div :style="{ top: window.scrollY + 'px' }"></div>"#);
insta::assert_debug_snapshot!(result);
}
}