Skip to main content

max / docengine

4.7 KB · 145 lines History Blame Raw
1 use std::collections::HashSet;
2
3 use crate::code_spans::code_span_ranges;
4
5 /// Extract unique `@username` mentions from raw markdown input.
6 /// Skips mentions inside inline code (backtick-wrapped).
7 pub fn extract_mentions(input: &str) -> Vec<String> {
8 static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> =
9 std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap());
10
11 let stripped = crate::code_spans::strip_code_spans(input);
12 let mut seen = HashSet::new();
13 let mut result = Vec::new();
14 for caps in MENTION_RE.captures_iter(&stripped) {
15 let username = caps[1].to_string();
16 if seen.insert(username.clone()) {
17 result.push(username);
18 }
19 }
20 result
21 }
22
23 /// Replace `@username` with markdown profile links for valid usernames.
24 ///
25 /// `url_template` uses `{username}` as placeholder. For example:
26 /// `/p/my-community/u/{username}` becomes `/p/my-community/u/alice`.
27 ///
28 /// Unknown usernames are left as plain text.
29 pub fn resolve_mentions(
30 input: &str,
31 valid_usernames: &HashSet<String>,
32 url_template: &str,
33 ) -> String {
34 static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> =
35 std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap());
36
37 let mut result = String::with_capacity(input.len());
38 let mut pos = 0;
39
40 for (code_start, code_end) in code_span_ranges(input) {
41 let before = &input[pos..code_start];
42 result.push_str(&replace_mentions(before, valid_usernames, url_template, &MENTION_RE));
43 result.push_str(&input[code_start..code_end]);
44 pos = code_end;
45 }
46 let tail = &input[pos..];
47 result.push_str(&replace_mentions(tail, valid_usernames, url_template, &MENTION_RE));
48
49 result
50 }
51
52 fn replace_mentions(
53 text: &str,
54 valid_usernames: &HashSet<String>,
55 url_template: &str,
56 re: &regex_lite::Regex,
57 ) -> String {
58 re.replace_all(text, |caps: &regex_lite::Captures| {
59 let username = &caps[1];
60 if valid_usernames.contains(username) {
61 let url = url_template.replace("{username}", username);
62 format!("[@{username}]({url})")
63 } else {
64 caps[0].to_string()
65 }
66 })
67 .to_string()
68 }
69
70 #[cfg(test)]
71 mod tests {
72 use super::*;
73
74 #[test]
75 fn extract_basic() {
76 let usernames = extract_mentions("Hello @alice and @bob!");
77 assert_eq!(usernames, vec!["alice", "bob"]);
78 }
79
80 #[test]
81 fn extract_deduplicates() {
82 let usernames = extract_mentions("@alice said @alice agrees");
83 assert_eq!(usernames, vec!["alice"]);
84 }
85
86 #[test]
87 fn extract_skips_inline_code() {
88 let usernames = extract_mentions("Hello `@notreal` and @real");
89 assert_eq!(usernames, vec!["real"]);
90 }
91
92 #[test]
93 fn extract_skips_fenced_code() {
94 let usernames = extract_mentions("text\n```\n@inside\n```\n@outside");
95 assert_eq!(usernames, vec!["outside"]);
96 }
97
98 #[test]
99 fn extract_empty() {
100 let usernames = extract_mentions("no mentions here");
101 assert!(usernames.is_empty());
102 }
103
104 #[test]
105 fn extract_with_hyphens_underscores() {
106 let usernames = extract_mentions("@user-name @user_name");
107 assert_eq!(usernames, vec!["user-name", "user_name"]);
108 }
109
110 #[test]
111 fn resolve_valid_replaced() {
112 let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
113 let result = resolve_mentions("Hello @alice!", &valid, "/p/test-community/u/{username}");
114 assert_eq!(result, "Hello [@alice](/p/test-community/u/alice)!");
115 }
116
117 #[test]
118 fn resolve_unknown_left_alone() {
119 let valid: HashSet<String> = HashSet::new();
120 let result = resolve_mentions("Hello @unknown!", &valid, "/u/{username}");
121 assert_eq!(result, "Hello @unknown!");
122 }
123
124 #[test]
125 fn resolve_in_code_not_replaced() {
126 let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
127 let result = resolve_mentions("Use `@alice` in code", &valid, "/u/{username}");
128 assert_eq!(result, "Use `@alice` in code");
129 }
130
131 #[test]
132 fn resolve_mixed_valid_invalid() {
133 let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
134 let result = resolve_mentions("@alice and @unknown", &valid, "/p/slug/u/{username}");
135 assert_eq!(result, "[@alice](/p/slug/u/alice) and @unknown");
136 }
137
138 #[test]
139 fn resolve_custom_url_template() {
140 let valid: HashSet<String> = ["bob"].iter().map(|s| s.to_string()).collect();
141 let result = resolve_mentions("Hi @bob", &valid, "/users/{username}/profile");
142 assert_eq!(result, "Hi [@bob](/users/bob/profile)");
143 }
144 }
145