use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
const INTENTIONALLY_UNSTYLED_CLASSES: &[(&str, &str)] = &[];
#[derive(Debug)]
struct Component {
name: String,
used: BTreeSet<String>,
declared: BTreeSet<String>,
mentioned: BTreeSet<String>,
}
fn bail(component: &str, detail: &str) -> ! {
panic!(
"template_scope_coverage: {} contains a construct this analyzer cannot \
classify exactly, so it would silently under-collect and go blind.\n\
Detail: {}\n\
Fix the analyzer in this file — do NOT relax it to a heuristic.",
component, detail
);
}
fn block<'a>(src: &'a str, name: &str) -> Option<&'a str> {
let open_pat = format!("<{name}");
let start = src.find(&open_pat)?;
let after_open = start + open_pat.len();
let gt_rel = src[after_open..].find('>')?;
let body_start = after_open + gt_rel + 1;
let close_pat = format!("</{name}>");
let close = src[body_start..].find(&close_pat)?;
Some(&src[body_start..body_start + close])
}
fn collect_used_classes(component: &str, template: &str) -> BTreeSet<String> {
let mut used = BTreeSet::new();
let bytes = template.as_bytes();
let mut i = 0usize;
while let Some(rel) = template[i..].find("class") {
let at = i + rel;
let after = at + "class".len();
let boundary_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
if !boundary_ok {
i = after;
continue;
}
let rest = &template[after..];
let Some(eq_rel) = rest.strip_prefix('=') else {
i = after;
continue;
};
let trimmed = eq_rel.trim_start();
let value_rel = 1 + (eq_rel.len() - trimmed.len());
let Some(first) = rest.as_bytes().get(value_rel).copied() else {
i = after;
continue;
};
if first != b'"' && first != b'\'' {
bail(
component,
&format!(
"unquoted `class=` value at template offset {at}; only quoted \
static class attributes are enumerable"
),
);
}
let quote = first;
let value_start = value_rel + 1;
let Some(end_rel) = rest[value_start..].find(quote as char) else {
bail(
component,
&format!("unterminated `class=` attribute at template offset {at}"),
);
};
let value = &rest[value_start..value_start + end_rel];
if value.contains("{{") {
i = after;
continue;
}
for token in value.split_whitespace() {
used.insert(token.to_string());
}
i = after + value_start + end_rel + 1;
}
used
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':'
}
fn strip_css_comments(css: &str) -> String {
fn skip_string(css: &str, i: usize) -> usize {
let bytes = css.as_bytes();
let quote = bytes[i];
let mut j = i + 1;
while j < bytes.len() {
match bytes[j] {
b'\\' => j += 2,
b if b == quote => return j + 1,
_ => j += 1,
}
}
bytes.len()
}
fn at_url_open(bytes: &[u8], i: usize) -> bool {
if !(bytes[i..].len() >= 4 && bytes[i..i + 4].eq_ignore_ascii_case(b"url(")) {
return false;
}
i > 0
&& (bytes[i - 1].is_ascii_alphanumeric()
|| bytes[i - 1] == b'-'
|| bytes[i - 1] == b'_')
}
fn skip_url(css: &str, i: usize) -> usize {
let bytes = css.as_bytes();
let mut j = i + 4;
while j < bytes.len() && (bytes[j] as char).is_whitespace() {
j += 1;
}
match bytes.get(j) {
Some(&b'"') | Some(&b'\'') => skip_string(css, j),
Some(&b')') => j + 1,
_ => {
while j < bytes.len() && bytes[j] != b')' {
j += 1;
}
(j + 1).min(bytes.len())
}
}
}
let mut out = String::with_capacity(css.len());
let mut run_start = 0usize;
let mut i = 0usize;
let bytes = css.as_bytes();
while i < bytes.len() {
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
out.push_str(&css[run_start..i]);
out.push(' ');
let end = css[i + 2..]
.find("*/")
.map(|e| i + 2 + e + 2)
.unwrap_or(bytes.len());
i = end;
run_start = i;
} else if bytes[i] == b'"' || bytes[i] == b'\'' {
i = skip_string(css, i);
} else if at_url_open(bytes, i) {
i = skip_url(css, i);
} else {
i += 1;
}
}
out.push_str(&css[run_start..]);
out
}
fn split_selector_list(list: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut current = String::new();
for ch in list.chars() {
match ch {
'(' | '[' => {
depth += 1;
current.push(ch);
}
')' | ']' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
out.push(std::mem::take(&mut current));
}
_ => current.push(ch),
}
}
out.push(current);
out.into_iter().map(|s| s.trim().to_string()).collect()
}
fn collect_rule_preludes(css: &str) -> Vec<String> {
fn walk(css: &str) -> Vec<String> {
let bytes = css.as_bytes();
let mut preludes = Vec::new();
let mut prelude = String::new();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'{' => {
let head = prelude.trim().to_string();
prelude.clear();
let mut depth = 1i32;
let mut j = i + 1;
while j < bytes.len() && depth > 0 {
match bytes[j] {
b'{' => depth += 1,
b'}' => depth -= 1,
_ => {}
}
j += 1;
}
let body = &css[i + 1..j.saturating_sub(1)];
if head.is_empty() {
preludes.push(String::new());
} else if head.starts_with('@') {
let is_statement = head
.split_whitespace()
.next()
.is_some_and(|kw| kw == "@import" || kw == "@charset");
if !is_statement {
preludes.extend(walk(body));
}
} else {
preludes.push(head);
}
i = j;
}
b'}' => {
prelude.clear();
i += 1;
}
_ => {
let ch = css[i..].chars().next().expect("i is a char boundary");
prelude.push(ch);
i += ch.len_utf8();
}
}
}
preludes
}
walk(css)
}
fn is_css_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'\\' || b >= 0x80
}
fn single_class_selector(selector: &str) -> Option<String> {
let b = selector.trim().as_bytes();
if b.first() != Some(&b'.') {
return None;
}
let mut i = 1usize;
let name_start = i;
while i < b.len() && is_css_ident_byte(b[i]) {
i += 1;
}
if i == name_start {
return None; }
let name = selector.trim()[name_start..i].to_string();
while i < b.len() {
if b[i] != b':' {
return None;
}
i += 1;
if i < b.len() && b[i] == b':' {
i += 1; }
let ident_start = i;
while i < b.len() && is_css_ident_byte(b[i]) {
i += 1;
}
if i == ident_start {
return None; }
if i < b.len() && b[i] == b'(' {
let mut depth = 0i32;
while i < b.len() {
match b[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
i += 1;
break;
}
}
_ => {}
}
i += 1;
}
if depth != 0 {
return None; }
}
}
Some(name)
}
#[test]
fn the_css_comment_stripper_is_string_aware_and_utf8_safe() {
let sheet = r#".a { content: "/*"; }
.b { color: red; }
.c { background: url(data:image/png;base64,/*AAA*/); }
"#;
let stripped = strip_css_comments(sheet);
assert!(
stripped.contains(".b { color: red; }"),
"a `/*` inside a quoted string was treated as a comment opener and \
truncated the sheet: {stripped:?}"
);
assert!(
stripped.contains(".c {"),
"a `/*` inside an unquoted url() was treated as a comment opener: \
{stripped:?}"
);
assert_eq!(
strip_css_comments("/* gone */ .keep { color: red; }"),
" .keep { color: red; }"
);
let unicode = ".glyph { content: \"☀ ☾ ✓ × —\"; } /* c */ .after { color: red; }";
let stripped = strip_css_comments(unicode);
assert!(
stripped.contains("☀ ☾ ✓ × —"),
"the stripper destroyed non-ASCII characters: {stripped:?}"
);
assert!(
stripped.contains(".after"),
"comment not removed: {stripped:?}"
);
assert_eq!(stripped, unicode.replace("/* c */", " "));
let dir = shipped_components_dir();
let mut checked = 0usize;
for entry in fs::read_dir(&dir).expect("read components dir") {
let path = entry.expect("entry").path();
if path.extension().is_none_or(|e| e != "vx") {
continue;
}
let src = fs::read_to_string(&path).expect("read .vx");
let style = block(&src, "style").expect("a <style> block");
let stripped = strip_css_comments(style);
assert!(
stripped.chars().count() <= style.chars().count(),
"{}: stripping comments INCREASED the character count ({} -> {}), so \
bytes were being cast to chars and multi-byte characters split",
path.display(),
style.chars().count(),
stripped.chars().count()
);
assert_eq!(
strip_css_comments(&stripped),
stripped,
"{}: stripping is not idempotent on its own output",
path.display()
);
checked += 1;
}
assert!(
checked >= 5,
"expected the five shipped components, saw {checked}"
);
}
fn class_tokens(selector: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut current = String::new();
let (mut reading_class, mut reading_pseudo) = (false, false);
for ch in selector.chars() {
if ch == '.' {
flush(&mut current, &mut out, &mut reading_pseudo);
reading_class = true;
continue;
}
if ch == ':' {
flush(&mut current, &mut out, &mut reading_pseudo);
reading_class = false;
reading_pseudo = true;
continue;
}
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '\\' {
if reading_class || reading_pseudo {
current.push(ch);
}
continue;
}
flush(&mut current, &mut out, &mut reading_pseudo);
reading_class = false;
}
flush(&mut current, &mut out, &mut reading_pseudo);
out
}
fn flush(current: &mut String, out: &mut BTreeSet<String>, reading_pseudo: &mut bool) {
if current.is_empty() {
return;
}
if !*reading_pseudo {
out.insert(std::mem::take(current));
} else {
current.clear();
}
}
fn analyze(name: &str, src: &str) -> Component {
let template =
block(src, "template").unwrap_or_else(|| panic!("{name}: no <template> block found"));
let style = block(src, "style").unwrap_or_else(|| panic!("{name}: no <style> block found"));
let used = collect_used_classes(name, template);
let css = strip_css_comments(style);
let mut declared = BTreeSet::new();
let mut mentioned = BTreeSet::new();
for prelude in collect_rule_preludes(&css) {
for selector in split_selector_list(&prelude) {
for token in class_tokens(&selector) {
mentioned.insert(token);
}
if let Some(class) = single_class_selector(&selector) {
declared.insert(class);
}
}
}
Component {
name: name.to_string(),
used,
declared,
mentioned,
}
}
fn analyze_dir(dir: &Path) -> Vec<Component> {
let mut paths: Vec<PathBuf> = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read template components dir {}: {e}", dir.display()))
.map(|e| e.expect("read_dir entry").path())
.filter(|p| p.extension().is_some_and(|e| e == "vx"))
.collect();
paths.sort();
assert!(
!paths.is_empty(),
"no .vx files found in {} — the template layout changed and this test \
would otherwise pass vacuously",
dir.display()
);
paths
.iter()
.map(|p| {
let name = p
.file_name()
.expect("file name")
.to_string_lossy()
.into_owned();
let src = fs::read_to_string(p).expect("read .vx source");
analyze(&name, &src)
})
.collect()
}
fn shipped_components_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates/project/src/components")
}
#[test]
fn direction_a_every_class_used_in_a_template_is_declared_in_that_component() {
let components = analyze_dir(&shipped_components_dir());
let mut declared_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for c in &components {
for class in &c.declared {
declared_by
.entry(class.as_str())
.or_default()
.push(c.name.as_str());
}
}
let mut violations: Vec<String> = Vec::new();
for c in &components {
for class in &c.used {
if c.declared.contains(class) || is_allowlisted(c.name.as_str(), class) {
continue;
}
let elsewhere = match declared_by.get(class.as_str()) {
Some(owners) if !owners.is_empty() => format!(
"\n but `{}` IS declared as a standalone rule in: {}.\n \
A `<style scoped>` rule only matches elements carrying its OWN \
component's data-v-* id (velox-sfc/src/template_codegen.rs:2741 \
`append_scope_attr`, velox-sfc/src/codegen.rs:524-550 \
`scope_single_selector`), and velox has no scope inheritance or \
`:deep()` escape hatch. So the rule is dead CSS and the element \
renders unstyled. Move the rule into {}.",
class,
owners.join(", "),
c.name
),
_ => String::new(),
};
let mentioned = if c.mentioned.contains(class) {
format!(
"\n note: `{}` IS mentioned in {}'s <style>, but only inside a \
compound/descendant selector, which this test deliberately does \
not evaluate. Verify by hand that it actually matches, or give \
the class its own standalone rule.",
class, c.name
)
} else {
String::new()
};
violations.push(format!(
" {} uses class `{}` in its <template> but declares no single-class \
rule for it in its own <style>.{}{}",
c.name, class, elsewhere, mentioned
));
}
}
assert!(
violations.is_empty(),
"Direction A violated — a component uses a class it does not itself style, \
so the element cannot be styled as intended ({} violation(s)):\n{}",
violations.len(),
violations.join("\n")
);
}
#[test]
fn direction_b_every_class_declared_in_a_component_is_used_in_that_component() {
let components = analyze_dir(&shipped_components_dir());
let mut violations: Vec<String> = Vec::new();
for c in &components {
for class in &c.declared {
if c.used.contains(class) || is_allowlisted(c.name.as_str(), class) {
continue;
}
let owners: Vec<&str> = components
.iter()
.filter(|other| other.name != c.name && other.used.contains(class))
.map(|other| other.name.as_str())
.collect();
let elsewhere = if owners.is_empty() {
"\n the class appears on NO element in any component's template."
} else {
"\n note: this class is used by another component's template, which \
cannot be styled from here."
};
violations.push(format!(
" {} declares class `{}` in its <style> but never uses it in its own \
<template>. The rule can never match.{}\n (used by: {})",
c.name,
class,
elsewhere,
if owners.is_empty() {
"—".to_string()
} else {
owners.join(", ")
}
));
}
}
assert!(
violations.is_empty(),
"Direction B violated — a component styles a class it never uses ({} \
violation(s)):\n{}",
violations.len(),
violations.join("\n")
);
}
#[test]
fn allowlist_entries_all_have_reasons() {
for (class, reason) in INTENTIONALLY_UNSTYLED_CLASSES {
assert!(
!reason.trim().is_empty(),
"allowlist entry `{class}` has no reason; every entry needs a one-line \
justification of why the class is intentionally unstyled"
);
}
}
#[test]
fn allowlist_entries_are_actually_needed() {
let components = analyze_dir(&shipped_components_dir());
let mut stale: Vec<String> = Vec::new();
for (class, _) in INTENTIONALLY_UNSTYLED_CLASSES {
let needed = components.iter().any(|c| {
(c.used.contains(*class) && !c.declared.contains(*class))
|| (c.declared.contains(*class) && !c.used.contains(*class))
});
if !needed {
stale.push(class.to_string());
}
}
assert!(
stale.is_empty(),
"stale allowlist entries — nothing in the template needs these \
exemptions any more, remove them: {stale:?}"
);
}
fn is_allowlisted(_component: &str, class: &str) -> bool {
INTENTIONALLY_UNSTYLED_CLASSES
.iter()
.any(|(c, _)| *c == class)
}
#[test]
fn detector_catches_a_child_styling_a_class_the_parent_owns() {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("scope-coverage-falsify");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create scratch dir");
fs::write(
dir.join("Child.vx"),
r#"<template>
<input class="input" />
</template>
<style scoped>
.input { flex: 1; }
.btn-add { background: #3478f6; }
</style>
"#,
)
.expect("write Child.vx");
fs::write(
dir.join("Parent.vx"),
r#"<template>
<button class="btn btn-add">Add</button>
</template>
<style scoped>
.btn { padding: 10px 20px; }
</style>
"#,
)
.expect("write Parent.vx");
let components = analyze_dir(&dir);
let child = components
.iter()
.find(|c| c.name == "Child.vx")
.expect("Child.vx analysed");
let parent = components
.iter()
.find(|c| c.name == "Parent.vx")
.expect("Parent.vx analysed");
assert!(
parent.used.contains("btn-add"),
"fixture sanity: Parent uses btn-add"
);
assert!(
!parent.declared.contains("btn-add"),
"fixture sanity: Parent declares no btn-add rule"
);
assert!(
child.declared.contains("btn-add"),
"fixture sanity: Child declares btn-add"
);
assert!(
!child.used.contains("btn-add"),
"fixture sanity: Child never uses btn-add"
);
let fixed = analyze(
"Parent.vx",
r#"<template>
<button class="btn btn-add">Add</button>
</template>
<style scoped>
.btn { padding: 10px 20px; }
.btn-add { background: #3478f6; }
</style>
"#,
);
assert!(
fixed.used.is_subset(&fixed.declared),
"a component that declares what it uses must satisfy direction A: used={:?} declared={:?}",
fixed.used,
fixed.declared
);
assert!(
fixed.declared.is_subset(&fixed.used),
"a component that uses what it declares must satisfy direction B: used={:?} declared={:?}",
fixed.used,
fixed.declared
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn single_class_selector_accepts_only_standalone_class_rules() {
for (sel, expected) in [
(".btn", "btn"),
(".btn-add", "btn-add"),
(".btn-add:hover", "btn-add"),
(".btn-add::before", "btn-add"),
(".a-1", "a-1"),
("._x", "_x"),
] {
assert_eq!(
single_class_selector(sel).as_deref(),
Some(expected),
"`{sel}` should classify as a single-class rule for `.{expected}`"
);
}
for sel in [
".todo-item.completed .todo-text",
".a .b",
".a > .b",
".a + .b",
".a ~ .b",
"button.a",
"*",
":root",
"html",
"body",
"div",
".a.b",
"[data-open].a",
"",
] {
assert_eq!(
single_class_selector(sel),
None,
"`{sel}` must NOT classify as a single-class rule"
);
}
}