1use crate::config::SmartSelectionRule;
9use regex::Regex;
10
11pub struct SmartSelectionMatcher {
13 rules: Vec<CompiledRule>,
15}
16
17struct CompiledRule {
18 name: String,
19 regex: Regex,
20 precision: f64,
21}
22
23impl SmartSelectionMatcher {
24 pub fn new(rules: &[SmartSelectionRule]) -> Self {
26 let mut compiled: Vec<CompiledRule> = rules
27 .iter()
28 .filter(|r| r.enabled)
29 .filter_map(|r| match Regex::new(&r.regex) {
30 Ok(regex) => Some(CompiledRule {
31 name: r.name.clone(),
32 regex,
33 precision: r.precision.value(),
34 }),
35 Err(e) => {
36 log::warn!(
37 "Failed to compile smart selection regex '{}': {}",
38 r.name,
39 e
40 );
41 None
42 }
43 })
44 .collect();
45
46 compiled.sort_by(|a, b| {
48 b.precision
49 .partial_cmp(&a.precision)
50 .unwrap_or(std::cmp::Ordering::Equal)
51 });
52
53 Self { rules: compiled }
54 }
55
56 pub fn find_match_at(&self, line: &str, col: usize) -> Option<(usize, usize)> {
65 let byte_offset = char_to_byte_offset(line, col)?;
67
68 for rule in &self.rules {
69 for mat in rule.regex.find_iter(line) {
71 let match_start_byte = mat.start();
72 let match_end_byte = mat.end();
73
74 if byte_offset >= match_start_byte && byte_offset < match_end_byte {
76 let start_col = byte_to_char_offset(line, match_start_byte)?;
78 let end_col = byte_to_char_offset(line, match_end_byte)?.saturating_sub(1);
79
80 log::trace!(
81 "smart_selection: rule '{}' matched col {} → [{}, {}]",
82 rule.name,
83 col,
84 start_col,
85 end_col,
86 );
87 return Some((start_col, end_col));
88 }
89 }
90 }
91
92 None
93 }
94}
95
96fn char_to_byte_offset(s: &str, char_offset: usize) -> Option<usize> {
98 s.char_indices()
99 .nth(char_offset)
100 .map(|(byte_idx, _)| byte_idx)
101 .or_else(|| {
102 if char_offset >= s.chars().count() {
104 Some(s.len())
105 } else {
106 None
107 }
108 })
109}
110
111fn byte_to_char_offset(s: &str, byte_offset: usize) -> Option<usize> {
113 if byte_offset > s.len() {
114 return None;
115 }
116 Some(s[..byte_offset].chars().count())
117}
118
119pub fn is_word_char(ch: char, word_characters: &str) -> bool {
129 ch.is_alphanumeric() || is_combining_mark(ch) || word_characters.contains(ch)
130}
131
132fn is_combining_mark(ch: char) -> bool {
138 matches!(ch as u32,
139 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20F0 | 0xFE20..=0xFE2F | 0x200D | 0xFE0E | 0xFE0F | 0x1F3FB..=0x1F3FF )
148}
149
150pub fn find_word_boundaries(line: &str, col: usize, word_characters: &str) -> (usize, usize) {
154 let chars: Vec<char> = line.chars().collect();
155
156 if chars.is_empty() || col >= chars.len() {
157 return (col, col);
158 }
159
160 let mut start_col = col;
161 let mut end_col = col;
162
163 while start_col > 0 && is_word_char(chars[start_col - 1], word_characters) {
165 start_col -= 1;
166 }
167
168 if !is_word_char(chars[col], word_characters) {
170 return (col, col);
171 }
172
173 while end_col < chars.len() - 1 && is_word_char(chars[end_col + 1], word_characters) {
175 end_col += 1;
176 }
177
178 (start_col, end_col)
179}
180
181pub struct SmartSelectionCache {
183 matcher: Option<SmartSelectionMatcher>,
185 rules_hash: u64,
187}
188
189impl Default for SmartSelectionCache {
190 fn default() -> Self {
191 Self::new()
192 }
193}
194
195impl SmartSelectionCache {
196 pub fn new() -> Self {
197 Self {
198 matcher: None,
199 rules_hash: 0,
200 }
201 }
202
203 pub fn get_matcher(&mut self, rules: &[SmartSelectionRule]) -> &SmartSelectionMatcher {
205 let hash = hash_rules(rules);
206
207 if self.rules_hash != hash || self.matcher.is_none() {
208 self.matcher = Some(SmartSelectionMatcher::new(rules));
209 self.rules_hash = hash;
210 }
211
212 self.matcher
213 .as_ref()
214 .expect("matcher was just set to Some above if it was None")
215 }
216}
217
218fn hash_rules(rules: &[SmartSelectionRule]) -> u64 {
220 use std::collections::hash_map::DefaultHasher;
221 use std::hash::{Hash, Hasher};
222
223 let mut hasher = DefaultHasher::new();
224 for rule in rules {
225 rule.name.hash(&mut hasher);
226 rule.regex.hash(&mut hasher);
227 rule.enabled.hash(&mut hasher);
228 std::mem::discriminant(&rule.precision).hash(&mut hasher);
230 }
231 hasher.finish()
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::config::{SmartSelectionPrecision, SmartSelectionRule};
238
239 fn test_rules() -> Vec<SmartSelectionRule> {
240 vec![
241 SmartSelectionRule::new(
242 "HTTP URL",
243 r"https?://[^\s]+",
244 SmartSelectionPrecision::VeryHigh,
245 ),
246 SmartSelectionRule::new(
247 "Email",
248 r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
249 SmartSelectionPrecision::High,
250 ),
251 SmartSelectionRule::new(
252 "File path",
253 r"~?/?(?:[a-zA-Z0-9._-]+/)+[a-zA-Z0-9._-]+/?",
254 SmartSelectionPrecision::Normal,
255 ),
256 ]
257 }
258
259 #[test]
260 fn test_find_url_match() {
261 let matcher = SmartSelectionMatcher::new(&test_rules());
262 let line = "Check out https://example.com/path for more info";
263
264 let result = matcher.find_match_at(line, 10);
266 assert_eq!(result, Some((10, 33)));
267
268 let result = matcher.find_match_at(line, 18);
270 assert_eq!(result, Some((10, 33)));
271
272 let result = matcher.find_match_at(line, 0);
274 assert_eq!(result, None);
275 }
276
277 #[test]
278 fn test_find_email_match() {
279 let matcher = SmartSelectionMatcher::new(&test_rules());
280 let line = "Contact user@example.com for help";
281
282 let result = matcher.find_match_at(line, 8);
284 assert_eq!(result, Some((8, 23)));
285
286 let result = matcher.find_match_at(line, 12);
288 assert_eq!(result, Some((8, 23)));
289 }
290
291 #[test]
292 fn test_find_path_match() {
293 let matcher = SmartSelectionMatcher::new(&test_rules());
294 let line = "Edit ~/Documents/file.txt and save";
295
296 let result = matcher.find_match_at(line, 7);
298 assert_eq!(result, Some((5, 24)));
299 }
300
301 #[test]
302 fn test_word_boundaries_default() {
303 let line = "hello_world test-case foo.bar";
304 let word_chars = "/-+\\~_.";
305
306 let (start, end) = find_word_boundaries(line, 6, word_chars);
308 assert_eq!(
309 &line.chars().collect::<Vec<_>>()[start..=end]
310 .iter()
311 .collect::<String>(),
312 "hello_world"
313 );
314
315 let (start, end) = find_word_boundaries(line, 12, word_chars);
317 assert_eq!(
318 &line.chars().collect::<Vec<_>>()[start..=end]
319 .iter()
320 .collect::<String>(),
321 "test-case"
322 );
323 }
324
325 #[test]
326 fn test_word_boundaries_empty_config() {
327 let line = "hello_world test-case";
328 let word_chars = "";
329
330 let (start, end) = find_word_boundaries(line, 6, word_chars);
334 assert_eq!(
335 &line.chars().collect::<Vec<_>>()[start..=end]
336 .iter()
337 .collect::<String>(),
338 "world"
339 );
340
341 let (start, end) = find_word_boundaries(line, 0, word_chars);
343 assert_eq!(
344 &line.chars().collect::<Vec<_>>()[start..=end]
345 .iter()
346 .collect::<String>(),
347 "hello"
348 );
349
350 let (start, end) = find_word_boundaries(line, 12, word_chars);
352 assert_eq!(
353 &line.chars().collect::<Vec<_>>()[start..=end]
354 .iter()
355 .collect::<String>(),
356 "test"
357 );
358 }
359
360 #[test]
361 fn test_is_word_char() {
362 let word_chars = "/-+\\~_.";
363
364 assert!(is_word_char('a', word_chars));
365 assert!(is_word_char('Z', word_chars));
366 assert!(is_word_char('5', word_chars));
367 assert!(is_word_char('_', word_chars));
368 assert!(is_word_char('-', word_chars));
369 assert!(is_word_char('/', word_chars));
370 assert!(is_word_char('.', word_chars));
371
372 assert!(!is_word_char(' ', word_chars));
373 assert!(!is_word_char('@', word_chars));
374 assert!(!is_word_char('!', word_chars));
375 }
376
377 #[test]
378 fn test_unicode_handling() {
379 let matcher = SmartSelectionMatcher::new(&test_rules());
380 let line = "日本語 https://example.com 中文";
381
382 let result = matcher.find_match_at(line, 4);
385 assert_eq!(result, Some((4, 22)));
387 }
388
389 #[test]
390 fn test_disabled_rule() {
391 let mut rules = test_rules();
392 rules[0].enabled = false; let matcher = SmartSelectionMatcher::new(&rules);
395 let line = "Check out https://example.com for more info";
396
397 let result = matcher.find_match_at(line, 10);
399 assert_eq!(result, None);
400 }
401
402 #[test]
403 fn test_precision_ordering() {
404 let rules = vec![
406 SmartSelectionRule::new("Whitespace-bounded", r"\S+", SmartSelectionPrecision::Low),
407 SmartSelectionRule::new(
408 "Email",
409 r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
410 SmartSelectionPrecision::High,
411 ),
412 ];
413
414 let matcher = SmartSelectionMatcher::new(&rules);
415 let line = "Contact user@example.com for help";
416
417 let result = matcher.find_match_at(line, 12);
419 assert_eq!(result, Some((8, 23)));
420 }
421}