1use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
15use crate::ir::Module;
16use crate::types::{ActiveType, Unit};
17
18pub fn normalized_levenshtein(a: &str, b: &str) -> f64 {
27 let a = a.to_lowercase();
28 let b = b.to_lowercase();
29 let (al, bl) = (a.len(), b.len());
30 if al == 0 && bl == 0 {
31 return 1.0;
32 }
33 if al == 0 || bl == 0 {
34 return 0.0;
35 }
36 let dist = damerau_levenshtein(&a, &b);
37 1.0 - (dist as f64 / al.max(bl) as f64)
38}
39
40#[allow(clippy::needless_range_loop)]
48fn damerau_levenshtein(a: &str, b: &str) -> usize {
49 let a: Vec<char> = a.chars().collect();
50 let b: Vec<char> = b.chars().collect();
51 let (al, bl) = (a.len(), b.len());
52 if al == 0 {
53 return bl;
54 }
55 if bl == 0 {
56 return al;
57 }
58 let mut d = vec![vec![0usize; bl + 1]; al + 1];
59 for i in 0..=al {
60 d[i][0] = i;
61 }
62 for j in 0..=bl {
63 d[0][j] = j;
64 }
65 for i in 1..=al {
66 for j in 1..=bl {
67 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
68 let mut best = (d[i - 1][j] + 1)
69 .min(d[i][j - 1] + 1)
70 .min(d[i - 1][j - 1] + cost);
71 if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
72 best = best.min(d[i - 2][j - 2] + 1);
73 }
74 d[i][j] = best;
75 }
76 }
77 d[al][bl]
78}
79
80pub fn keyboard_similarity(a: &str, b: &str) -> f64 {
84 if a.len() != b.len() {
85 return 0.0;
86 }
87 let a = a.to_lowercase();
88 let b = b.to_lowercase();
89 let total = a.len() as f64;
90 if total == 0.0 {
91 return 1.0;
92 }
93 let matches = a
94 .chars()
95 .zip(b.chars())
96 .filter(|&(ca, cb)| ca == cb || are_adjacent_keys(ca, cb))
97 .count();
98 matches as f64 / total
99}
100
101fn are_adjacent_keys(a: char, b: char) -> bool {
102 let pos = |c: char| -> Option<(i32, i32)> {
103 let layout = [
104 "`1234567890-=",
105 "\tqwertyuiop[]\\",
106 " asdfghjkl;'",
107 " zxcvbnm,./",
108 ];
109 for (row, line) in layout.iter().enumerate() {
110 if let Some(col) = line.find(c) {
111 return Some((row as i32, col as i32));
112 }
113 }
114 None
115 };
116 match (pos(a), pos(b)) {
117 (Some((r1, c1)), Some((r2, c2))) => (r1 - r2).abs().max((c1 - c2).abs()) <= 1,
118 _ => false,
119 }
120}
121
122pub fn phonetic_similarity(a: &str, b: &str) -> f64 {
125 if metaphone(a) == metaphone(b) {
126 1.0
127 } else {
128 0.0
129 }
130}
131
132fn metaphone(s: &str) -> String {
137 let code = |c: char| -> Option<char> {
138 match c.to_ascii_lowercase() {
139 'b' | 'f' | 'p' | 'v' => Some('1'),
140 'c' | 'g' | 'j' | 'k' | 'q' | 's' | 'x' | 'z' => Some('2'),
141 'd' | 't' => Some('3'),
142 'l' => Some('4'),
143 'm' | 'n' => Some('5'),
144 'r' => Some('6'),
145 _ => None,
146 }
147 };
148 let mut out = String::new();
149 for (i, c) in s.chars().enumerate() {
150 if i == 0 {
151 out.push(c.to_ascii_lowercase());
152 } else if let Some(d) = code(c) {
153 if !out.ends_with(d) {
154 out.push(d);
155 }
156 }
157 }
158 while out.len() < 4 {
159 out.push('0');
160 }
161 out.truncate(4);
162 out
163}
164
165pub fn key_similarity(typo: &str, candidate: &str) -> f64 {
170 let lev = normalized_levenshtein(typo, candidate);
171 let kb = keyboard_similarity(typo, candidate);
172 let phon = phonetic_similarity(typo, candidate);
173 1.0 - (1.0 - lev) * (1.0 - 0.5 * kb) * (1.0 - 0.5 * phon)
174}
175
176pub fn best_key_match(typo: &str, legal: &[String], threshold: f64) -> Option<(String, f64)> {
180 let mut best: Option<(String, f64)> = None;
181 for k in legal {
182 let score = key_similarity(typo, k);
183 if score < threshold {
184 continue;
185 }
186 let better = match &best {
187 Some((_, best_score)) => score > *best_score,
188 None => true,
189 };
190 if better {
191 best = Some((k.clone(), score));
192 }
193 }
194 best
195}
196
197pub fn make_key_typo_diagnostic(
204 typo: &str,
205 suggestion: &str,
206 confidence: f64,
207 span: Option<Span>,
208) -> Diagnostic {
209 let safe = normalized_levenshtein(typo, suggestion) >= thresholds::AUTO_APPLY;
210 Diagnostic {
211 severity: if safe {
212 Severity::Info
213 } else {
214 Severity::Warning
215 },
216 code: "H005".into(),
217 message: format!("unknown key '{}', did you mean '{}'?", typo, suggestion),
218 path: Some(typo.into()),
219 span,
220 repair: Some(Repair {
221 original: typo.into(),
222 suggested: suggestion.into(),
223 kind: RepairKind::KeyTypo,
224 safe,
225 }),
226 confidence,
227 file: None,
230 }
231}
232
233pub fn try_coerce_int(s: &str) -> Option<i64> {
240 let trimmed = s.trim();
241 if trimmed.starts_with('$') || trimmed.starts_with('#') {
242 return None;
243 }
244 trimmed.parse::<i64>().ok()
245}
246
247pub fn try_coerce_duration(s: &str) -> Option<u64> {
250 let trimmed = s.trim();
251 let split = trimmed.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-');
252 match split {
253 Some(pos) if pos > 0 => {
254 let num: f64 = trimmed[..pos].parse().ok()?;
255 let suffix = &trimmed[pos..];
256 Unit::from_suffix(suffix)?.normalize_time_to_ms(num)
257 }
258 _ => None,
259 }
260}
261
262pub fn sentinel_meaning(word: &str) -> Option<&'static str> {
266 match word.to_lowercase().as_str() {
267 "unlimited" | "no_limit" | "max" => Some("limit"),
268 "none" | "null" | "off" => Some("absent"),
269 "inf" | "infinity" | "forever" => Some("infinite"),
270 "any" | "all" | "wildcard" | "*" => Some("wildcard"),
271 _ => None,
272 }
273}
274
275pub fn coerce_string_to(value: &str, target: &ActiveType) -> Option<crate::ir::Value> {
279 match target {
280 ActiveType::Int => try_coerce_int(value).map(crate::ir::Value::Int),
281 ActiveType::Float => value
282 .trim()
283 .parse::<f64>()
284 .ok()
285 .map(crate::ir::Value::Float),
286 ActiveType::Bool => match value.to_lowercase().as_str() {
287 "true" | "yes" | "1" | "on" => Some(crate::ir::Value::Bool(true)),
288 "false" | "no" | "0" | "off" => Some(crate::ir::Value::Bool(false)),
289 _ => None,
290 },
291 ActiveType::Duration => try_coerce_duration(value).map(crate::ir::Value::DurationMs),
292 _ => None,
293 }
294}
295
296pub fn heal(module: &mut Module) -> usize {
305 module.healing.len()
306}