mz_deploy/suggest.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Nearest-name suggestions for names that did not resolve.
11//!
12//! Shared by the LSP's quick fixes and the deployment validation errors so a
13//! misspelling reads the same however the user hit it.
14
15/// Maximum number of suggestions returned by [`did_you_mean`].
16pub(crate) const MAX_DID_YOU_MEAN: usize = 3;
17
18/// Return up to [`MAX_DID_YOU_MEAN`] closest names from `candidates` to
19/// `needle`, sorted by Damerau-Levenshtein distance ascending. Names whose
20/// distance exceeds `max(2, needle.len() / 3)` are filtered out so unrelated
21/// matches don't surface as suggestions.
22///
23/// Allocations only happen for surviving candidates, so passing a borrowed
24/// slice (e.g. `pool.iter()`) is cheap even when the pool has thousands of
25/// names.
26pub(crate) fn did_you_mean<I, S>(needle: &str, candidates: I) -> Vec<String>
27where
28 I: IntoIterator<Item = S>,
29 S: AsRef<str>,
30{
31 let threshold = std::cmp::max(2, needle.len() / 3);
32 let mut scored: Vec<(usize, String)> = candidates
33 .into_iter()
34 .filter_map(|c| {
35 let s = c.as_ref();
36 let d = strsim::damerau_levenshtein(needle, s);
37 (d <= threshold).then(|| (d, s.to_string()))
38 })
39 .collect();
40 scored.sort_by_key(|(d, _)| *d);
41 scored.truncate(MAX_DID_YOU_MEAN);
42 scored.into_iter().map(|(_, s)| s).collect()
43}
44
45#[cfg(test)]
46mod tests {
47 use super::*;
48
49 #[mz_ore::test]
50 fn did_you_mean_returns_empty_for_no_close_match() {
51 let candidates = ["customer_name", "customer_id", "shipping_address"];
52 let out = did_you_mean("xyz", candidates.iter().map(|s| s.to_string()));
53 assert!(out.is_empty(), "expected no matches, got {:?}", out);
54 }
55
56 #[mz_ore::test]
57 fn did_you_mean_returns_exact_match_first() {
58 let candidates = ["customer_name", "customer_id"];
59 let out = did_you_mean("customer_name", candidates.iter().map(|s| s.to_string()));
60 // "customer_name" (distance 0) and "customer_id" (distance 4) are both within
61 // threshold max(2, 13/3) = 4, so both are returned, sorted by distance.
62 assert_eq!(
63 out,
64 vec!["customer_name".to_string(), "customer_id".to_string()]
65 );
66 }
67
68 #[mz_ore::test]
69 fn did_you_mean_handles_transposition() {
70 // Damerau-Levenshtein treats one transposition as distance 1.
71 let candidates = ["customer_name"];
72 let out = did_you_mean("cusotmer_name", candidates.iter().map(|s| s.to_string()));
73 assert_eq!(out, vec!["customer_name".to_string()]);
74 }
75
76 #[mz_ore::test]
77 fn did_you_mean_respects_max_three_limit() {
78 // Provide many candidates that are all close enough to hit the limit.
79 // "custoser_name" (distance 1 to): customer_name, custumer_name, cust_name, etc.
80 let candidates = [
81 "customer_name", // distance 1
82 "custumer_name", // distance 1 (typo: transposition)
83 "custoser_name_x", // distance 2 (one extra char)
84 "customers", // distance 6 (exceeds threshold, excluded)
85 "x_custoser_name", // distance 2 (one extra char prefix)
86 ];
87 let out = did_you_mean("custoser_name", candidates.iter().map(|s| s.to_string()));
88 // Should return at most 3, even though multiple candidates match.
89 assert!(out.len() <= 3, "should cap at 3, got {:?}", out);
90 // Best match (distance 1) comes first.
91 assert_eq!(out[0], "customer_name");
92 }
93
94 #[mz_ore::test]
95 fn did_you_mean_skips_empty_candidates() {
96 let candidates: Vec<String> = Vec::new();
97 let out = did_you_mean("anything", candidates);
98 assert!(out.is_empty());
99 }
100}