mod no_display_none;
mod no_hardcoded_values;
mod no_id_selectors;
mod no_important;
mod no_utility_classes;
mod no_v_bind_performance;
mod prefer_logical_properties;
mod prefer_nested_selectors;
mod prefer_slotted;
mod require_font_display;
use std::collections::HashSet;
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
use memchr::memmem;
use crate::diagnostic::{LintDiagnostic, Severity};
pub use no_display_none::NoDisplayNone;
pub use no_hardcoded_values::NoHardcodedValues;
pub use no_id_selectors::NoIdSelectors;
pub use no_important::NoImportant;
pub use no_utility_classes::NoUtilityClasses;
pub use no_v_bind_performance::NoVBindPerformance;
pub use prefer_logical_properties::PreferLogicalProperties;
pub use prefer_nested_selectors::PreferNestedSelectors;
pub use prefer_slotted::PreferSlotted;
pub use require_font_display::RequireFontDisplay;
pub struct CssRuleMeta {
pub name: &'static str,
pub description: &'static str,
pub default_severity: Severity,
}
#[derive(Debug, Default)]
pub struct CssLintResult {
pub diagnostics: Vec<LintDiagnostic>,
pub error_count: usize,
pub warning_count: usize,
}
impl CssLintResult {
pub fn add_diagnostic(&mut self, diagnostic: LintDiagnostic) {
match diagnostic.severity {
Severity::Error => self.error_count += 1,
Severity::Warning => self.warning_count += 1,
}
self.diagnostics.push(diagnostic);
}
}
pub trait CssRule: Send + Sync {
fn meta(&self) -> &'static CssRuleMeta;
fn check<'i>(
&self,
source: &'i str,
stylesheet: &StyleSheet<'i, 'i>,
offset: usize,
result: &mut CssLintResult,
);
}
#[derive(Debug, Default)]
pub struct DisabledRules {
block_disabled: Vec<(usize, String, bool)>,
line_disabled: Vec<(usize, HashSet<String>)>,
next_line_disabled: Vec<(usize, HashSet<String>)>,
}
impl DisabledRules {
pub fn parse(source: &str) -> Self {
let mut result = Self::default();
let bytes = source.as_bytes();
let disable_finder = memmem::Finder::new(b"vize-disable ");
let enable_finder = memmem::Finder::new(b"vize-enable ");
let disable_line_finder = memmem::Finder::new(b"vize-disable-line ");
let disable_next_line_finder = memmem::Finder::new(b"vize-disable-next-line ");
let mut line_starts: Vec<usize> = vec![0];
for (i, &b) in bytes.iter().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
}
let get_line_number =
|pos: usize| -> usize { line_starts.partition_point(|&start| start <= pos) };
let mut search_start = 0;
while let Some(pos) = disable_finder.find(&bytes[search_start..]) {
let abs_pos = search_start + pos;
if Self::is_in_css_comment(bytes, abs_pos) {
let line = get_line_number(abs_pos);
let rule_name = Self::extract_rule_name(source, abs_pos + 13); if !rule_name.is_empty() {
result.block_disabled.push((line, rule_name, true));
}
}
search_start = abs_pos + 1;
}
search_start = 0;
while let Some(pos) = enable_finder.find(&bytes[search_start..]) {
let abs_pos = search_start + pos;
if Self::is_in_css_comment(bytes, abs_pos) {
let line = get_line_number(abs_pos);
let rule_name = Self::extract_rule_name(source, abs_pos + 12); if !rule_name.is_empty() {
result.block_disabled.push((line, rule_name, false));
}
}
search_start = abs_pos + 1;
}
search_start = 0;
while let Some(pos) = disable_line_finder.find(&bytes[search_start..]) {
let abs_pos = search_start + pos;
if Self::is_in_css_comment(bytes, abs_pos) {
let line = get_line_number(abs_pos);
let rule_name = Self::extract_rule_name(source, abs_pos + 18); if !rule_name.is_empty() {
if let Some((_, set)) =
result.line_disabled.iter_mut().find(|(l, _)| *l == line)
{
set.insert(rule_name);
} else {
let mut set = HashSet::new();
set.insert(rule_name);
result.line_disabled.push((line, set));
}
}
}
search_start = abs_pos + 1;
}
search_start = 0;
while let Some(pos) = disable_next_line_finder.find(&bytes[search_start..]) {
let abs_pos = search_start + pos;
if Self::is_in_css_comment(bytes, abs_pos) {
let line = get_line_number(abs_pos);
let rule_name = Self::extract_rule_name(source, abs_pos + 23); if !rule_name.is_empty() {
let next_line = line + 1;
if let Some((_, set)) = result
.next_line_disabled
.iter_mut()
.find(|(l, _)| *l == next_line)
{
set.insert(rule_name);
} else {
let mut set = HashSet::new();
set.insert(rule_name);
result.next_line_disabled.push((next_line, set));
}
}
}
search_start = abs_pos + 1;
}
result
.block_disabled
.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2).reverse()));
result
}
fn is_in_css_comment(bytes: &[u8], pos: usize) -> bool {
if pos < 2 {
return false;
}
let mut i = pos.saturating_sub(1);
loop {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
for j in (i + 2)..pos {
if j + 1 < bytes.len() && bytes[j] == b'*' && bytes[j + 1] == b'/' {
return false;
}
}
return true;
}
if i == 0 {
break;
}
i -= 1;
}
false
}
fn extract_rule_name(source: &str, start: usize) -> String {
let bytes = source.as_bytes();
if start >= bytes.len() {
return String::new();
}
let mut end = start;
while end < bytes.len() {
let b = bytes[end];
if b.is_ascii_alphanumeric() || b == b'-' || b == b'/' || b == b'_' {
end += 1;
} else {
break;
}
}
source[start..end].to_string()
}
pub fn is_disabled(&self, rule_name: &str, line: usize) -> bool {
if let Some((_, set)) = self.line_disabled.iter().find(|(l, _)| *l == line) {
if set.contains(rule_name) {
return true;
}
}
if let Some((_, set)) = self.next_line_disabled.iter().find(|(l, _)| *l == line) {
if set.contains(rule_name) {
return true;
}
}
let mut disabled = false;
for (block_line, name, is_disable) in &self.block_disabled {
if *block_line <= line && name == rule_name {
disabled = *is_disable;
}
}
disabled
}
}
pub fn strip_vize_comments(source: &str) -> String {
let mut result = String::with_capacity(source.len());
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
let comment_start = i;
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
if i + 1 < bytes.len() {
i += 2; }
let comment = &source[comment_start..i];
if !comment.contains("vize-disable")
&& !comment.contains("vize-enable")
&& !comment.contains("vize-disable-line")
&& !comment.contains("vize-disable-next-line")
{
result.push_str(comment);
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
pub struct CssLinter {
rules: Vec<Box<dyn CssRule>>,
}
impl CssLinter {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn with_all_rules() -> Self {
Self {
rules: vec![
Box::new(NoImportant),
Box::new(NoIdSelectors),
Box::new(PreferLogicalProperties),
Box::new(RequireFontDisplay),
Box::new(PreferNestedSelectors),
Box::new(NoDisplayNone),
Box::new(NoVBindPerformance),
Box::new(NoHardcodedValues::default()),
Box::new(NoUtilityClasses),
Box::new(PreferSlotted),
],
}
}
pub fn add_rule(&mut self, rule: Box<dyn CssRule>) {
self.rules.push(rule);
}
pub fn lint(&self, source: &str, offset: usize) -> CssLintResult {
self.lint_with_options(source, offset, true)
}
pub fn lint_with_options(
&self,
source: &str,
offset: usize,
respect_disable_comments: bool,
) -> CssLintResult {
let mut result = CssLintResult::default();
let disabled_rules = if respect_disable_comments {
Some(DisabledRules::parse(source))
} else {
None
};
let stylesheet = match StyleSheet::parse(source, ParserOptions::default()) {
Ok(ss) => ss,
Err(_) => {
return result;
}
};
for rule in &self.rules {
rule.check(source, &stylesheet, offset, &mut result);
}
if let Some(disabled) = disabled_rules {
let line_starts: Vec<usize> = std::iter::once(0)
.chain(source.bytes().enumerate().filter_map(|(i, b)| {
if b == b'\n' {
Some(i + 1)
} else {
None
}
}))
.collect();
let get_line =
|pos: u32| -> usize { line_starts.partition_point(|&start| start <= pos as usize) };
result.diagnostics.retain(|d| {
let line = get_line(d.start);
!disabled.is_disabled(d.rule_name, line)
});
result.error_count = result
.diagnostics
.iter()
.filter(|d| matches!(d.severity, Severity::Error))
.count();
result.warning_count = result
.diagnostics
.iter()
.filter(|d| matches!(d.severity, Severity::Warning))
.count();
}
result
}
}
impl Default for CssLinter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod disable_tests {
use super::*;
#[test]
fn test_parse_disable_comments() {
let source = "/* vize-disable css/no-important */\n.foo { color: red !important; }";
let disabled = DisabledRules::parse(source);
println!("block_disabled: {:?}", disabled.block_disabled);
assert!(
!disabled.block_disabled.is_empty(),
"block_disabled should not be empty"
);
assert!(
disabled.is_disabled("css/no-important", 2),
"css/no-important should be disabled on line 2"
);
}
#[test]
fn test_parse_next_line_comments() {
let source =
"/* vize-disable-next-line css/no-important */\n.foo { color: red !important; }";
let disabled = DisabledRules::parse(source);
println!("next_line_disabled: {:?}", disabled.next_line_disabled);
println!("block_disabled: {:?}", disabled.block_disabled);
println!("line_disabled: {:?}", disabled.line_disabled);
assert!(
!disabled.next_line_disabled.is_empty(),
"next_line_disabled should not be empty"
);
assert!(
disabled.is_disabled("css/no-important", 2),
"css/no-important should be disabled on line 2"
);
}
#[test]
fn test_disable_line() {
let linter = CssLinter::with_all_rules();
let source = ".foo { color: red !important; } /* vize-disable-line css/no-important */";
let result = linter.lint(source, 0);
assert!(!result
.diagnostics
.iter()
.any(|d| d.rule_name == "css/no-important"));
}
#[test]
fn test_disable_next_line() {
let source =
"/* vize-disable-next-line css/no-important */\n.foo { color: red !important; }";
let disabled = DisabledRules::parse(source);
println!("disabled: {:?}", disabled);
assert!(
!disabled.next_line_disabled.is_empty(),
"next_line_disabled should not be empty: {:?}",
disabled
);
let line_starts: Vec<usize> = std::iter::once(0)
.chain(source.bytes().enumerate().filter_map(|(i, b)| {
if b == b'\n' {
Some(i + 1)
} else {
None
}
}))
.collect();
println!("line_starts: {:?}", line_starts);
let get_line =
|pos: u32| -> usize { line_starts.partition_point(|&start| start <= pos as usize) };
let linter = CssLinter::with_all_rules();
let result = linter.lint(source, 0);
for d in &result.diagnostics {
let line = get_line(d.start);
println!(
"Diagnostic: {} at byte {} (line {}), disabled={}",
d.rule_name,
d.start,
line,
disabled.is_disabled(d.rule_name, line)
);
}
assert!(
!result
.diagnostics
.iter()
.any(|d| d.rule_name == "css/no-important"),
"Should not have css/no-important warning, got: {:?}",
result
.diagnostics
.iter()
.map(|d| format!(
"{} at byte {} (line {})",
d.rule_name,
d.start,
get_line(d.start)
))
.collect::<Vec<_>>()
);
}
#[test]
fn test_disable_block() {
let source = r#"/* vize-disable css/no-important */
.foo { color: red !important; }
.bar { color: blue !important; }
/* vize-enable css/no-important */
.baz { color: green !important; }"#;
let disabled = DisabledRules::parse(source);
assert!(
disabled.block_disabled.len() >= 2,
"block_disabled should have at least 2 entries (disable and enable): {:?}",
disabled.block_disabled
);
let linter = CssLinter::with_all_rules();
let result = linter.lint(source, 0);
let important_warnings: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.rule_name == "css/no-important")
.collect();
assert_eq!(
important_warnings.len(),
1,
"Expected 1 warning, got {}: {:?}",
important_warnings.len(),
important_warnings
.iter()
.map(|d| format!("{} at {}", d.rule_name, d.start))
.collect::<Vec<_>>()
);
}
#[test]
fn test_strip_vize_comments() {
let source = r#".foo { color: red; } /* vize-disable css/no-important */
.bar { color: blue !important; }
/* regular comment */
.baz { color: green; }"#;
let stripped = strip_vize_comments(source);
assert!(!stripped.contains("vize-disable"));
assert!(stripped.contains("regular comment"));
}
}