use super::items::{SettingControl, SettingItem, SettingsPage};
#[derive(Debug, Clone)]
pub enum DeepMatch {
MapKey {
key: String,
entry_index: usize,
},
MapValue {
key: String,
entry_index: usize,
field_path: String,
matched_text: String,
},
TextListItem {
text: String,
item_index: usize,
},
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub page_index: usize,
pub item_index: usize,
pub item: SettingItem,
pub breadcrumb: String,
pub score: i32,
pub name_matches: Vec<usize>,
pub description_matches: Vec<usize>,
pub deep_match: Option<DeepMatch>,
}
pub fn search_settings(pages: &[SettingsPage], query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (page_index, page) in pages.iter().enumerate() {
for (item_index, item) in page.items.iter().enumerate() {
let (name_score, name_matches) = fuzzy_match(&item.name.to_lowercase(), &query_lower);
let (desc_score, desc_matches) = item
.description
.as_ref()
.map(|d| fuzzy_match(&d.to_lowercase(), &query_lower))
.unwrap_or((0, Vec::new()));
let (path_score, _) = fuzzy_match(&item.path.to_lowercase(), &query_lower);
let total_score = name_score.max(desc_score).max(path_score);
if total_score > 0 {
results.push(SearchResult {
page_index,
item_index,
item: item.clone(),
breadcrumb: page.name.clone(),
score: total_score,
name_matches,
description_matches: desc_matches,
deep_match: None,
});
}
search_composite_control(
&mut results,
page_index,
item_index,
item,
&page.name,
&query_lower,
);
}
}
results.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.item.name.cmp(&b.item.name))
});
results
}
fn search_composite_control(
results: &mut Vec<SearchResult>,
page_index: usize,
item_index: usize,
item: &SettingItem,
page_name: &str,
query_lower: &str,
) {
match &item.control {
SettingControl::Map(map_state) => {
for (entry_idx, (key, value)) in map_state.entries.iter().enumerate() {
let (key_score, key_matches) = fuzzy_match(&key.to_lowercase(), query_lower);
if key_score > 0 {
results.push(SearchResult {
page_index,
item_index,
item: item.clone(),
breadcrumb: format!("{} > {}", page_name, key),
score: key_score,
name_matches: key_matches,
description_matches: Vec::new(),
deep_match: Some(DeepMatch::MapKey {
key: key.clone(),
entry_index: entry_idx,
}),
});
continue;
}
search_json_value(
results,
page_index,
item_index,
item,
page_name,
key,
entry_idx,
value,
"",
query_lower,
);
}
}
SettingControl::TextList(list_state) => {
for (list_idx, text) in list_state.items.iter().enumerate() {
let (score, matches) = fuzzy_match(&text.to_lowercase(), query_lower);
if score > 0 {
results.push(SearchResult {
page_index,
item_index,
item: item.clone(),
breadcrumb: format!("{} > {}", page_name, item.name),
score,
name_matches: matches,
description_matches: Vec::new(),
deep_match: Some(DeepMatch::TextListItem {
text: text.clone(),
item_index: list_idx,
}),
});
}
}
}
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
fn search_json_value(
results: &mut Vec<SearchResult>,
page_index: usize,
item_index: usize,
item: &SettingItem,
page_name: &str,
map_key: &str,
entry_index: usize,
value: &serde_json::Value,
path: &str,
query_lower: &str,
) {
match value {
serde_json::Value::String(s) => {
let (score, _) = fuzzy_match(&s.to_lowercase(), query_lower);
if score > 0 {
let field_name = path.rsplit('/').next().unwrap_or(path).to_string();
let display_name = if field_name.is_empty() {
s.clone()
} else {
format!("{}: {}", field_name, s)
};
results.push(SearchResult {
page_index,
item_index,
item: item.clone(),
breadcrumb: format!("{} > {}", page_name, map_key),
score,
name_matches: Vec::new(),
description_matches: Vec::new(),
deep_match: Some(DeepMatch::MapValue {
key: map_key.to_string(),
entry_index,
field_path: path.to_string(),
matched_text: display_name,
}),
});
}
}
serde_json::Value::Object(obj) => {
for (k, v) in obj {
let child_path = format!("{}/{}", path, k);
search_json_value(
results,
page_index,
item_index,
item,
page_name,
map_key,
entry_index,
v,
&child_path,
query_lower,
);
}
}
serde_json::Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let child_path = format!("{}/{}", path, i);
search_json_value(
results,
page_index,
item_index,
item,
page_name,
map_key,
entry_index,
v,
&child_path,
query_lower,
);
}
}
_ => {}
}
}
fn fuzzy_match(text: &str, pattern: &str) -> (i32, Vec<usize>) {
if pattern.is_empty() {
return (0, Vec::new());
}
let text_chars: Vec<char> = text.chars().collect();
let pattern_chars: Vec<char> = pattern.chars().collect();
let mut score = 0;
let mut matched_indices = Vec::new();
let mut pattern_idx = 0;
let mut prev_match_idx: Option<usize> = None;
for (text_idx, &text_char) in text_chars.iter().enumerate() {
if pattern_idx < pattern_chars.len() && text_char == pattern_chars[pattern_idx] {
matched_indices.push(text_idx);
score += 10;
if let Some(prev) = prev_match_idx {
if text_idx == prev + 1 {
score += 15; }
}
if text_idx == 0
|| text_chars.get(text_idx.wrapping_sub(1)) == Some(&' ')
|| text_chars.get(text_idx.wrapping_sub(1)) == Some(&'_')
{
score += 20; }
if text_idx == pattern_idx {
score += 5; }
prev_match_idx = Some(text_idx);
pattern_idx += 1;
}
}
if pattern_idx == pattern_chars.len() {
let length_bonus = (100 - text_chars.len().min(100) as i32) / 10;
score += length_bonus;
if text == pattern {
score += 100;
}
(score, matched_indices)
} else {
(0, Vec::new())
}
}
pub fn matches_query(item: &SettingItem, query: &str) -> bool {
let query_lower = query.to_lowercase();
item.name.to_lowercase().contains(&query_lower)
|| item
.description
.as_ref()
.map(|d| d.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| item.path.to_lowercase().contains(&query_lower)
}
pub fn matching_categories(pages: &[SettingsPage], query: &str) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
pages
.iter()
.enumerate()
.filter(|(_, page)| page.items.iter().any(|item| matches_query(item, query)))
.map(|(idx, _)| idx)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::controls::ToggleState;
use crate::view::settings::items::{ItemBoxStyle, SettingControl};
fn make_item(name: &str, description: Option<&str>, path: &str) -> SettingItem {
SettingItem {
path: path.to_string(),
name: name.to_string(),
description: description.map(String::from),
control: SettingControl::Toggle(ToggleState::new(false, name)),
default: None,
modified: false,
layer_source: crate::config_io::ConfigLayer::System,
read_only: false,
is_auto_managed: false,
nullable: false,
is_null: false,
section: None,
is_section_start: false,
style: ItemBoxStyle::default(),
dual_list_sibling: None,
}
}
fn make_page(name: &str, items: Vec<SettingItem>) -> SettingsPage {
SettingsPage {
name: name.to_string(),
path: format!("/{}", name.to_lowercase()),
description: None,
nullable: false,
items,
subpages: Vec::new(),
sections: Vec::new(),
}
}
#[test]
fn test_fuzzy_match_exact() {
let (score, indices) = fuzzy_match("line_numbers", "line");
assert!(score > 0);
assert_eq!(indices, vec![0, 1, 2, 3]);
}
#[test]
fn test_fuzzy_match_prefix() {
let (score, indices) = fuzzy_match("tab_size", "tab");
assert!(score > 0);
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn test_fuzzy_match_scattered() {
let (score, indices) = fuzzy_match("line_numbers", "lnm");
assert!(score > 0);
assert_eq!(indices, vec![0, 2, 7]);
}
#[test]
fn test_fuzzy_match_no_match() {
let (score, indices) = fuzzy_match("hello", "xyz");
assert_eq!(score, 0);
assert!(indices.is_empty());
}
#[test]
fn test_search_settings_empty_query() {
let pages = vec![make_page(
"Editor",
vec![make_item(
"Line Numbers",
Some("Show line numbers"),
"/line_numbers",
)],
)];
let results = search_settings(&pages, "");
assert!(results.is_empty());
}
#[test]
fn test_search_settings_name_match() {
let pages = vec![make_page(
"Editor",
vec![
make_item("Line Numbers", Some("Show line numbers"), "/line_numbers"),
make_item("Tab Size", Some("Spaces per tab"), "/tab_size"),
],
)];
let results = search_settings(&pages, "line");
assert_eq!(results.len(), 1);
assert_eq!(results[0].item.name, "Line Numbers");
assert_eq!(results[0].breadcrumb, "Editor");
}
#[test]
fn test_search_settings_description_match() {
let pages = vec![make_page(
"Editor",
vec![make_item(
"Tab Size",
Some("Number of spaces per tab character"),
"/tab_size",
)],
)];
let results = search_settings(&pages, "spaces");
assert_eq!(results.len(), 1);
assert_eq!(results[0].item.name, "Tab Size");
}
#[test]
fn test_search_settings_path_match() {
let pages = vec![make_page(
"Editor",
vec![make_item("Tab Size", None, "/editor/tab_size")],
)];
let results = search_settings(&pages, "editor");
assert_eq!(results.len(), 1);
}
#[test]
fn test_matching_categories() {
let pages = vec![
make_page(
"Editor",
vec![make_item("Line Numbers", None, "/line_numbers")],
),
make_page("Theme", vec![make_item("Theme Name", None, "/theme")]),
];
let matches = matching_categories(&pages, "line");
assert_eq!(matches, vec![0]);
let matches = matching_categories(&pages, "theme");
assert_eq!(matches, vec![1]);
}
#[test]
fn test_search_ranking() {
let pages = vec![make_page(
"Editor",
vec![
make_item("Tab", None, "/tab"), make_item("Tab Size", None, "/tab_size"), make_item("Default Tab", None, "/default_tab"), ],
)];
let results = search_settings(&pages, "tab");
assert_eq!(results.len(), 3);
assert_eq!(results[0].item.name, "Tab");
assert_eq!(results[1].item.name, "Tab Size");
assert_eq!(results[2].item.name, "Default Tab");
}
fn make_map_item(
name: &str,
path: &str,
entries: Vec<(String, serde_json::Value)>,
) -> SettingItem {
use crate::view::controls::MapState;
let mut map_state = MapState::new(name);
map_state.entries = entries;
SettingItem {
path: path.to_string(),
name: name.to_string(),
description: None,
control: SettingControl::Map(map_state),
default: None,
modified: false,
layer_source: crate::config_io::ConfigLayer::System,
read_only: false,
is_auto_managed: false,
nullable: false,
is_null: false,
section: None,
is_section_start: false,
style: ItemBoxStyle::default(),
dual_list_sibling: None,
}
}
fn make_text_list_item(name: &str, path: &str, items: Vec<String>) -> SettingItem {
use crate::view::controls::TextListState;
let mut list_state = TextListState::new(name);
list_state.items = items;
SettingItem {
path: path.to_string(),
name: name.to_string(),
description: None,
control: SettingControl::TextList(list_state),
default: None,
modified: false,
layer_source: crate::config_io::ConfigLayer::System,
read_only: false,
is_auto_managed: false,
nullable: false,
is_null: false,
section: None,
is_section_start: false,
style: ItemBoxStyle::default(),
dual_list_sibling: None,
}
}
#[test]
fn test_search_map_key() {
let pages = vec![make_page(
"Languages",
vec![make_map_item(
"Languages",
"/languages",
vec![
("python".to_string(), serde_json::json!({})),
("rust".to_string(), serde_json::json!({})),
],
)],
)];
let results = search_settings(&pages, "python");
let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
assert!(!deep_results.is_empty(), "Should find map key 'python'");
assert!(
matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python")
);
assert_eq!(deep_results[0].breadcrumb, "Languages > python");
}
#[test]
fn test_search_map_key_no_duplicate_nested_values() {
let pages = vec![make_page(
"General",
vec![make_map_item(
"Languages",
"/languages",
vec![(
"bash".to_string(),
serde_json::json!({"grammar": "bash", "files": ["1.bash"]}),
)],
)],
)];
let results = search_settings(&pages, "bash");
let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
assert_eq!(
deep_results.len(),
1,
"Expected exactly 1 deep match (MapKey), got {}: {:?}",
deep_results.len(),
deep_results
.iter()
.map(|r| &r.deep_match)
.collect::<Vec<_>>()
);
assert!(
matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "bash")
);
}
#[test]
fn test_search_map_nested_value() {
let pages = vec![make_page(
"LSP",
vec![make_map_item(
"LSP",
"/lsp",
vec![(
"rust".to_string(),
serde_json::json!({"command": "rust-analyzer", "args": ["--stdio"]}),
)],
)],
)];
let results = search_settings(&pages, "rust-analyzer");
let deep_results: Vec<_> = results
.iter()
.filter(|r| matches!(&r.deep_match, Some(DeepMatch::MapValue { .. })))
.collect();
assert!(
!deep_results.is_empty(),
"Should find nested value 'rust-analyzer'"
);
}
#[test]
fn test_search_text_list_item() {
let pages = vec![make_page(
"Editor",
vec![make_text_list_item(
"File Extensions",
"/file_extensions",
vec!["py".to_string(), "rs".to_string(), "js".to_string()],
)],
)];
let results = search_settings(&pages, "py");
let deep_results: Vec<_> = results
.iter()
.filter(|r| matches!(&r.deep_match, Some(DeepMatch::TextListItem { .. })))
.collect();
assert!(!deep_results.is_empty(), "Should find text list item 'py'");
}
#[test]
fn test_deep_match_ranks_higher_than_fuzzy_noise() {
let pages = vec![
make_page(
"Editor",
vec![
make_item(
"Leading Spaces",
Some("Show space indicators for leading whitespace"),
"/editor/whitespace_spaces_leading",
),
],
),
make_page(
"Languages",
vec![make_map_item(
"Languages",
"/languages",
vec![("python".to_string(), serde_json::json!({}))],
)],
),
];
let results = search_settings(&pages, "python");
assert!(!results.is_empty());
assert!(
matches!(&results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python"),
"Map key 'python' should rank above fuzzy noise, got: {:?}",
results[0].deep_match
);
}
}