返回 CodeWhale
matcher.rs
根目录 / crates / tui / src / plugins / matcher.rs
1 //! Pure keyword matcher over plugin metadata.
2 //!
3 //! Matches live as data: explicit `keywords`, `domains` (scheme / `www.` /
4 //! path stripped), and the plugin `name`. There is no `regex` dependency —
5 //! matching is substring search guarded by ASCII word boundaries.
6
7 use std::cmp::Reverse;
8
9 /// A plugin to match a draft against.
10 pub struct KeywordCandidate<'a> {
11 pub name: &'a str,
12 pub domains: &'a [String],
13 pub keywords: &'a [String],
14 }
15
16 /// Return the candidate index and exact term that matched `draft`.
17 ///
18 /// Returns `None` when `draft` has fewer than 3 characters or nothing matches.
19 /// Longer keywords take precedence; a keyword matches only when the occurrence
20 /// is flanked by ASCII word boundaries.
21 pub fn match_plugin_keyword(
22 draft: &str,
23 candidates: &[KeywordCandidate<'_>],
24 ) -> Option<(usize, String)> {
25 if draft.trim_start().starts_with('/') || draft.chars().count() < 3 {
26 return None;
27 }
28 let draft_lc = draft.to_ascii_lowercase();
29 let haystack = draft_lc.as_bytes();
30
31 let mut pairs: Vec<(String, usize)> = Vec::new();
32 for (idx, candidate) in candidates.iter().enumerate() {
33 for keyword in effective_keywords(candidate) {
34 pairs.push((keyword, idx));
35 }
36 }
37 pairs.sort_by_key(|(keyword, _)| Reverse(keyword.len()));
38
39 pairs
40 .iter()
41 .find(|(keyword, _)| keyword_matches(haystack, keyword.as_bytes()))
42 .map(|(keyword, idx)| (*idx, keyword.clone()))
43 }
44
45 fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec<String> {
46 let mut keywords = Vec::new();
47 for keyword in candidate.keywords {
48 let normalized = keyword.trim().to_ascii_lowercase();
49 if is_matchable_term(&normalized) {
50 keywords.push(normalized);
51 }
52 }
53 for domain in candidate.domains {
54 // A homepage on a code-hosting platform names where the plugin
55 // *lives*, not what it is; matching it would make every github-hosted
56 // plugin fire on any "github" mention. Everything else matches on
57 // declared data, which the host does not second-guess.
58 if let Some(normalized) = normalize_domain(domain)
59 && is_matchable_term(&normalized)
60 && !matches!(
61 normalized.as_str(),
62 "github.com" | "gitlab.com" | "bitbucket.org"
63 )
64 {
65 keywords.push(normalized);
66 }
67 }
68 let name = candidate.name.trim().to_ascii_lowercase();
69 if is_matchable_term(&name) {
70 keywords.push(name);
71 }
72 keywords
73 }
74
75 // Core vocabulary is not evidence that a user needs an integration. A
76 // specific product name, phrase or domain is still eligible.
77 /// Mechanical admissibility for a match term: long enough to be a word and
78 /// free of control characters.
79 ///
80 /// Deliberately **not** a semantic stoplist. It used to reject declared terms
81 /// like `mcp`, `agent`, `model`, `data` and `code`, which made a catalog
82 /// author's declared keywords unmatchable — the same failure mode as the
83 /// deleted #6274 name suppression, one layer down. Declared keywords are the
84 /// catalog author's call; the noise controls are the score threshold, the
85 /// once-per-lifetime gate, and dismissal (#6290 rework).
86 fn is_matchable_term(term: &str) -> bool {
87 term.chars().count() >= 3 && !term.chars().any(char::is_control)
88 }
89
90 pub(crate) fn normalize_domain(domain: &str) -> Option<String> {
91 let trimmed = domain.trim();
92 let after_scheme = match trimmed.find("://") {
93 Some(i) => &trimmed[i + 3..],
94 None => trimmed,
95 };
96 let host = after_scheme
97 .split(['/', '?', '#'])
98 .next()
99 .unwrap_or(after_scheme)
100 .to_ascii_lowercase();
101 let host = host.strip_prefix("www.").unwrap_or(&host);
102 if host.is_empty() {
103 None
104 } else {
105 Some(host.to_string())
106 }
107 }
108
109 fn keyword_matches(haystack: &[u8], keyword: &[u8]) -> bool {
110 if keyword.is_empty() {
111 return false;
112 }
113 let len = haystack.len();
114 haystack
115 .windows(keyword.len())
116 .enumerate()
117 .any(|(start, window)| {
118 if window != keyword {
119 return false;
120 }
121 let end = start + keyword.len();
122 let start_ok = start == 0 || is_word(haystack[start - 1]) != is_word(haystack[start]);
123 let end_ok = end == len || is_word(haystack[end - 1]) != is_word(haystack[end]);
124 start_ok && end_ok
125 })
126 }
127
128 fn is_word(byte: u8) -> bool {
129 byte.is_ascii_alphanumeric() || byte == b'_'
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 // Existing selection tests assert the index; the receipt test below also
137 // checks the matched term carried through to the UI.
138 fn match_plugin_keyword(draft: &str, candidates: &[KeywordCandidate<'_>]) -> Option<usize> {
139 super::match_plugin_keyword(draft, candidates).map(|(index, _)| index)
140 }
141
142 #[test]
143 fn match_receipt_names_the_exact_trigger() {
144 let keywords = vec!["finance".to_string(), "mcp".to_string()];
145 let candidates = [candidate("kimi-datasource", &[], &keywords)];
146 assert_eq!(
147 super::match_plugin_keyword("help with finance", &candidates),
148 Some((0, "finance".into()))
149 );
150 // Declared terms are the catalog author's call (#6290 rework): `mcp`
151 // matches when declared, and the receipt names it.
152 assert_eq!(
153 super::match_plugin_keyword("help with mcp", &candidates),
154 Some((0, "mcp".into()))
155 );
156 }
157
158 fn candidate<'a>(
159 name: &'a str,
160 domains: &'a [String],
161 keywords: &'a [String],
162 ) -> KeywordCandidate<'a> {
163 KeywordCandidate {
164 name,
165 domains,
166 keywords,
167 }
168 }
169
170 #[test]
171 fn longest_keyword_takes_precedence() {
172 let short = vec!["editor".to_string()];
173 let long = vec!["code editor".to_string()];
174 let candidates = [
175 candidate("plugin-a", &[], &short),
176 candidate("plugin-b", &[], &long),
177 ];
178 assert_eq!(
179 match_plugin_keyword("my code editor rocks", &candidates),
180 Some(1)
181 );
182 }
183
184 #[test]
185 fn word_boundary_required() {
186 let keywords = vec!["box".to_string()];
187 let candidates = [candidate("box", &[], &keywords)];
188 assert_eq!(match_plugin_keyword("i love boxing", &candidates), None);
189 assert_eq!(match_plugin_keyword("i love box", &candidates), Some(0));
190 }
191
192 #[test]
193 fn boxing_does_not_match_box() {
194 let keywords = vec!["box".to_string()];
195 let candidates = [candidate("box", &[], &keywords)];
196 assert_eq!(match_plugin_keyword("try boxing drills", &candidates), None);
197 }
198
199 #[test]
200 fn domains_match_inside_pasted_urls() {
201 let domains = vec!["figma.com".to_string()];
202 let none: Vec<String> = Vec::new();
203 let candidates = [candidate("design-app", &domains, &none)];
204 assert_eq!(
205 match_plugin_keyword("open https://www.figma.com/board/x please", &candidates),
206 Some(0)
207 );
208 assert_eq!(
209 match_plugin_keyword("open figma.com please", &candidates),
210 Some(0)
211 );
212 assert_eq!(match_plugin_keyword("open figma please", &candidates), None);
213 }
214
215 #[test]
216 fn name_is_used_as_fallback() {
217 let none: Vec<String> = Vec::new();
218 let candidates = [candidate("obsidian", &[], &none)];
219 assert_eq!(
220 match_plugin_keyword("open obsidian now", &candidates),
221 Some(0)
222 );
223 }
224
225 #[test]
226 fn draft_below_min_length_never_matches() {
227 let keywords = vec!["go".to_string()];
228 let candidates = [candidate("go", &[], &keywords)];
229 assert_eq!(match_plugin_keyword("go", &candidates), None);
230 let git = vec!["git".to_string()];
231 let candidates = [candidate("git", &[], &git)];
232 assert_eq!(match_plugin_keyword("git", &candidates), Some(0));
233 }
234
235 #[test]
236 fn declared_vocabulary_matches_and_only_mechanics_filter_terms() {
237 // Declared keywords are the catalog author's call (#6290 rework):
238 // `mcp`, `agent`, `model`, … match when declared. The remaining
239 // filters are mechanical (>= 3 characters, no control characters),
240 // the `/`-command guard, and the code-hosting homepage exclusion.
241 let words = [
242 "mcp", "plugin", "skill", "agent", "tool", "code", "data", "model", "session",
243 ];
244 let keywords = words
245 .iter()
246 .map(|word| word.to_string())
247 .collect::<Vec<_>>();
248 let candidates = [candidate("mcp", &[], &keywords)];
249 for word in words {
250 assert_eq!(
251 match_plugin_keyword(&format!("please help with {word}"), &candidates),
252 Some(0),
253 "{word}"
254 );
255 }
256 // Two-character terms stay out on the mechanical floor.
257 let short_keywords = vec!["go".to_string()];
258 let short = [candidate("git", &[], &short_keywords)];
259 assert_eq!(match_plugin_keyword("go", &short), None);
260
261 let shared_host = vec!["https://github.com/example/plugin".to_string()];
262 let candidates = [candidate("supabase", &shared_host, &[])];
263 assert_eq!(
264 match_plugin_keyword("open github.com/example/repo", &candidates),
265 None
266 );
267 for command in ["/mcp", " /plugin show supabase", "/skills supabase"] {
268 assert_eq!(match_plugin_keyword(command, &candidates), None);
269 }
270 assert_eq!(
271 match_plugin_keyword("add supabase auth", &candidates),
272 Some(0)
273 );
274 }
275 }
276
276 lines RUST