//! Deriving `#tags` from a note's body — the local mirror of what the server computes //! on save. Pure string scanning (no regex dependency), kept in lockstep with the //! frontend's inline rules (see frontend notes/markdown.ts): //! //! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! On save these become labels attached with `via_tag = true`. //! //! Dedupes case-insensitively, preserving first-seen order. //! //! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a //! capture-and-recall surface, and a linking system is organization. /// Extract every `#tag` name (without the leading `#`) from `body`. pub fn extract_tags(body: &str) -> Vec { let chars: Vec = body.chars().collect(); let mut out: Vec = Vec::new(); let mut i = 0; while i < chars.len() { if chars[i] == '#' { let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#'); // A tag must start with a letter (so "#1" or a bare "#" is not a tag). if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() { let mut j = i + 1; while j < chars.len() && is_tag_char(chars[j]) { j += 1; } let tag: String = chars[i + 1..j].iter().collect(); push_unique(&mut out, &tag); i = j; continue; } } i += 1; } out } fn is_tag_char(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '-' } fn push_unique(out: &mut Vec, candidate: &str) { if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) { out.push(candidate.to_string()); } } #[cfg(test)] mod tests { use super::*; #[test] fn tags_basic() { assert_eq!( extract_tags("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"] ); } #[test] fn tags_require_letter_start_and_boundary() { // "#1" (digit) and an in-word "#" (email-ish) are not tags. assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); } #[test] fn tags_dedupe_case_insensitive() { assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]); } #[test] fn empty_body() { assert!(extract_tags("").is_empty()); } }