1use super::{ByteSet, Finder, RunClass, RunRule};
2use crate::word::{digit_at, digit_lead_bytes, space_at};
3use std::ops::Range;
4use std::sync::OnceLock;
5
6#[derive(Default)]
14pub struct Phone {}
15
16const MAX_GROUPS: usize = 7;
18
19impl Phone {
20 fn count_digits(s: &[u8]) -> usize {
21 s.iter().filter(|b| b.is_ascii_digit()).count()
22 }
23
24 #[inline]
26 fn separator_at(input: &[u8], pos: usize) -> Option<usize> {
27 match input.get(pos) {
28 Some(b'.' | b'-') => Some(1),
29 _ => space_at(input, pos),
30 }
31 }
32
33 fn digit_ends(input: &[u8], pos: usize, max: usize) -> Vec<usize> {
35 let mut ends = Vec::with_capacity(max);
36 let mut p = pos;
37 for _ in 0..max {
38 match digit_at(input, p) {
39 Some(width) => {
40 p += width;
41 ends.push(p);
42 }
43 None => break,
44 }
45 }
46 ends.reverse();
47 ends
48 }
49
50 fn e164_groups(input: &[u8], pos: usize, count: usize) -> Option<usize> {
53 if count < MAX_GROUPS {
54 let opens: &[usize] = if input.get(pos) == Some(&b'(') {
55 &[1, 0]
56 } else {
57 &[0]
58 };
59 for &open in opens {
60 let a = pos + open;
61 for b in Self::digit_ends(input, a, 4) {
62 let closes: &[usize] = if input.get(b) == Some(&b')') {
63 &[1, 0]
64 } else {
65 &[0]
66 };
67 for &close in closes {
68 let c = b + close;
69 let seps: [Option<usize>; 2] = [Self::separator_at(input, c), Some(0)];
70 for sep in seps.into_iter().flatten() {
71 if let Some(end) = Self::e164_groups(input, c + sep, count + 1) {
72 return Some(end);
73 }
74 }
75 }
76 }
77 }
78 }
79 if count >= 1 {
80 return digit_at(input, pos).map(|width| pos + width);
81 }
82 None
83 }
84
85 fn match_e164(input: &[u8], pos: usize) -> Option<usize> {
87 if input.get(pos) != Some(&b'+') || !matches!(input.get(pos + 1), Some(b'1'..=b'9')) {
88 return None;
89 }
90 let p = pos + 2;
91 let mut ends = Self::digit_ends(input, p, 2);
92 ends.push(p);
93 for q in ends {
94 let seps: [Option<usize>; 2] = [Self::separator_at(input, q), Some(0)];
95 for sep in seps.into_iter().flatten() {
96 if let Some(end) = Self::e164_groups(input, q + sep, 0) {
97 return Some(end);
98 }
99 }
100 }
101 None
102 }
103
104 fn digits(input: &[u8], pos: usize, n: usize) -> Option<usize> {
106 let mut p = pos;
107 for _ in 0..n {
108 p += digit_at(input, p)?;
109 }
110 Some(p)
111 }
112
113 fn match_paren(input: &[u8], pos: usize) -> Option<usize> {
115 if input.get(pos) != Some(&b'(') {
116 return None;
117 }
118 let p = Self::digits(input, pos + 1, 3)?;
119 if input.get(p) != Some(&b')') {
120 return None;
121 }
122 let p = p + 1;
123 let p = p + Self::separator_at(input, p).unwrap_or(0);
124 let p = Self::digits(input, p, 3)?;
125 let p = p + Self::separator_at(input, p).unwrap_or(0);
126 Self::digits(input, p, 4)
127 }
128
129 fn match_dashed(input: &[u8], pos: usize) -> Option<usize> {
131 let p = Self::digits(input, pos, 3)?;
132 if !matches!(input.get(p), Some(b'-' | b'.')) {
133 return None;
134 }
135 let p = Self::digits(input, p + 1, 3)?;
136 if !matches!(input.get(p), Some(b'-' | b'.')) {
137 return None;
138 }
139 Self::digits(input, p + 1, 4)
140 }
141
142 fn match_at(input: &[u8], pos: usize) -> Option<usize> {
143 match input.get(pos)? {
144 b'+' => Self::match_e164(input, pos),
145 b'(' => Self::match_paren(input, pos),
146 _ => Self::match_dashed(input, pos),
147 }
148 }
149
150 fn start_bytes() -> &'static ByteSet {
151 static SET: OnceLock<ByteSet> = OnceLock::new();
152 SET.get_or_init(|| {
153 let mut set = ByteSet::from_bytes(b"+(");
154 for b in digit_lead_bytes() {
155 set = set.with(b);
156 }
157 set
158 })
159 }
160
161 fn digit_leads() -> &'static ByteSet {
162 static SET: OnceLock<ByteSet> = OnceLock::new();
163 SET.get_or_init(|| {
164 let mut set = ByteSet::EMPTY;
165 for b in digit_lead_bytes() {
166 set = set.with(b);
167 }
168 set
169 })
170 }
171
172 fn validate_match(input: &[u8], start: usize, end: usize) -> Option<Range<usize>> {
173 let matched = &input[start..end];
174
175 if start > 0 && input[start - 1].is_ascii_alphanumeric() {
177 return None;
178 }
179
180 if end < input.len() && input[end].is_ascii_digit() {
182 return None;
183 }
184
185 let digit_count = Self::count_digits(matched);
186 if !(7..=15).contains(&digit_count) {
187 return None;
188 }
189
190 let bytes = matched;
195 if bytes[0] == b'+' {
196 let first_group = bytes[1..].iter().take_while(|b| b.is_ascii_digit()).count();
197 let has_separators = 1 + first_group < bytes.len();
198 if has_separators && first_group > 3 {
199 return None;
200 }
201 }
202
203 Some(start..end)
204 }
205
206 fn range_at(input: &[u8], pos: usize) -> Option<Range<usize>> {
207 let end = Self::match_at(input, pos)?;
208 Self::validate_match(input, pos, end)
209 }
210}
211
212impl Finder for Phone {
213 fn id(&self) -> &'static str {
214 "phone"
215 }
216
217 fn dispatchable(&self) -> bool {
218 true
219 }
220
221 fn could_start_at(&self, byte: u8) -> bool {
222 Self::start_bytes().contains(byte)
223 }
224
225 fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
226 !prev.is_ascii_alphanumeric()
227 }
228
229 fn could_continue_with(&self, cur: u8, next: u8) -> bool {
230 match cur {
231 b'+' => matches!(next, b'1'..=b'9'),
232 b'(' => Self::digit_leads().contains(next),
233 b'0'..=b'9' => Self::digit_leads().contains(next),
234 _ => next & 0xC0 == 0x80,
235 }
236 }
237
238 fn run_rules(&self) -> Vec<RunRule> {
239 let mut leads = ByteSet::EMPTY;
243 for b in digit_lead_bytes().filter(|&b| b >= 0x80) {
244 leads = leads.with(b);
245 }
246 let mixed = |rule: RunRule| {
247 let mut rule = rule;
248 let last = rule.then.iter_mut().rev().find_map(Option::as_mut);
249 match last {
250 Some(step) => {
251 step.after = leads;
252 step.after_end = false;
253 }
254 None => {
255 rule.after = leads;
256 rule.after_end = false;
257 }
258 }
259 rule
260 };
261 vec![
262 RunRule::new(RunClass::Digit, 3, 3)
264 .followed_by(b"-.")
265 .then(RunClass::Digit, 3, 3)
266 .followed_by(b"-.")
267 .then(RunClass::Digit, 4, 4),
268 mixed(RunRule::new(RunClass::Digit, 1, 3)),
270 mixed(RunRule::new(RunClass::Digit, 3, 3).followed_by(b"-.").then(
271 RunClass::Digit,
272 0,
273 3,
274 )),
275 mixed(
276 RunRule::new(RunClass::Digit, 3, 3)
277 .followed_by(b"-.")
278 .then(RunClass::Digit, 3, 3)
279 .followed_by(b"-.")
280 .then(RunClass::Digit, 0, 4),
281 ),
282 ]
283 }
284
285 fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
286 Self::range_at(input, pos)
287 }
288
289 fn find(&self, s: &str) -> Option<Range<usize>> {
292 let input = s.as_bytes();
293 for pos in 0..input.len() {
294 if self.could_start_at(input[pos])
295 && let Some(end) = Self::match_at(input, pos)
296 && let Some(range) = Self::validate_match(input, pos, end)
297 {
298 return Some(range);
299 }
300 }
301 None
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn id_should_return_phone() {
311 let finder = Phone::default();
312 assert_eq!("phone", finder.id());
313 }
314
315 #[test]
317 fn find_should_extract_e164() {
318 let finder = Phone::default();
319 let input = "call +14155551234";
320 let range = finder.find(input).unwrap();
321 assert_eq!("+14155551234", &input[range]);
322 }
323
324 #[test]
325 fn find_should_extract_e164_with_separators() {
326 let finder = Phone::default();
327 let input = "call +1-415-555-1234";
328 let range = finder.find(input).unwrap();
329 assert_eq!("+1-415-555-1234", &input[range]);
330 }
331
332 #[test]
333 fn find_should_extract_e164_with_spaces() {
334 let finder = Phone::default();
335 let input = "call +1 415 555 1234";
336 let range = finder.find(input).unwrap();
337 assert_eq!("+1 415 555 1234", &input[range]);
338 }
339
340 #[test]
341 fn find_should_extract_international_with_country_code() {
342 let finder = Phone::default();
343 let input = "+44 20 7946 0958";
344 let range = finder.find(input).unwrap();
345 assert_eq!("+44 20 7946 0958", &input[range]);
346 }
347
348 #[test]
349 fn find_should_extract_e164_with_dots() {
350 let finder = Phone::default();
351 let input = "+1.415.555.1234";
352 let range = finder.find(input).unwrap();
353 assert_eq!("+1.415.555.1234", &input[range]);
354 }
355
356 #[test]
358 fn find_should_extract_parens_format() {
359 let finder = Phone::default();
360 let input = "call (415) 555-1234";
361 let range = finder.find(input).unwrap();
362 assert_eq!("(415) 555-1234", &input[range]);
363 }
364
365 #[test]
366 fn find_should_extract_parens_no_space() {
367 let finder = Phone::default();
368 let input = "(415)555-1234";
369 let range = finder.find(input).unwrap();
370 assert_eq!("(415)555-1234", &input[range]);
371 }
372
373 #[test]
375 fn find_should_extract_dashed_format() {
376 let finder = Phone::default();
377 let input = "call 415-555-1234";
378 let range = finder.find(input).unwrap();
379 assert_eq!("415-555-1234", &input[range]);
380 }
381
382 #[test]
383 fn find_should_extract_dotted_format() {
384 let finder = Phone::default();
385 let input = "call 415.555.1234";
386 let range = finder.find(input).unwrap();
387 assert_eq!("415.555.1234", &input[range]);
388 }
389
390 #[test]
392 fn find_should_reject_plain_digits() {
393 let finder = Phone::default();
394 assert!(finder.find("1234567890").is_none());
395 }
396
397 #[test]
398 fn find_should_reject_short_number() {
399 let finder = Phone::default();
400 assert!(finder.find("+123").is_none());
401 }
402
403 #[test]
404 fn find_should_reject_preceded_by_alpha() {
405 let finder = Phone::default();
406 assert!(finder.find("x+14155551234").is_none());
407 }
408
409 #[test]
410 fn find_should_handle_empty_input() {
411 let finder = Phone::default();
412 assert!(finder.find("").is_none());
413 }
414
415 #[test]
417 fn find_should_extract_multiple_phones_iteratively() {
418 let finder = Phone::default();
419 let input = "+14155551234 and (212) 555-6789";
420
421 let mut results = Vec::new();
422 let mut idx = 0;
423 while idx < input.len() {
424 if let Some(range) = finder.find(&input[idx..]) {
425 results.push(&input[idx + range.start..idx + range.end]);
426 idx += range.end;
427 } else {
428 break;
429 }
430 }
431
432 assert_eq!(vec!["+14155551234", "(212) 555-6789"], results);
433 }
434
435 #[test]
436 fn find_should_extract_phone_in_text() {
437 let finder = Phone::default();
438 let input = "Call us at +1-800-555-0199 for support";
439 let range = finder.find(input).unwrap();
440 assert_eq!("+1-800-555-0199", &input[range]);
441 }
442}