Skip to main content

squeeze/
handle.rs

1//! Social handle finder.
2//!
3//! Extracts `@username` style mentions used by Twitter, GitHub, Mastodon, etc.
4//! Accepts handles of the form `@[A-Za-z0-9][A-Za-z0-9_-]{0,38}` and the
5//! Mastodon-style `@user@host.tld`.
6
7use super::Finder;
8use crate::domain::looks_like_tld;
9use std::ops::Range;
10
11#[derive(Default)]
12pub struct Handle {}
13
14#[inline]
15fn is_handle_char(b: u8) -> bool {
16    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
17}
18
19/// A Mastodon-style host must look like a real domain: at least two
20/// non-empty dot-separated labels, the last of which is a plausible TLD.
21fn is_valid_host(host: &[u8]) -> bool {
22    let mut labels = 0usize;
23    let mut last: &[u8] = &[];
24    for label in host.split(|&b| b == b'.') {
25        if label.is_empty() {
26            return false;
27        }
28        labels += 1;
29        last = label;
30    }
31    labels >= 2 && looks_like_tld(last)
32}
33
34impl Finder for Handle {
35    fn line_agnostic(&self) -> bool {
36        // Matches never contain a line terminator and `\n`/`\r` end every
37        // walk exactly like the end of the input does.
38        true
39    }
40
41    fn id(&self) -> &'static str {
42        "handle"
43    }
44
45    fn dispatchable(&self) -> bool {
46        true
47    }
48
49    fn could_start_at(&self, byte: u8) -> bool {
50        byte == b'@'
51    }
52
53    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
54        !(prev.is_ascii_alphanumeric() || prev == b'.' || prev == b'_')
55    }
56
57    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
58        next.is_ascii_alphanumeric()
59    }
60
61    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
62        if input.get(pos)? != &b'@' {
63            return None;
64        }
65        // Must not be glued to a preceding alphanumeric (avoid email tails).
66        if pos > 0 {
67            let prev = input[pos - 1];
68            if prev.is_ascii_alphanumeric() || prev == b'.' || prev == b'_' {
69                return None;
70            }
71        }
72        let start = pos;
73        let name_start = pos + 1;
74        if name_start >= input.len() || !input[name_start].is_ascii_alphanumeric() {
75            return None;
76        }
77        let mut end = name_start + 1;
78        while end < input.len() && is_handle_char(input[end]) {
79            end += 1;
80        }
81        // Strip trailing hyphens.
82        while end > name_start && input[end - 1] == b'-' {
83            end -= 1;
84        }
85        let len = end - name_start;
86        if !(1..=39).contains(&len) {
87            return None;
88        }
89
90        // Optional Mastodon-style suffix: @host.tld
91        if end < input.len() && input[end] == b'@' {
92            let host_start = end + 1;
93            let mut host_end = host_start;
94            while host_end < input.len() {
95                let b = input[host_end];
96                if b.is_ascii_alphanumeric() || b == b'-' || b == b'.' {
97                    host_end += 1;
98                } else {
99                    break;
100                }
101            }
102            // Strip trailing dots and hyphens (sentence punctuation).
103            while host_end > host_start
104                && (input[host_end - 1] == b'.' || input[host_end - 1] == b'-')
105            {
106                host_end -= 1;
107            }
108            // Validate what remains as a domain-shaped host; on an invalid
109            // host, fall back to the bare handle (consistent with the
110            // dotless-host fallback).
111            if is_valid_host(&input[host_start..host_end]) {
112                end = host_end;
113            }
114        }
115
116        Some(start..end)
117    }
118
119    fn find(&self, s: &str) -> Option<Range<usize>> {
120        let input = s.as_bytes();
121        let mut i = 0;
122        while i < input.len() {
123            if input[i] == b'@'
124                && let Some(r) = self.try_at(input, i)
125            {
126                return Some(r);
127            }
128            i += 1;
129        }
130        None
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn id_should_return_handle() {
140        assert_eq!("handle", Handle::default().id());
141    }
142
143    #[test]
144    fn find_simple_handle() {
145        let f = Handle::default();
146        let input = "ping @alice please";
147        let r = f.find(input).unwrap();
148        assert_eq!("@alice", &input[r]);
149    }
150
151    #[test]
152    fn find_github_style() {
153        let f = Handle::default();
154        let input = "cc @user-name on this";
155        let r = f.find(input).unwrap();
156        assert_eq!("@user-name", &input[r]);
157    }
158
159    #[test]
160    fn find_mastodon_style() {
161        let f = Handle::default();
162        let input = "follow @alice@example.social to see";
163        let r = f.find(input).unwrap();
164        assert_eq!("@alice@example.social", &input[r]);
165    }
166
167    #[test]
168    fn find_skips_email() {
169        let f = Handle::default();
170        assert!(f.find("user@example.com").is_none());
171    }
172
173    #[test]
174    fn find_handle_at_start() {
175        let f = Handle::default();
176        let input = "@bob hi";
177        let r = f.find(input).unwrap();
178        assert_eq!("@bob", &input[r]);
179    }
180
181    #[test]
182    fn find_rejects_lone_at() {
183        let f = Handle::default();
184        assert!(f.find("@ alone").is_none());
185    }
186
187    #[test]
188    fn find_rejects_trailing_hyphen() {
189        let f = Handle::default();
190        let input = "@bob- here";
191        let r = f.find(input).unwrap();
192        assert_eq!("@bob", &input[r]);
193    }
194
195    #[test]
196    fn find_returns_none_for_empty() {
197        assert!(Handle::default().find("").is_none());
198    }
199
200    // --- Numbers + underscores ---
201
202    #[test]
203    fn find_handle_with_numbers() {
204        let f = Handle::default();
205        let input = "@user42 done";
206        let r = f.find(input).unwrap();
207        assert_eq!("@user42", &input[r]);
208    }
209
210    #[test]
211    fn find_handle_with_underscore() {
212        let f = Handle::default();
213        let input = "ping @snake_case";
214        let r = f.find(input).unwrap();
215        assert_eq!("@snake_case", &input[r]);
216    }
217
218    #[test]
219    fn find_handle_starting_with_digit() {
220        let f = Handle::default();
221        let input = "@42alice hi";
222        let r = f.find(input).unwrap();
223        assert_eq!("@42alice", &input[r]);
224    }
225
226    // --- Length limits ---
227
228    #[test]
229    fn find_handle_at_max_length() {
230        let f = Handle::default();
231        let name = "a".repeat(39);
232        let input = format!("hi @{} hi", name);
233        let r = f.find(&input).unwrap();
234        assert_eq!(format!("@{}", name), input[r]);
235    }
236
237    #[test]
238    fn find_handle_over_max_length_truncates_to_max() {
239        // Our impl accepts up to 39 chars; the 40th wouldn't pass `1..=39`.
240        // If the entire run is 40 chars, we currently report None (full check).
241        // This documents that strictness explicitly.
242        let f = Handle::default();
243        let name = "a".repeat(40);
244        let input = format!("@{}", name);
245        assert!(f.find(&input).is_none());
246    }
247
248    // --- Boundary checks ---
249
250    #[test]
251    fn find_skips_handle_after_word_char() {
252        let f = Handle::default();
253        // No space before '@' but preceded by alphanumeric → email-like → skip
254        assert!(f.find("abc@bob").is_none());
255    }
256
257    #[test]
258    fn find_skips_handle_after_dot() {
259        let f = Handle::default();
260        // ".alice@bob" — prev byte before '@' is alphanumeric 'e'; skip
261        assert!(f.find(".alice@bob").is_none());
262    }
263
264    #[test]
265    fn find_handle_after_punctuation() {
266        let f = Handle::default();
267        let input = "[@alice]";
268        let r = f.find(input).unwrap();
269        assert_eq!("@alice", &input[r]);
270    }
271
272    #[test]
273    fn find_handle_after_comma() {
274        let f = Handle::default();
275        let input = "cc, @bob";
276        let r = f.find(input).unwrap();
277        assert_eq!("@bob", &input[r]);
278    }
279
280    // --- Mastodon variations ---
281
282    #[test]
283    fn find_mastodon_with_subdomain() {
284        let f = Handle::default();
285        let input = "@user@mastodon.example.social";
286        let r = f.find(input).unwrap();
287        assert_eq!("@user@mastodon.example.social", &input[r]);
288    }
289
290    #[test]
291    fn find_mastodon_without_dot_in_host_rejects_suffix() {
292        let f = Handle::default();
293        // host has no dot → falls back to bare handle
294        let input = "@user@localhost";
295        let r = f.find(input).unwrap();
296        assert_eq!("@user", &input[r]);
297    }
298
299    #[test]
300    fn find_mastodon_trailing_dot_excluded() {
301        let f = Handle::default();
302        let input = "@user@example.social.";
303        let r = f.find(input).unwrap();
304        assert_eq!("@user@example.social", &input[r]);
305    }
306
307    // --- Multiple handles ---
308
309    #[test]
310    fn find_multiple_handles_iteratively() {
311        let f = Handle::default();
312        let input = "@alice and @bob and @carol";
313        let mut results = Vec::new();
314        let mut idx = 0;
315        while idx < input.len() {
316            if let Some(r) = f.find(&input[idx..]) {
317                results.push(input[idx + r.start..idx + r.end].to_string());
318                idx += r.end;
319            } else {
320                break;
321            }
322        }
323        assert_eq!(vec!["@alice", "@bob", "@carol"], results);
324    }
325
326    #[test]
327    fn find_adjacent_handles() {
328        let f = Handle::default();
329        // "@a@b" — `@a` is bare, then `@b` follows but host has no '.', so first match is "@a"
330        let input = "@a@b cool";
331        let r = f.find(input).unwrap();
332        assert_eq!("@a", &input[r]);
333    }
334
335    // --- Reject conditions ---
336
337    #[test]
338    fn find_rejects_handle_with_only_underscore() {
339        let f = Handle::default();
340        // first char must be alphanumeric (not '_')
341        assert!(f.find("@_alice").is_none());
342    }
343
344    #[test]
345    fn find_rejects_handle_with_only_hyphen() {
346        let f = Handle::default();
347        assert!(f.find("@-alice").is_none());
348    }
349
350    #[test]
351    fn find_handle_in_email_address_skipped() {
352        // The handle finder must not match the @ in "user@example.com"
353        let f = Handle::default();
354        let input = "send to user@example.com today";
355        assert!(f.find(input).is_none());
356    }
357
358    #[test]
359    fn find_handle_after_newline_break() {
360        let f = Handle::default();
361        let input = "hello\n@bob";
362        let r = f.find(input).unwrap();
363        assert_eq!("@bob", &input[r]);
364    }
365
366    #[test]
367    fn find_handle_with_unicode_after() {
368        // unicode follows handle; should still match handle correctly
369        let f = Handle::default();
370        let input = "@alice 🎉";
371        let r = f.find(input).unwrap();
372        assert_eq!("@alice", &input[r]);
373    }
374}