Skip to main content

use_css/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A lightweight CSS declaration.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CssDeclaration {
7    pub property: String,
8    pub value: String,
9}
10
11/// A lightweight CSS rule.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CssRule {
14    pub selector: String,
15    pub declarations: Vec<CssDeclaration>,
16}
17
18/// Returns `true` when the input looks like CSS markup or a declaration.
19#[must_use]
20pub fn looks_like_css(input: &str) -> bool {
21    let trimmed = input.trim();
22    !trimmed.is_empty()
23        && ((trimmed.contains('{') && trimmed.contains('}'))
24            || split_css_declaration(trimmed).is_some())
25}
26
27/// Returns `true` when the input looks like a CSS identifier.
28#[must_use]
29pub fn is_css_identifier(input: &str) -> bool {
30    let trimmed = input.trim();
31    if trimmed.is_empty() || trimmed.starts_with("--") {
32        return false;
33    }
34
35    let mut characters = trimmed.chars();
36    let Some(first) = characters.next() else {
37        return false;
38    };
39    if !(first.is_ascii_alphabetic() || matches!(first, '_' | '-')) {
40        return false;
41    }
42
43    characters.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
44}
45
46/// Returns `true` when the input looks like a CSS custom property.
47#[must_use]
48pub fn is_css_custom_property(input: &str) -> bool {
49    let trimmed = input.trim();
50    trimmed.starts_with("--")
51        && trimmed.len() > 2
52        && trimmed[2..]
53            .chars()
54            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
55}
56
57/// Splits a `property: value` declaration.
58#[must_use]
59pub fn split_css_declaration(input: &str) -> Option<CssDeclaration> {
60    let trimmed = input.trim().trim_end_matches(';').trim();
61    let (property, value) = trimmed.split_once(':')?;
62    let property = normalize_css_property(property);
63    let value = value.trim();
64    if value.is_empty() || !(is_css_identifier(&property) || is_css_custom_property(&property)) {
65        return None;
66    }
67
68    Some(CssDeclaration {
69        property,
70        value: value.to_string(),
71    })
72}
73
74/// Extracts declarations from a CSS block or declaration list.
75#[must_use]
76pub fn extract_css_declarations(input: &str) -> Vec<CssDeclaration> {
77    let without_comments = strip_css_comments(input);
78    let declaration_source = if let Some(start) = without_comments.find('{') {
79        let end = without_comments
80            .rfind('}')
81            .unwrap_or(without_comments.len());
82        &without_comments[start + 1..end]
83    } else {
84        without_comments.as_str()
85    };
86
87    declaration_source
88        .split(';')
89        .filter_map(split_css_declaration)
90        .collect()
91}
92
93/// Normalizes a CSS property to lowercase ASCII.
94#[must_use]
95pub fn normalize_css_property(input: &str) -> String {
96    input.trim().to_ascii_lowercase()
97}
98
99/// Returns `true` when the input looks like a common CSS color value.
100#[must_use]
101pub fn is_css_color_value(input: &str) -> bool {
102    let trimmed = input.trim().to_ascii_lowercase();
103    if let Some(hex) = trimmed.strip_prefix('#') {
104        return matches!(hex.len(), 3 | 4 | 6 | 8)
105            && hex.chars().all(|character| character.is_ascii_hexdigit());
106    }
107
108    trimmed.starts_with("rgb(")
109        || trimmed.starts_with("rgba(")
110        || trimmed.starts_with("hsl(")
111        || trimmed.starts_with("hsla(")
112        || matches!(
113            trimmed.as_str(),
114            "black"
115                | "white"
116                | "red"
117                | "green"
118                | "blue"
119                | "gray"
120                | "grey"
121                | "transparent"
122                | "currentcolor"
123        )
124}
125
126/// Returns `true` when the input looks like a common CSS length value.
127#[must_use]
128pub fn is_css_length_value(input: &str) -> bool {
129    let trimmed = input.trim().to_ascii_lowercase();
130    if trimmed == "0" || trimmed == "0.0" {
131        return true;
132    }
133
134    let split_at = trimmed
135        .char_indices()
136        .find(|(_, character)| {
137            !(character.is_ascii_digit() || matches!(character, '.' | '+' | '-'))
138        })
139        .map_or(trimmed.len(), |(index, _)| index);
140    let (number, unit) = trimmed.split_at(split_at);
141    !number.is_empty()
142        && number.parse::<f64>().is_ok()
143        && matches!(
144            unit,
145            "px" | "rem"
146                | "em"
147                | "vw"
148                | "vh"
149                | "vmin"
150                | "vmax"
151                | "ch"
152                | "ex"
153                | "cm"
154                | "mm"
155                | "in"
156                | "pt"
157                | "pc"
158                | "%"
159        )
160}
161
162/// Removes CSS comments from the input.
163#[must_use]
164pub fn strip_css_comments(input: &str) -> String {
165    let mut result = String::new();
166    let mut remainder = input;
167
168    while let Some(start) = remainder.find("/*") {
169        result.push_str(&remainder[..start]);
170        let comment = &remainder[start + 2..];
171        if let Some(end) = comment.find("*/") {
172            remainder = &comment[end + 2..];
173        } else {
174            remainder = "";
175            break;
176        }
177    }
178
179    result.push_str(remainder);
180    result
181}
182
183/// Performs a small whitespace-oriented CSS minification pass.
184#[must_use]
185pub fn minify_css_basic(input: &str) -> String {
186    let without_comments = strip_css_comments(input);
187    let mut collapsed = String::new();
188    let mut previous_was_space = false;
189
190    for character in without_comments.chars() {
191        if character.is_whitespace() {
192            if !previous_was_space {
193                collapsed.push(' ');
194                previous_was_space = true;
195            }
196        } else {
197            collapsed.push(character);
198            previous_was_space = false;
199        }
200    }
201
202    let mut compact = collapsed.trim().to_string();
203    for (from, to) in [
204        (" {", "{"),
205        ("{ ", "{"),
206        (" }", "}"),
207        ("; ", ";"),
208        (": ", ":"),
209        (" :", ":"),
210        (", ", ","),
211        (" >", ">"),
212        ("> ", ">"),
213        (" +", "+"),
214        ("+ ", "+"),
215        (" ~", "~"),
216        ("~ ", "~"),
217    ] {
218        compact = compact.replace(from, to);
219    }
220
221    compact
222}