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 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 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 if pos > 0 && input[pos - 1].is_ascii_alphanumeric() {
69 return None;
70 }
71
72 const MAX_BODY_SEARCH: usize = 256;
75
76 let body_start = pos + matched_len; 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 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 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 #[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 #[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 #[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}