Skip to main content

squeeze/
phone.rs

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/// Phone number finder.
7///
8/// Hand-written equivalent of the former regex
9/// `\+[1-9]\d{0,2}[\s.\-]?(?:\(?\d{1,4}\)?[\s.\-]?){1,7}\d` (E.164 with
10/// optional separators), `\(\d{3}\)[\s.\-]?\d{3}[\s.\-]?\d{4}` and
11/// `\d{3}[\-\.]\d{3}[\-\.]\d{4}` (North American forms), including the
12/// regex crate's Unicode `\d` and `\s`, followed by the same validation.
13#[derive(Default)]
14pub struct Phone {}
15
16/// Longest run of E.164 digit groups.
17const 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    /// `[\s.\-]` at `pos`: byte length of the separator.
25    #[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    /// Ends of `\d{1,max}` at `pos`, longest first.
34    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    /// `(?:\(?\d{1,4}\)?[\s.\-]?){1,7}\d` from `pos` with `count` groups
51    /// already matched, in backtracking order: more groups first.
52    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    /// `\+[1-9]\d{0,2}[\s.\-]?` then the groups.
86    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    /// Exactly `n` `\d` at `pos`.
105    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    /// `\(\d{3}\)[\s.\-]?\d{3}[\s.\-]?\d{4}`.
114    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    /// `\d{3}[\-\.]\d{3}[\-\.]\d{4}`.
130    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        // Boundary before: not preceded by alphanumeric
176        if start > 0 && input[start - 1].is_ascii_alphanumeric() {
177            return None;
178        }
179
180        // Boundary after: not followed by digit
181        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        // A '+' number written with separators must lead with a plausible
191        // country code (1-3 digits before the first separator):
192        // `+2024-01-15 ...` is a diff-line date, not a phone number.
193        // Compact `+14155551234` (no separators) is unaffected.
194        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        // `\d{3}[-.]\d{3}[-.]\d{4}` where `\d` also matches non-ASCII
240        // digits: the ASCII digit run of a group is either the whole group,
241        // followed by a separator, or is completed by a non-ASCII digit.
242        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            // All three groups ASCII.
263            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            // A non-ASCII digit completes the first, second or third group.
269            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    // Candidates are tried in position order; a rejected candidate is
290    // simply followed by the next position, which may lie inside it.
291    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    // E.164 format
316    #[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    // North American with parens
357    #[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    // North American with dashes
374    #[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    // Rejection tests
391    #[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    // Multiple
416    #[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}