#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Occurrence {
pub body: String,
pub line: Option<usize>,
pub context: String,
pub line_text: String,
pub token_col: usize,
}
const MAX_CONTEXT_CHARS: usize = 240;
#[must_use]
pub fn occurrences(title: &str, body: &str) -> Vec<Occurrence> {
let mut out = Vec::new();
for (token, col) in find_mentions(&mask_code(title)) {
out.push(Occurrence {
body: token,
line: None,
context: truncate_chars(title.trim(), MAX_CONTEXT_CHARS),
line_text: title.to_string(),
token_col: col,
});
}
let mut fence: Option<(char, usize)> = None;
for (index, raw) in body.split('\n').enumerate() {
if let Some((marker, len)) = fence {
if closes_fence(raw, marker, len) {
fence = None;
}
continue;
}
if let Some(opened) = opens_fence(raw) {
fence = Some(opened);
continue;
}
for (token, col) in find_mentions(&mask_code(raw)) {
out.push(Occurrence {
body: token,
line: Some(index),
context: truncate_chars(raw.trim(), MAX_CONTEXT_CHARS),
line_text: raw.to_string(),
token_col: col,
});
}
}
out
}
fn truncate_chars(text: &str, max: usize) -> String {
let mut out: String = text.chars().take(max).collect();
if text.chars().count() > max {
out.push('…');
}
out
}
fn is_boundary_blocker(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | '@')
}
fn is_body_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')
}
fn opens_fence(line: &str) -> Option<(char, usize)> {
let trimmed = line.trim_start();
for marker in ['`', '~'] {
let len = trimmed.chars().take_while(|c| *c == marker).count();
if len >= 3 {
return Some((marker, len));
}
}
None
}
fn closes_fence(line: &str, marker: char, len: usize) -> bool {
let trimmed = line.trim_start();
trimmed.chars().take_while(|c| *c == marker).count() >= len
}
fn mask_code(line: &str) -> Vec<char> {
const SENTINEL: char = '\u{1}';
let mut chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut i = 0;
while i < n {
if chars[i] != '`' {
i += 1;
continue;
}
let open_len = chars[i..].iter().take_while(|c| **c == '`').count();
let mut j = i + open_len;
let mut close: Option<usize> = None;
while j < n {
if chars[j] == '`' {
let run = chars[j..].iter().take_while(|c| **c == '`').count();
if run == open_len {
close = Some(j + run);
break;
}
j += run;
continue;
}
j += 1;
}
match close {
Some(end) => {
for slot in &mut chars[i..end] {
*slot = SENTINEL;
}
i = end;
}
None => i += open_len,
}
}
chars
}
fn find_mentions(chars: &[char]) -> Vec<(String, usize)> {
let mut out = Vec::new();
let n = chars.len();
let mut i = 0;
while i < n {
if chars[i] != '@' {
i += 1;
continue;
}
if i > 0 && is_boundary_blocker(chars[i - 1]) {
i += 1;
continue;
}
match match_body(chars, i + 1) {
Some((body, end)) => {
out.push((body, i));
i = end;
}
None => i += 1,
}
}
out
}
fn match_body(chars: &[char], start: usize) -> Option<(String, usize)> {
let n = chars.len();
let mut j = start;
while j < n && is_body_char(chars[j]) {
j += 1;
}
if j > start && j < n && chars[j] == '@' {
if let Some(end) = email_end(chars, j + 1) {
return Some((chars[start..end].iter().collect(), end));
}
}
let mut end = j;
while end > start && matches!(chars[end - 1], '.' | '-') {
end -= 1;
}
if end > start {
return Some((chars[start..end].iter().collect(), end));
}
None
}
fn email_end(chars: &[char], start: usize) -> Option<usize> {
let n = chars.len();
if start >= n || !chars[start].is_ascii_alphanumeric() {
return None;
}
let mut i = start;
let mut dots = 0usize;
let mut ends_alnum = false;
while i < n {
let ch = chars[i];
if ch.is_ascii_alphanumeric() {
ends_alnum = true;
i += 1;
} else if ch == '-' {
ends_alnum = false;
i += 1;
} else if ch == '.' && ends_alnum && i + 1 < n && chars[i + 1].is_ascii_alphanumeric() {
dots += 1;
ends_alnum = false;
i += 1;
} else {
break;
}
}
if dots >= 1 && ends_alnum && i > start {
Some(i)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bodies(found: &[Occurrence]) -> Vec<&str> {
found.iter().map(|o| o.body.as_str()).collect()
}
#[test]
fn a_handle_in_the_body_is_found_with_its_line() {
let body = "first line\nblocked on @herbert-maschke's review\nlast";
let found = occurrences("", body);
assert_eq!(bodies(&found), ["herbert-maschke"]);
assert_eq!(found[0].line, Some(1));
assert_eq!(found[0].context, "blocked on @herbert-maschke's review");
assert_eq!(found[0].token_col, 11);
}
#[test]
fn a_handle_in_the_title_is_found_with_no_line() {
let found = occurrences("@alice please confirm", "");
assert_eq!(bodies(&found), ["alice"]);
assert_eq!(found[0].line, None);
}
#[test]
fn a_handle_in_a_comment_section_is_found() {
let body = "## Comments\n\n### alice (2026-10-10)\n\ncc @bob when done";
let found = occurrences("", body);
assert_eq!(bodies(&found), ["bob"]);
assert_eq!(found[0].line, Some(4));
}
#[test]
fn a_full_email_mention_is_found() {
let found = occurrences("", "ping @thyseus@pm.me about the parser");
assert_eq!(bodies(&found), ["thyseus@pm.me"]);
assert_eq!(found[0].token_col, 5);
}
#[test]
fn an_email_of_another_person_with_the_sigil_is_found() {
let found = occurrences("", "ask @herbert.maschke@example.com today");
assert_eq!(bodies(&found), ["herbert.maschke@example.com"]);
}
#[test]
fn a_bare_email_is_not_a_mention() {
let found = occurrences("", "contact herbert@example.com for access");
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn an_incomplete_email_degrades_to_a_handle() {
let found = occurrences("", "ping @thyseus@pm. today");
assert_eq!(bodies(&found), ["thyseus"]);
}
#[test]
fn a_mention_inside_an_inline_code_span_is_not_found() {
let found = occurrences("", "use `@alice` here, but @bob there");
assert_eq!(bodies(&found), ["bob"]);
}
#[test]
fn a_mention_inside_a_fenced_code_block_is_not_found() {
let body = "before @alice\n\n```\nquoted @bob\n@carol\n```\nafter @dave\n";
let found = occurrences("", body);
assert_eq!(bodies(&found), ["alice", "dave"]);
}
#[test]
fn a_tilde_fence_is_skipped_too() {
let body = "~~~\n@bob\n~~~\n@carol";
let found = occurrences("", body);
assert_eq!(bodies(&found), ["carol"]);
}
#[test]
fn two_occurrences_of_one_body_on_different_lines_are_both_found() {
let found = occurrences("", "@alice on line one\nand @alice on line two");
assert_eq!(bodies(&found), ["alice", "alice"]);
assert_eq!(found[0].line, Some(0));
assert_eq!(found[1].line, Some(1));
}
#[test]
fn a_trailing_dot_is_not_part_of_the_handle() {
let found = occurrences("", "thanks @alice.");
assert_eq!(bodies(&found), ["alice"]);
}
#[test]
fn double_sigil_is_not_a_mention() {
let found = occurrences("", "send @@alice now");
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn the_sigil_needs_a_boundary() {
assert_eq!(bodies(&occurrences("", "(@alice)")), ["alice"]);
assert_eq!(bodies(&occurrences("", "list item - @alice")), ["alice"]);
assert_eq!(
occurrences("", "x@alice"),
[] as [crate::mentions::scan::Occurrence; 0]
);
assert_eq!(
occurrences("", "foo-@alice"),
[] as [crate::mentions::scan::Occurrence; 0]
);
}
}