Skip to main content

squeeze/
color.rs

1use super::{Anchor, ByteSet, Finder, RunClass, RunRule};
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Color {}
6
7impl Color {
8    fn is_hex(b: u8) -> bool {
9        b.is_ascii_hexdigit()
10    }
11
12    fn try_hex_color(input: &[u8], pos: usize) -> Option<Range<usize>> {
13        if input[pos] != b'#' {
14            return None;
15        }
16
17        let start = pos;
18        let after_hash = pos + 1;
19        if after_hash >= input.len() || !Self::is_hex(input[after_hash]) {
20            return None;
21        }
22
23        let mut hex_end = after_hash;
24        while hex_end < input.len() && Self::is_hex(input[hex_end]) {
25            hex_end += 1;
26        }
27
28        let hex_len = hex_end - after_hash;
29
30        // Check boundary after: the hex run must not be glued to more
31        // identifier characters (`#deadline` is a word, not `#dead` + junk).
32        let has_boundary = hex_end >= input.len()
33            || (!input[hex_end].is_ascii_alphanumeric() && input[hex_end] != b'_');
34        if !has_boundary {
35            return None;
36        }
37
38        // Match longest valid: 8, 6, 4, 3
39        for &valid_len in &[8, 6, 4, 3] {
40            if hex_len == valid_len {
41                return Some(start..after_hash + valid_len);
42            }
43        }
44
45        None
46    }
47
48    fn try_css_function(input: &[u8], pos: usize) -> Option<Range<usize>> {
49        let remaining = &input[pos..];
50
51        let prefixes: &[&[u8]] = &[b"rgba(", b"rgb(", b"hsla(", b"hsl("];
52
53        let mut matched_len = 0;
54        for prefix in prefixes {
55            if remaining.len() >= prefix.len()
56                && remaining[..prefix.len()].eq_ignore_ascii_case(prefix)
57            {
58                matched_len = prefix.len();
59                break;
60            }
61        }
62
63        if matched_len == 0 {
64            return None;
65        }
66
67        // Check boundary before: not preceded by alphanumeric
68        if pos > 0 && input[pos - 1].is_ascii_alphanumeric() {
69            return None;
70        }
71
72        // Cap the closing-paren search: real CSS color functions are short,
73        // and unclosed `rgb(` candidates must not scan to end-of-line each.
74        const MAX_BODY_SEARCH: usize = 256;
75
76        let body_start = pos + matched_len; // first byte after '('
77        let limit = input.len().min(body_start + MAX_BODY_SEARCH);
78        let mut end = body_start;
79        let mut depth = 1;
80
81        while end < limit && depth > 0 {
82            match input[end] {
83                b'(' => depth += 1,
84                b')' => depth -= 1,
85                _ => {}
86            }
87            end += 1;
88        }
89
90        if depth == 0 { Some(pos..end) } else { None }
91    }
92}
93
94impl Finder for Color {
95    fn line_agnostic(&self) -> bool {
96        // Matches never contain a line terminator and `\n`/`\r` end every
97        // walk exactly like the end of the input does.
98        true
99    }
100
101    fn id(&self) -> &'static str {
102        "color"
103    }
104
105    fn dispatchable(&self) -> bool {
106        true
107    }
108
109    fn could_start_at(&self, byte: u8) -> bool {
110        byte == b'#' || matches!(byte, b'r' | b'R' | b'h' | b'H')
111    }
112
113    fn could_start_after(&self, prev: u8, cur: u8) -> bool {
114        cur == b'#' || !prev.is_ascii_alphanumeric()
115    }
116
117    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
118        match cur {
119            b'#' => Self::is_hex(next),
120            b'r' | b'R' => next.eq_ignore_ascii_case(&b'g'),
121            _ => next.eq_ignore_ascii_case(&b's'),
122        }
123    }
124
125    fn run_rules(&self) -> Vec<RunRule> {
126        // `rgb(`, `rgba(`, `hsl(`, `hsla(`; `#` starts are not word bytes.
127        vec![RunRule::new(RunClass::Word, 3, 4).followed_by(b"(")]
128    }
129
130    fn anchor(&self) -> Option<Anchor> {
131        Some(Anchor::new(
132            ByteSet::from_bytes(b"#("),
133            ByteSet::from_fn(|b| b.is_ascii_alphabetic()),
134        ))
135    }
136
137    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
138        if input[pos] == b'#'
139            && let Some(range) = Self::try_hex_color(input, pos)
140        {
141            return Some(range);
142        }
143        if matches!(input[pos], b'r' | b'R' | b'h' | b'H')
144            && let Some(range) = Self::try_css_function(input, pos)
145        {
146            return Some(range);
147        }
148        None
149    }
150
151    fn find(&self, s: &str) -> Option<Range<usize>> {
152        let input = s.as_bytes();
153        let mut idx = 0;
154
155        while idx < input.len() {
156            if input[idx] == b'#'
157                && let Some(range) = Self::try_hex_color(input, idx)
158            {
159                return Some(range);
160            }
161
162            if matches!(input[idx], b'r' | b'R' | b'h' | b'H')
163                && let Some(range) = Self::try_css_function(input, idx)
164            {
165                return Some(range);
166            }
167
168            idx += 1;
169        }
170
171        None
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn id_should_return_color() {
181        let finder = Color::default();
182        assert_eq!("color", finder.id());
183    }
184
185    // Hex colors
186    #[test]
187    fn find_should_extract_6_digit_hex() {
188        let finder = Color::default();
189        let input = "color: #ff00aa";
190        let range = finder.find(input).unwrap();
191        assert_eq!("#ff00aa", &input[range]);
192    }
193
194    #[test]
195    fn find_should_extract_3_digit_hex() {
196        let finder = Color::default();
197        let input = "color: #f0a";
198        let range = finder.find(input).unwrap();
199        assert_eq!("#f0a", &input[range]);
200    }
201
202    #[test]
203    fn find_should_extract_8_digit_hex() {
204        let finder = Color::default();
205        let input = "#ff00aa80 with alpha";
206        let range = finder.find(input).unwrap();
207        assert_eq!("#ff00aa80", &input[range]);
208    }
209
210    #[test]
211    fn find_should_extract_4_digit_hex() {
212        let finder = Color::default();
213        let input = "#f0a8 short alpha";
214        let range = finder.find(input).unwrap();
215        assert_eq!("#f0a8", &input[range]);
216    }
217
218    #[test]
219    fn find_should_extract_uppercase_hex() {
220        let finder = Color::default();
221        let input = "#FF00AA";
222        let range = finder.find(input).unwrap();
223        assert_eq!("#FF00AA", &input[range]);
224    }
225
226    #[test]
227    fn find_should_reject_5_digit_hex() {
228        let finder = Color::default();
229        assert!(finder.find("#ff00a").is_none());
230    }
231
232    #[test]
233    fn find_should_reject_1_digit_hex() {
234        let finder = Color::default();
235        assert!(finder.find("#f ").is_none());
236    }
237
238    #[test]
239    fn find_should_reject_hash_with_non_hex() {
240        let finder = Color::default();
241        assert!(finder.find("#gghhii").is_none());
242    }
243
244    // CSS functions
245    #[test]
246    fn find_should_extract_rgb() {
247        let finder = Color::default();
248        let input = "color: rgb(255, 0, 170)";
249        let range = finder.find(input).unwrap();
250        assert_eq!("rgb(255, 0, 170)", &input[range]);
251    }
252
253    #[test]
254    fn find_should_extract_rgba() {
255        let finder = Color::default();
256        let input = "rgba(255, 0, 170, 0.5)";
257        let range = finder.find(input).unwrap();
258        assert_eq!("rgba(255, 0, 170, 0.5)", &input[range]);
259    }
260
261    #[test]
262    fn find_should_extract_hsl() {
263        let finder = Color::default();
264        let input = "hsl(120, 100%, 50%)";
265        let range = finder.find(input).unwrap();
266        assert_eq!("hsl(120, 100%, 50%)", &input[range]);
267    }
268
269    #[test]
270    fn find_should_extract_hsla() {
271        let finder = Color::default();
272        let input = "hsla(120, 100%, 50%, 0.8)";
273        let range = finder.find(input).unwrap();
274        assert_eq!("hsla(120, 100%, 50%, 0.8)", &input[range]);
275    }
276
277    #[test]
278    fn find_should_not_match_rgb_preceded_by_alpha() {
279        let finder = Color::default();
280        assert!(finder.find("srgb(1, 2, 3)").is_none());
281    }
282
283    #[test]
284    fn find_should_reject_unclosed_rgb() {
285        let finder = Color::default();
286        assert!(finder.find("rgb(255, 0, 170").is_none());
287    }
288
289    // Multiple
290    #[test]
291    fn find_should_extract_multiple_colors_iteratively() {
292        let finder = Color::default();
293        let input = "#ff0000 and rgb(0, 255, 0)";
294
295        let mut results = Vec::new();
296        let mut idx = 0;
297        while idx < input.len() {
298            if let Some(range) = finder.find(&input[idx..]) {
299                results.push(&input[idx + range.start..idx + range.end]);
300                idx += range.end;
301            } else {
302                break;
303            }
304        }
305
306        assert_eq!(vec!["#ff0000", "rgb(0, 255, 0)"], results);
307    }
308
309    #[test]
310    fn find_should_handle_empty_input() {
311        let finder = Color::default();
312        assert!(finder.find("").is_none());
313    }
314
315    #[test]
316    fn find_should_extract_hex_in_css() {
317        let finder = Color::default();
318        let input = "background-color: #333;";
319        let range = finder.find(input).unwrap();
320        assert_eq!("#333", &input[range]);
321    }
322
323    #[test]
324    fn try_at_hex_color() {
325        let finder = Color::default();
326        let input = b"#ff00aa rest";
327        assert_eq!(finder.try_at(input, 0), Some(0..7));
328    }
329
330    #[test]
331    fn try_at_rgb_function() {
332        let finder = Color::default();
333        let input = b"rgb(255, 0, 0) rest";
334        assert_eq!(finder.try_at(input, 0), Some(0..14));
335    }
336
337    #[test]
338    fn try_at_hsl_function() {
339        let finder = Color::default();
340        let input = b"hsl(120, 100%, 50%) rest";
341        assert_eq!(finder.try_at(input, 0), Some(0..19));
342    }
343
344    #[test]
345    fn try_at_rejects_bare_hash() {
346        let finder = Color::default();
347        let input = b"# not a color";
348        assert!(finder.try_at(input, 0).is_none());
349    }
350
351    #[test]
352    fn try_at_rejects_too_many_hex() {
353        let finder = Color::default();
354        let input = b"#123456789";
355        assert!(finder.try_at(input, 0).is_none());
356    }
357
358    #[test]
359    fn find_css_case_insensitive() {
360        let finder = Color::default();
361        let input = "RGB(1, 2, 3)";
362        assert!(finder.find(input).is_some());
363        let input = "HSL(0, 0%, 0%)";
364        assert!(finder.find(input).is_some());
365    }
366
367    #[test]
368    fn find_hash_only() {
369        let finder = Color::default();
370        assert!(finder.find("#").is_none());
371    }
372
373    #[test]
374    fn find_hash_two_hex() {
375        let finder = Color::default();
376        assert!(finder.find("#ab").is_none());
377    }
378
379    #[test]
380    fn try_at_non_color_byte() {
381        let finder = Color::default();
382        let input = b"xyz";
383        assert!(finder.try_at(input, 0).is_none());
384    }
385}