1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CssDeclaration {
7 pub property: String,
8 pub value: String,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CssRule {
14 pub selector: String,
15 pub declarations: Vec<CssDeclaration>,
16}
17
18#[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#[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#[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#[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#[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#[must_use]
95pub fn normalize_css_property(input: &str) -> String {
96 input.trim().to_ascii_lowercase()
97}
98
99#[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#[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#[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#[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}