Skip to main content

perl_lsp_symbol_query/
lib.rs

1//! Query matching and ranking helpers for workspace symbol search.
2//!
3//! This crate has a single responsibility: provide reusable matching and
4//! ranking primitives used by LSP symbol-search providers.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use std::cmp::Ordering;
12
13/// Returns `true` when a symbol name matches the provided query.
14///
15/// Matching strategy order:
16/// 1. Empty query (matches everything)
17/// 2. Exact case-insensitive match
18/// 3. Prefix case-insensitive match
19/// 4. Contains case-insensitive match
20/// 5. Subsequence/fuzzy case-insensitive match
21#[must_use]
22pub fn matches_query(name: &str, query: &str) -> bool {
23    if query.is_empty() {
24        return true;
25    }
26
27    let name_lower = name.to_lowercase();
28    let query_lower = query.to_lowercase();
29
30    if name_lower == query_lower {
31        return true;
32    }
33
34    if name_lower.starts_with(&query_lower) {
35        return true;
36    }
37
38    if name_lower.contains(&query_lower) {
39        return true;
40    }
41
42    is_subsequence(&name_lower, &query_lower)
43}
44
45/// Compares two symbol names by query relevance.
46///
47/// Ordering:
48/// 1. Exact match first
49/// 2. Prefix match second
50/// 3. Lexicographic name order
51#[must_use]
52pub fn compare_names_by_query(a: &str, b: &str, query: &str) -> Ordering {
53    let query_lower = query.to_lowercase();
54    let a_lower = a.to_lowercase();
55    let b_lower = b.to_lowercase();
56
57    let a_exact = a_lower == query_lower;
58    let b_exact = b_lower == query_lower;
59    let a_prefix = a_lower.starts_with(&query_lower);
60    let b_prefix = b_lower.starts_with(&query_lower);
61
62    match (a_exact, b_exact) {
63        (true, false) => Ordering::Less,
64        (false, true) => Ordering::Greater,
65        _ => match (a_prefix, b_prefix) {
66            (true, false) => Ordering::Less,
67            (false, true) => Ordering::Greater,
68            _ => a.cmp(b),
69        },
70    }
71}
72
73fn is_subsequence(haystack: &str, needle: &str) -> bool {
74    let mut needle_chars = needle.chars();
75    let mut current = needle_chars.next();
76
77    for ch in haystack.chars() {
78        if let Some(target) = current {
79            if ch == target {
80                current = needle_chars.next();
81            }
82        } else {
83            return true;
84        }
85    }
86
87    current.is_none()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::{compare_names_by_query, matches_query};
93
94    #[test]
95    fn query_matching_covers_exact_prefix_contains_and_fuzzy() {
96        assert!(matches_query("foo", "foo"));
97        assert!(matches_query("foobar", "foo"));
98        assert!(matches_query("foobar", "bar"));
99        assert!(matches_query("foobar", "fb"));
100        assert!(!matches_query("alpha", "zq"));
101    }
102
103    #[test]
104    fn empty_query_matches_anything() {
105        assert!(matches_query("anything", ""));
106    }
107
108    #[test]
109    fn relevance_prefers_exact_then_prefix_then_name_order() {
110        let mut names = ["foxtrot", "foo", "foobar", "alpha"];
111        names.sort_by(|a, b| compare_names_by_query(a, b, "foo"));
112
113        assert_eq!(names, ["foo", "foobar", "alpha", "foxtrot"]);
114    }
115}