1use super::items::{SettingControl, SettingItem, SettingsPage};
7
8#[derive(Debug, Clone)]
10pub enum DeepMatch {
11 MapKey {
13 key: String,
15 entry_index: usize,
17 },
18 MapValue {
20 key: String,
22 entry_index: usize,
24 field_path: String,
26 matched_text: String,
28 },
29 TextListItem {
31 text: String,
33 item_index: usize,
35 },
36}
37
38#[derive(Debug, Clone)]
40pub struct SearchResult {
41 pub page_index: usize,
43 pub item_index: usize,
45 pub item: SettingItem,
47 pub breadcrumb: String,
49 pub score: i32,
51 pub name_matches: Vec<usize>,
53 pub description_matches: Vec<usize>,
55 pub deep_match: Option<DeepMatch>,
57}
58
59pub fn search_settings(pages: &[SettingsPage], query: &str) -> Vec<SearchResult> {
61 if query.is_empty() {
62 return Vec::new();
63 }
64
65 let query_lower = query.to_lowercase();
66 let mut results = Vec::new();
67
68 for (page_index, page) in pages.iter().enumerate() {
69 for (item_index, item) in page.items.iter().enumerate() {
70 let (name_score, name_matches) = fuzzy_match(&item.name.to_lowercase(), &query_lower);
72
73 let (desc_score, desc_matches) = item
75 .description
76 .as_ref()
77 .map(|d| fuzzy_match(&d.to_lowercase(), &query_lower))
78 .unwrap_or((0, Vec::new()));
79
80 let (path_score, _) = fuzzy_match(&item.path.to_lowercase(), &query_lower);
82
83 let total_score = name_score.max(desc_score).max(path_score);
85
86 if total_score > 0 {
87 results.push(SearchResult {
88 page_index,
89 item_index,
90 item: item.clone(),
91 breadcrumb: page.name.clone(),
92 score: total_score,
93 name_matches,
94 description_matches: desc_matches,
95 deep_match: None,
96 });
97 }
98
99 search_composite_control(
101 &mut results,
102 page_index,
103 item_index,
104 item,
105 &page.name,
106 &query_lower,
107 );
108 }
109 }
110
111 results.sort_by(|a, b| {
113 b.score
114 .cmp(&a.score)
115 .then_with(|| a.item.name.cmp(&b.item.name))
116 });
117
118 results
119}
120
121fn search_composite_control(
123 results: &mut Vec<SearchResult>,
124 page_index: usize,
125 item_index: usize,
126 item: &SettingItem,
127 page_name: &str,
128 query_lower: &str,
129) {
130 match &item.control {
131 SettingControl::Map(map_state) => {
132 for (entry_idx, (key, value)) in map_state.entries.iter().enumerate() {
133 let (key_score, key_matches) = fuzzy_match(&key.to_lowercase(), query_lower);
135 if key_score > 0 {
136 results.push(SearchResult {
137 page_index,
138 item_index,
139 item: item.clone(),
140 breadcrumb: format!("{} > {}", page_name, key),
141 score: key_score,
142 name_matches: key_matches,
143 description_matches: Vec::new(),
144 deep_match: Some(DeepMatch::MapKey {
145 key: key.clone(),
146 entry_index: entry_idx,
147 }),
148 });
149 continue;
152 }
153
154 search_json_value(
156 results,
157 page_index,
158 item_index,
159 item,
160 page_name,
161 key,
162 entry_idx,
163 value,
164 "",
165 query_lower,
166 );
167 }
168 }
169 SettingControl::TextList(list_state) => {
170 for (list_idx, text) in list_state.items.iter().enumerate() {
171 let (score, matches) = fuzzy_match(&text.to_lowercase(), query_lower);
172 if score > 0 {
173 results.push(SearchResult {
174 page_index,
175 item_index,
176 item: item.clone(),
177 breadcrumb: format!("{} > {}", page_name, item.name),
178 score,
179 name_matches: matches,
180 description_matches: Vec::new(),
181 deep_match: Some(DeepMatch::TextListItem {
182 text: text.clone(),
183 item_index: list_idx,
184 }),
185 });
186 }
187 }
188 }
189 _ => {}
190 }
191}
192
193#[allow(clippy::too_many_arguments)]
195fn search_json_value(
196 results: &mut Vec<SearchResult>,
197 page_index: usize,
198 item_index: usize,
199 item: &SettingItem,
200 page_name: &str,
201 map_key: &str,
202 entry_index: usize,
203 value: &serde_json::Value,
204 path: &str,
205 query_lower: &str,
206) {
207 match value {
208 serde_json::Value::String(s) => {
209 let (score, _) = fuzzy_match(&s.to_lowercase(), query_lower);
210 if score > 0 {
211 let field_name = path.rsplit('/').next().unwrap_or(path).to_string();
213 let display_name = if field_name.is_empty() {
214 s.clone()
215 } else {
216 format!("{}: {}", field_name, s)
217 };
218 results.push(SearchResult {
219 page_index,
220 item_index,
221 item: item.clone(),
222 breadcrumb: format!("{} > {}", page_name, map_key),
223 score,
224 name_matches: Vec::new(),
225 description_matches: Vec::new(),
226 deep_match: Some(DeepMatch::MapValue {
227 key: map_key.to_string(),
228 entry_index,
229 field_path: path.to_string(),
230 matched_text: display_name,
231 }),
232 });
233 }
234 }
235 serde_json::Value::Object(obj) => {
236 for (k, v) in obj {
237 let child_path = format!("{}/{}", path, k);
238 search_json_value(
239 results,
240 page_index,
241 item_index,
242 item,
243 page_name,
244 map_key,
245 entry_index,
246 v,
247 &child_path,
248 query_lower,
249 );
250 }
251 }
252 serde_json::Value::Array(arr) => {
253 for (i, v) in arr.iter().enumerate() {
254 let child_path = format!("{}/{}", path, i);
255 search_json_value(
256 results,
257 page_index,
258 item_index,
259 item,
260 page_name,
261 map_key,
262 entry_index,
263 v,
264 &child_path,
265 query_lower,
266 );
267 }
268 }
269 _ => {}
270 }
271}
272
273fn fuzzy_match(text: &str, pattern: &str) -> (i32, Vec<usize>) {
276 if pattern.is_empty() {
277 return (0, Vec::new());
278 }
279
280 let text_chars: Vec<char> = text.chars().collect();
281 let pattern_chars: Vec<char> = pattern.chars().collect();
282
283 let mut score = 0;
284 let mut matched_indices = Vec::new();
285 let mut pattern_idx = 0;
286 let mut prev_match_idx: Option<usize> = None;
287
288 for (text_idx, &text_char) in text_chars.iter().enumerate() {
289 if pattern_idx < pattern_chars.len() && text_char == pattern_chars[pattern_idx] {
290 matched_indices.push(text_idx);
291
292 score += 10; if let Some(prev) = prev_match_idx {
297 if text_idx == prev + 1 {
298 score += 15; }
300 }
301
302 if text_idx == 0
304 || text_chars.get(text_idx.wrapping_sub(1)) == Some(&' ')
305 || text_chars.get(text_idx.wrapping_sub(1)) == Some(&'_')
306 {
307 score += 20; }
309
310 if text_idx == pattern_idx {
312 score += 5; }
314
315 prev_match_idx = Some(text_idx);
316 pattern_idx += 1;
317 }
318 }
319
320 if pattern_idx == pattern_chars.len() {
322 let length_bonus = (100 - text_chars.len().min(100) as i32) / 10;
324 score += length_bonus;
325
326 if text == pattern {
328 score += 100;
329 }
330
331 (score, matched_indices)
332 } else {
333 (0, Vec::new())
335 }
336}
337
338pub fn matches_query(item: &SettingItem, query: &str) -> bool {
340 let query_lower = query.to_lowercase();
341
342 item.name.to_lowercase().contains(&query_lower)
343 || item
344 .description
345 .as_ref()
346 .map(|d| d.to_lowercase().contains(&query_lower))
347 .unwrap_or(false)
348 || item.path.to_lowercase().contains(&query_lower)
349}
350
351pub fn matching_categories(pages: &[SettingsPage], query: &str) -> Vec<usize> {
353 if query.is_empty() {
354 return Vec::new();
355 }
356
357 pages
358 .iter()
359 .enumerate()
360 .filter(|(_, page)| page.items.iter().any(|item| matches_query(item, query)))
361 .map(|(idx, _)| idx)
362 .collect()
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::view::controls::ToggleState;
369 use crate::view::settings::items::{ItemBoxStyle, SettingControl};
370
371 fn make_item(name: &str, description: Option<&str>, path: &str) -> SettingItem {
372 SettingItem {
373 path: path.to_string(),
374 name: name.to_string(),
375 description: description.map(String::from),
376 control: SettingControl::Toggle(ToggleState::new(false, name)),
377 default: None,
378 modified: false,
379 layer_source: crate::config_io::ConfigLayer::System,
380 read_only: false,
381 is_auto_managed: false,
382 nullable: false,
383 is_null: false,
384 section: None,
385 is_section_start: false,
386 style: ItemBoxStyle::default(),
387 dual_list_sibling: None,
388 }
389 }
390
391 fn make_page(name: &str, items: Vec<SettingItem>) -> SettingsPage {
392 SettingsPage {
393 name: name.to_string(),
394 path: format!("/{}", name.to_lowercase()),
395 description: None,
396 nullable: false,
397 items,
398 subpages: Vec::new(),
399 sections: Vec::new(),
400 }
401 }
402
403 #[test]
404 fn test_fuzzy_match_exact() {
405 let (score, indices) = fuzzy_match("line_numbers", "line");
406 assert!(score > 0);
407 assert_eq!(indices, vec![0, 1, 2, 3]);
408 }
409
410 #[test]
411 fn test_fuzzy_match_prefix() {
412 let (score, indices) = fuzzy_match("tab_size", "tab");
413 assert!(score > 0);
414 assert_eq!(indices, vec![0, 1, 2]);
415 }
416
417 #[test]
418 fn test_fuzzy_match_scattered() {
419 let (score, indices) = fuzzy_match("line_numbers", "lnm");
420 assert!(score > 0);
421 assert_eq!(indices, vec![0, 2, 7]);
423 }
424
425 #[test]
426 fn test_fuzzy_match_no_match() {
427 let (score, indices) = fuzzy_match("hello", "xyz");
428 assert_eq!(score, 0);
429 assert!(indices.is_empty());
430 }
431
432 #[test]
433 fn test_search_settings_empty_query() {
434 let pages = vec![make_page(
435 "Editor",
436 vec![make_item(
437 "Line Numbers",
438 Some("Show line numbers"),
439 "/line_numbers",
440 )],
441 )];
442
443 let results = search_settings(&pages, "");
444 assert!(results.is_empty());
445 }
446
447 #[test]
448 fn test_search_settings_name_match() {
449 let pages = vec![make_page(
450 "Editor",
451 vec![
452 make_item("Line Numbers", Some("Show line numbers"), "/line_numbers"),
453 make_item("Tab Size", Some("Spaces per tab"), "/tab_size"),
454 ],
455 )];
456
457 let results = search_settings(&pages, "line");
458 assert_eq!(results.len(), 1);
459 assert_eq!(results[0].item.name, "Line Numbers");
460 assert_eq!(results[0].breadcrumb, "Editor");
461 }
462
463 #[test]
464 fn test_search_settings_description_match() {
465 let pages = vec![make_page(
466 "Editor",
467 vec![make_item(
468 "Tab Size",
469 Some("Number of spaces per tab character"),
470 "/tab_size",
471 )],
472 )];
473
474 let results = search_settings(&pages, "spaces");
475 assert_eq!(results.len(), 1);
476 assert_eq!(results[0].item.name, "Tab Size");
477 }
478
479 #[test]
480 fn test_search_settings_path_match() {
481 let pages = vec![make_page(
482 "Editor",
483 vec![make_item("Tab Size", None, "/editor/tab_size")],
484 )];
485
486 let results = search_settings(&pages, "editor");
487 assert_eq!(results.len(), 1);
488 }
489
490 #[test]
491 fn test_matching_categories() {
492 let pages = vec![
493 make_page(
494 "Editor",
495 vec![make_item("Line Numbers", None, "/line_numbers")],
496 ),
497 make_page("Theme", vec![make_item("Theme Name", None, "/theme")]),
498 ];
499
500 let matches = matching_categories(&pages, "line");
501 assert_eq!(matches, vec![0]);
502
503 let matches = matching_categories(&pages, "theme");
504 assert_eq!(matches, vec![1]);
505 }
506
507 #[test]
508 fn test_search_ranking() {
509 let pages = vec![make_page(
510 "Editor",
511 vec![
512 make_item("Tab", None, "/tab"), make_item("Tab Size", None, "/tab_size"), make_item("Default Tab", None, "/default_tab"), ],
516 )];
517
518 let results = search_settings(&pages, "tab");
519 assert_eq!(results.len(), 3);
520 assert_eq!(results[0].item.name, "Tab");
522 assert_eq!(results[1].item.name, "Tab Size");
524 assert_eq!(results[2].item.name, "Default Tab");
526 }
527
528 fn make_map_item(
529 name: &str,
530 path: &str,
531 entries: Vec<(String, serde_json::Value)>,
532 ) -> SettingItem {
533 use crate::view::controls::MapState;
534 let mut map_state = MapState::new(name);
535 map_state.entries = entries;
536 SettingItem {
537 path: path.to_string(),
538 name: name.to_string(),
539 description: None,
540 control: SettingControl::Map(map_state),
541 default: None,
542 modified: false,
543 layer_source: crate::config_io::ConfigLayer::System,
544 read_only: false,
545 is_auto_managed: false,
546 nullable: false,
547 is_null: false,
548 section: None,
549 is_section_start: false,
550 style: ItemBoxStyle::default(),
551 dual_list_sibling: None,
552 }
553 }
554
555 fn make_text_list_item(name: &str, path: &str, items: Vec<String>) -> SettingItem {
556 use crate::view::controls::TextListState;
557 let mut list_state = TextListState::new(name);
558 list_state.items = items;
559 SettingItem {
560 path: path.to_string(),
561 name: name.to_string(),
562 description: None,
563 control: SettingControl::TextList(list_state),
564 default: None,
565 modified: false,
566 layer_source: crate::config_io::ConfigLayer::System,
567 read_only: false,
568 is_auto_managed: false,
569 nullable: false,
570 is_null: false,
571 section: None,
572 is_section_start: false,
573 style: ItemBoxStyle::default(),
574 dual_list_sibling: None,
575 }
576 }
577
578 #[test]
579 fn test_search_map_key() {
580 let pages = vec![make_page(
581 "Languages",
582 vec![make_map_item(
583 "Languages",
584 "/languages",
585 vec![
586 ("python".to_string(), serde_json::json!({})),
587 ("rust".to_string(), serde_json::json!({})),
588 ],
589 )],
590 )];
591
592 let results = search_settings(&pages, "python");
593 let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
595 assert!(!deep_results.is_empty(), "Should find map key 'python'");
596 assert!(
597 matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python")
598 );
599 assert_eq!(deep_results[0].breadcrumb, "Languages > python");
600 }
601
602 #[test]
603 fn test_search_map_key_no_duplicate_nested_values() {
604 let pages = vec![make_page(
608 "General",
609 vec![make_map_item(
610 "Languages",
611 "/languages",
612 vec![(
613 "bash".to_string(),
614 serde_json::json!({"grammar": "bash", "files": ["1.bash"]}),
615 )],
616 )],
617 )];
618
619 let results = search_settings(&pages, "bash");
620 let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
621 assert_eq!(
623 deep_results.len(),
624 1,
625 "Expected exactly 1 deep match (MapKey), got {}: {:?}",
626 deep_results.len(),
627 deep_results
628 .iter()
629 .map(|r| &r.deep_match)
630 .collect::<Vec<_>>()
631 );
632 assert!(
633 matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "bash")
634 );
635 }
636
637 #[test]
638 fn test_search_map_nested_value() {
639 let pages = vec![make_page(
640 "LSP",
641 vec![make_map_item(
642 "LSP",
643 "/lsp",
644 vec![(
645 "rust".to_string(),
646 serde_json::json!({"command": "rust-analyzer", "args": ["--stdio"]}),
647 )],
648 )],
649 )];
650
651 let results = search_settings(&pages, "rust-analyzer");
652 let deep_results: Vec<_> = results
653 .iter()
654 .filter(|r| matches!(&r.deep_match, Some(DeepMatch::MapValue { .. })))
655 .collect();
656 assert!(
657 !deep_results.is_empty(),
658 "Should find nested value 'rust-analyzer'"
659 );
660 }
661
662 #[test]
663 fn test_search_text_list_item() {
664 let pages = vec![make_page(
665 "Editor",
666 vec![make_text_list_item(
667 "File Extensions",
668 "/file_extensions",
669 vec!["py".to_string(), "rs".to_string(), "js".to_string()],
670 )],
671 )];
672
673 let results = search_settings(&pages, "py");
674 let deep_results: Vec<_> = results
675 .iter()
676 .filter(|r| matches!(&r.deep_match, Some(DeepMatch::TextListItem { .. })))
677 .collect();
678 assert!(!deep_results.is_empty(), "Should find text list item 'py'");
679 }
680
681 #[test]
682 fn test_deep_match_ranks_higher_than_fuzzy_noise() {
683 let pages = vec![
684 make_page(
685 "Editor",
686 vec![
687 make_item(
689 "Leading Spaces",
690 Some("Show space indicators for leading whitespace"),
691 "/editor/whitespace_spaces_leading",
692 ),
693 ],
694 ),
695 make_page(
696 "Languages",
697 vec![make_map_item(
698 "Languages",
699 "/languages",
700 vec![("python".to_string(), serde_json::json!({}))],
701 )],
702 ),
703 ];
704
705 let results = search_settings(&pages, "python");
706 assert!(!results.is_empty());
707 assert!(
709 matches!(&results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python"),
710 "Map key 'python' should rank above fuzzy noise, got: {:?}",
711 results[0].deep_match
712 );
713 }
714}