use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
use crate::ir::Module;
use crate::types::{ActiveType, Unit};
pub fn normalized_levenshtein(a: &str, b: &str) -> f64 {
let a = a.to_lowercase();
let b = b.to_lowercase();
let (al, bl) = (a.len(), b.len());
if al == 0 && bl == 0 {
return 1.0;
}
if al == 0 || bl == 0 {
return 0.0;
}
let dist = damerau_levenshtein(&a, &b);
1.0 - (dist as f64 / al.max(bl) as f64)
}
#[allow(clippy::needless_range_loop)]
fn damerau_levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (al, bl) = (a.len(), b.len());
if al == 0 {
return bl;
}
if bl == 0 {
return al;
}
let mut d = vec![vec![0usize; bl + 1]; al + 1];
for i in 0..=al {
d[i][0] = i;
}
for j in 0..=bl {
d[0][j] = j;
}
for i in 1..=al {
for j in 1..=bl {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
let mut best = (d[i - 1][j] + 1)
.min(d[i][j - 1] + 1)
.min(d[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(d[i - 2][j - 2] + 1);
}
d[i][j] = best;
}
}
d[al][bl]
}
pub fn keyboard_similarity(a: &str, b: &str) -> f64 {
if a.len() != b.len() {
return 0.0;
}
let a = a.to_lowercase();
let b = b.to_lowercase();
let total = a.len() as f64;
if total == 0.0 {
return 1.0;
}
let matches = a
.chars()
.zip(b.chars())
.filter(|&(ca, cb)| ca == cb || are_adjacent_keys(ca, cb))
.count();
matches as f64 / total
}
fn are_adjacent_keys(a: char, b: char) -> bool {
let pos = |c: char| -> Option<(i32, i32)> {
let layout = [
"`1234567890-=",
"\tqwertyuiop[]\\",
" asdfghjkl;'",
" zxcvbnm,./",
];
for (row, line) in layout.iter().enumerate() {
if let Some(col) = line.find(c) {
return Some((row as i32, col as i32));
}
}
None
};
match (pos(a), pos(b)) {
(Some((r1, c1)), Some((r2, c2))) => (r1 - r2).abs().max((c1 - c2).abs()) <= 1,
_ => false,
}
}
pub fn phonetic_similarity(a: &str, b: &str) -> f64 {
if metaphone(a) == metaphone(b) {
1.0
} else {
0.0
}
}
fn metaphone(s: &str) -> String {
let code = |c: char| -> Option<char> {
match c.to_ascii_lowercase() {
'b' | 'f' | 'p' | 'v' => Some('1'),
'c' | 'g' | 'j' | 'k' | 'q' | 's' | 'x' | 'z' => Some('2'),
'd' | 't' => Some('3'),
'l' => Some('4'),
'm' | 'n' => Some('5'),
'r' => Some('6'),
_ => None,
}
};
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i == 0 {
out.push(c.to_ascii_lowercase());
} else if let Some(d) = code(c) {
if !out.ends_with(d) {
out.push(d);
}
}
}
while out.len() < 4 {
out.push('0');
}
out.truncate(4);
out
}
pub fn key_similarity(typo: &str, candidate: &str) -> f64 {
let lev = normalized_levenshtein(typo, candidate);
let kb = keyboard_similarity(typo, candidate);
let phon = phonetic_similarity(typo, candidate);
1.0 - (1.0 - lev) * (1.0 - 0.5 * kb) * (1.0 - 0.5 * phon)
}
pub fn best_key_match(typo: &str, legal: &[String], threshold: f64) -> Option<(String, f64)> {
let mut best: Option<(String, f64)> = None;
for k in legal {
let score = key_similarity(typo, k);
if score < threshold {
continue;
}
let better = match &best {
Some((_, best_score)) => score > *best_score,
None => true,
};
if better {
best = Some((k.clone(), score));
}
}
best
}
pub fn make_key_typo_diagnostic(
typo: &str,
suggestion: &str,
confidence: f64,
span: Option<Span>,
) -> Diagnostic {
let safe = normalized_levenshtein(typo, suggestion) >= thresholds::AUTO_APPLY;
Diagnostic {
severity: if safe {
Severity::Info
} else {
Severity::Warning
},
code: "H005".into(),
message: format!("unknown key '{}', did you mean '{}'?", typo, suggestion),
path: Some(typo.into()),
span,
repair: Some(Repair {
original: typo.into(),
suggested: suggestion.into(),
kind: RepairKind::KeyTypo,
safe,
}),
confidence,
file: None,
}
}
pub fn try_coerce_int(s: &str) -> Option<i64> {
let trimmed = s.trim();
if trimmed.starts_with('$') || trimmed.starts_with('#') {
return None;
}
trimmed.parse::<i64>().ok()
}
pub fn try_coerce_duration(s: &str) -> Option<u64> {
let trimmed = s.trim();
let split = trimmed.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-');
match split {
Some(pos) if pos > 0 => {
let num: f64 = trimmed[..pos].parse().ok()?;
let suffix = &trimmed[pos..];
Unit::from_suffix(suffix)?.normalize_time_to_ms(num)
}
_ => None,
}
}
pub fn sentinel_meaning(word: &str) -> Option<&'static str> {
match word.to_lowercase().as_str() {
"unlimited" | "no_limit" | "max" => Some("limit"),
"none" | "null" | "off" => Some("absent"),
"inf" | "infinity" | "forever" => Some("infinite"),
"any" | "all" | "wildcard" | "*" => Some("wildcard"),
_ => None,
}
}
pub fn coerce_string_to(value: &str, target: &ActiveType) -> Option<crate::ir::Value> {
match target {
ActiveType::Int => try_coerce_int(value).map(crate::ir::Value::Int),
ActiveType::Float => value
.trim()
.parse::<f64>()
.ok()
.map(crate::ir::Value::Float),
ActiveType::Bool => match value.to_lowercase().as_str() {
"true" | "yes" | "1" | "on" => Some(crate::ir::Value::Bool(true)),
"false" | "no" | "0" | "off" => Some(crate::ir::Value::Bool(false)),
_ => None,
},
ActiveType::Duration => try_coerce_duration(value).map(crate::ir::Value::DurationMs),
_ => None,
}
}
pub fn heal(module: &mut Module) -> usize {
module.healing.len()
}