tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Markdown-aware mention token scan (design D3).
//!
//! Two mention forms share one sigil:
//!
//! ```text
//! mention := '@' ( handle | email )
//! handle  := [A-Za-z0-9._-]+
//! email   := [A-Za-z0-9._-]+ '@' [A-Za-z0-9-]+ ('.' [A-Za-z0-9-]+)+
//! ```
//!
//! The sigil must start at a **boundary**: the character before it is the start of the text or
//! is not a handle character (`.`, `-`, `_`, alphanumeric), not `@`, and not a word character.
//! That rule is what keeps a bare email out — in `herbert@example.com` the `@` is preceded by a
//! word character, so nothing starts there — while `@thyseus@pm.me` starts at the first `@`.
//!
//! Both a fenced code block and an inline code span are skipped: a quoted chat log or a
//! `@decorator` in a pasted snippet is quoting, not addressing.

/// A mention token found in an issue's title or body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Occurrence {
    /// The mention body exactly as written (the handle or the full email).
    pub body: String,
    /// The 0-based index of the containing line within the **body**, or `None` for a
    /// occurrence in the title (which has no line of its own).
    pub line: Option<usize>,
    /// The containing line, trimmed — for display and for the fuzzy fallback.
    pub context: String,
    /// The containing line, **untrimmed** — the anchor windows this, because trimming would
    /// shift [`Self::token_col`].
    pub line_text: String,
    /// The sigil's character column within the containing line.
    pub token_col: usize,
}

/// The longest `context` kept in a record, so one pathological line cannot bloat the store.
const MAX_CONTEXT_CHARS: usize = 240;

/// Every mention token in an issue's `title` and `body`, in document order.
///
/// The title is scanned first (it is above the body in the rendered issue).
#[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
}

/// Take at most `max` characters, marking a truncation with an ellipsis.
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
}

/// A character that cannot immediately precede a mention sigil.
fn is_boundary_blocker(ch: char) -> bool {
    ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | '@')
}

/// A character that can be part of a mention body before its `@` (an email local part) or in
/// a handle.
fn is_body_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')
}

/// Whether a trimmed line opens a fenced code block, and which fence it opened.
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
}

/// Whether a trimmed line closes the fence `(marker, len)`.
fn closes_fence(line: &str, marker: char, len: usize) -> bool {
    let trimmed = line.trim_start();
    trimmed.chars().take_while(|c| *c == marker).count() >= len
}

/// Replace every inline code span's characters with a sentinel, so nothing inside one matches.
///
/// A code span is a backtick run of length `N` closed by the next run of **exactly** `N`
/// backticks (CommonMark). An unclosed run is literal text and is left alone.
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();
        // Search for a closing run of exactly `open_len` backticks.
        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
}

/// Find every mention token in a masked line, returning `(body, sigil column)`.
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
}

/// Parse a mention body starting at `start` (just past the sigil).
///
/// The email form is attempted first, because it is the longer match and the one that would
/// otherwise be truncated at its second `@`. A leading run that is not a valid email degrades
/// to a handle: `@alice.` in prose is the mention `alice`, not the handle `alice.`.
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;
    }
    // The email form: the run we just read must be followed by `@` and a domain.
    if j > start && j < n && chars[j] == '@' {
        if let Some(end) = email_end(chars, j + 1) {
            return Some((chars[start..end].iter().collect(), end));
        }
    }
    // The handle form: the run, minus trailing sentence punctuation (`alice.`, `alice-`).
    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
}

/// The end of a domain starting at `start`, or `None` when it is not a domain.
///
/// A domain is `label ('.' label)+` — at least one dot, every label starting and ending
/// alphanumeric — so a trailing `.` is not swallowed (`@thyseus@pm.` ends at `pm`).
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() {
        // The `@` follows a word character, so no sigil starts there.
        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() {
        // A punctuation or whitespace boundary starts a mention…
        assert_eq!(bodies(&occurrences("", "(@alice)")), ["alice"]);
        assert_eq!(bodies(&occurrences("", "list item - @alice")), ["alice"]);
        // …and a word character does not.
        assert_eq!(
            occurrences("", "x@alice"),
            [] as [crate::mentions::scan::Occurrence; 0]
        );
        // A handle character does not either: `-`, `.`, `_` and alphanumerics are all part
        // of the handle grammar, so `foo-@alice` is one token, not a mention after a dash.
        assert_eq!(
            occurrences("", "foo-@alice"),
            [] as [crate::mentions::scan::Occurrence; 0]
        );
    }
}