1pub const DEFAULT_FUZZY_THRESHOLD: f64 = 0.95;
40pub const FALLBACK_THRESHOLD: f64 = 0.8;
42pub const OCCURRENCE_PREVIEW_CONTEXT: usize = 5;
44pub const OCCURRENCE_PREVIEW_MAX_LEN: usize = 80;
46pub const MAX_RECORDED_MATCHES: usize = 5;
48pub const DOMINANT_FUZZY_MIN_CONFIDENCE: f64 = 0.97;
50pub const MAX_FUZZY_WORK: u64 = 50_000_000;
58pub const DOMINANT_FUZZY_DELTA: f64 = 0.08;
60
61#[derive(Debug, Clone, PartialEq)]
63pub struct FuzzyMatch {
64 pub actual_text: String,
65 pub start_index: usize,
67 pub start_line: u32,
69 pub confidence: f64,
70}
71
72#[derive(Debug, Clone, Default, PartialEq)]
74pub struct MatchOutcome {
75 pub matched: Option<FuzzyMatch>,
76 pub closest: Option<FuzzyMatch>,
77 pub occurrences: Option<usize>,
78 pub occurrence_lines: Option<Vec<u32>>,
79 pub occurrence_previews: Option<Vec<String>>,
80 pub fuzzy_matches: Option<usize>,
81 pub dominant_fuzzy: Option<bool>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct ExcludedRange {
87 pub start_index: usize,
88 pub end_index: usize,
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct FindMatchOptions<'a> {
94 pub allow_fuzzy: bool,
95 pub threshold: Option<f64>,
97 pub excluded_ranges: &'a [ExcludedRange],
98}
99
100pub const fn is_js_whitespace(ch: char) -> bool {
103 matches!(
104 ch,
105 '\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
106 ..='\u{200A}'
107 | '\u{2028}'
108 | '\u{2029}'
109 | '\u{202F}'
110 | '\u{205F}'
111 | '\u{3000}'
112 | '\u{FEFF}'
113 )
114}
115
116pub fn js_trim(text: &str) -> &str {
118 text.trim_matches(is_js_whitespace)
119}
120
121pub fn utf16_len(text: &str) -> usize {
124 text.encode_utf16().count()
125}
126
127pub fn is_non_empty_line(line: &str) -> bool {
129 !js_trim(line).is_empty()
130}
131
132pub fn count_leading_whitespace(line: &str) -> usize {
134 line.bytes()
135 .take_while(|b| *b == b' ' || *b == b'\t')
136 .count()
137}
138
139pub fn normalize_for_fuzzy(line: &str) -> String {
144 let trimmed = js_trim(line);
145 if trimmed.is_empty() {
146 return String::new();
147 }
148 let mut out = String::with_capacity(trimmed.len());
149 let mut in_space = false;
150 for ch in trimmed.chars() {
151 let mapped = match ch {
152 '"' | '\u{201E}' | '\u{201F}' | '\u{AB}' | '\u{BB}' => '"',
153 '\'' | '\u{201A}' | '\u{201B}' | '`' | '\u{B4}' => '\'',
154 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2212}' => '-',
155 ' ' | '\t' => ' ',
156 other => other,
157 };
158 if mapped == ' ' {
159 if in_space {
160 continue;
161 }
162 in_space = true;
163 } else {
164 in_space = false;
165 }
166 out.push(mapped);
167 }
168 out
169}
170
171#[allow(clippy::suspicious_operation_groupings)]
172fn levenshtein_chars(a: &[char], b: &[char]) -> usize {
173 if a == b {
174 return 0;
175 }
176 let mut start = 0;
179 let shared_limit = a.len().min(b.len());
180 while start < shared_limit && a[start] == b[start] {
181 start += 1;
182 }
183 let mut a_end = a.len();
184 let mut b_end = b.len();
185 while a_end > start && b_end > start && a[a_end - 1] == b[b_end - 1] {
186 a_end -= 1;
187 b_end -= 1;
188 }
189 let mut longer = &a[start..a_end];
190 let mut shorter = &b[start..b_end];
191 if longer.is_empty() {
192 return shorter.len();
193 }
194 if shorter.is_empty() {
195 return longer.len();
196 }
197 if shorter.len() > longer.len() {
198 std::mem::swap(&mut longer, &mut shorter);
199 }
200
201 let mut row: Vec<usize> = (0..=shorter.len()).collect();
204 for (line, &a_char) in longer.iter().enumerate() {
205 let mut diagonal = row[0];
206 row[0] = line + 1;
207 for (column, &b_char) in shorter.iter().enumerate() {
208 let cell = column + 1;
209 let above = row[cell];
210 row[cell] = if a_char == b_char {
211 diagonal
212 } else {
213 (above + 1).min(row[cell - 1] + 1).min(diagonal + 1)
214 };
215 diagonal = above;
216 }
217 }
218 row[shorter.len()]
219}
220
221pub fn levenshtein_distance(a: &str, b: &str) -> usize {
226 let a_chars: Vec<char> = a.chars().collect();
227 let b_chars: Vec<char> = b.chars().collect();
228 levenshtein_chars(&a_chars, &b_chars)
229}
230
231pub fn similarity(a: &str, b: &str) -> f64 {
233 let a_chars: Vec<char> = a.chars().collect();
234 let b_chars: Vec<char> = b.chars().collect();
235 let max_len = a_chars.len().max(b_chars.len());
236 if max_len == 0 {
237 return 1.0;
238 }
239 1.0 - levenshtein_chars(&a_chars, &b_chars) as f64 / max_len as f64
240}
241
242fn format_preview_window(lines: &[&str], center_index: usize) -> String {
243 let start = center_index.saturating_sub(OCCURRENCE_PREVIEW_CONTEXT);
244 let end = lines
245 .len()
246 .min(center_index + OCCURRENCE_PREVIEW_CONTEXT + 1);
247 lines[start..end]
248 .iter()
249 .enumerate()
250 .map(|(offset, line)| {
251 let truncated = if utf16_len(line) > OCCURRENCE_PREVIEW_MAX_LEN {
252 let mut units = 0;
253 let mut text = String::new();
254 for ch in line.chars() {
255 let width = ch.len_utf16();
256 if units + width > OCCURRENCE_PREVIEW_MAX_LEN - 1 {
257 break;
258 }
259 text.push(ch);
260 units += width;
261 }
262 text.push('…');
263 text
264 } else {
265 (*line).to_owned()
266 };
267 format!(" {} | {truncated}", start + offset + 1)
268 })
269 .collect::<Vec<_>>()
270 .join("\n")
271}
272
273fn overlaps_excluded(start: usize, end: usize, ranges: &[ExcludedRange]) -> bool {
274 ranges
275 .iter()
276 .any(|range| start < range.end_index && end > range.start_index)
277}
278
279fn find_exact_match_outcome(
280 content: &str,
281 target: &str,
282 excluded_ranges: &[ExcludedRange],
283) -> Option<MatchOutcome> {
284 let mut first_index = None;
285 let mut occurrences = 0;
286 let mut recorded_indices = Vec::new();
287 let mut search_start = 0;
288 while search_start <= content.len().saturating_sub(target.len()) {
289 let Some(relative) = content[search_start..].find(target) else {
290 break;
291 };
292 let index = search_start + relative;
293 let end_index = index + target.len();
294 if !overlaps_excluded(index, end_index, excluded_ranges) {
295 first_index.get_or_insert(index);
296 occurrences += 1;
297 if recorded_indices.len() < MAX_RECORDED_MATCHES {
298 recorded_indices.push(index);
299 }
300 }
301 search_start = end_index;
302 }
303 let first_index = first_index?;
304 if occurrences > 1 {
305 let content_lines: Vec<&str> = content.split('\n').collect();
306 let mut occurrence_lines = Vec::with_capacity(recorded_indices.len());
307 let mut occurrence_previews = Vec::with_capacity(recorded_indices.len());
308 for index in recorded_indices {
309 let line_number = content[..index]
310 .bytes()
311 .filter(|byte| *byte == b'\n')
312 .count()
313 + 1;
314 occurrence_lines.push(line_number as u32);
315 occurrence_previews.push(format_preview_window(&content_lines, line_number - 1));
316 }
317 return Some(MatchOutcome {
318 occurrences: Some(occurrences),
319 occurrence_lines: Some(occurrence_lines),
320 occurrence_previews: Some(occurrence_previews),
321 ..MatchOutcome::default()
322 });
323 }
324 let start_line = content[..first_index]
325 .bytes()
326 .filter(|byte| *byte == b'\n')
327 .count() as u32
328 + 1;
329 Some(MatchOutcome {
330 matched: Some(FuzzyMatch {
331 actual_text: target.to_owned(),
332 start_index: first_index,
333 start_line,
334 confidence: 1.0,
335 }),
336 ..MatchOutcome::default()
337 })
338}
339
340fn relative_indent_depths(lines: &[&str]) -> Vec<usize> {
345 let indents: Vec<usize> = lines
346 .iter()
347 .map(|line| count_leading_whitespace(line))
348 .collect();
349 let non_empty_indents: Vec<usize> = lines
350 .iter()
351 .zip(&indents)
352 .filter_map(|(line, indent)| is_non_empty_line(line).then_some(*indent))
353 .collect();
354 let min_indent = non_empty_indents.iter().copied().min().unwrap_or(0);
355 let indent_unit = non_empty_indents
356 .iter()
357 .filter_map(|indent| indent.checked_sub(min_indent))
358 .filter(|step| *step > 0)
359 .min()
360 .unwrap_or(1);
361 lines
362 .iter()
363 .zip(indents)
364 .map(|(line, indent)| {
365 if !is_non_empty_line(line) || indent_unit == 0 {
366 0
367 } else {
368 ((indent - min_indent) as f64 / indent_unit as f64).round() as usize
369 }
370 })
371 .collect()
372}
373
374fn normalize_line(depth: Option<usize>, trimmed_normalized: &str) -> String {
378 match depth {
379 Some(depth) => format!("{depth}|{trimmed_normalized}"),
380 None => format!("|{trimmed_normalized}"),
381 }
382}
383
384fn line_offsets(lines: &[&str]) -> Vec<usize> {
387 let mut offsets = Vec::with_capacity(lines.len());
388 let mut offset = 0;
389 for (index, line) in lines.iter().enumerate() {
390 offsets.push(offset);
391 offset += line.len() + usize::from(index + 1 < lines.len());
392 }
393 offsets
394}
395
396#[derive(Debug)]
397struct BestFuzzyMatch {
398 best: Option<FuzzyMatch>,
399 above_threshold_count: usize,
400 second_best_score: f64,
401}
402
403fn best_fuzzy_match_core(
414 content_lines: &[&str],
415 target_lines: &[&str],
416 offsets: &[usize],
417 normalized_lines: &[String],
418 threshold: f64,
419 include_depth: bool,
420 excluded_ranges: &[ExcludedRange],
421) -> BestFuzzyMatch {
422 let target_normalized = normalize_lines(target_lines, include_depth, None);
423 let mut best = None;
424 let mut best_score = -1.0;
425 let mut second_best_score = -1.0;
426 let mut above_threshold_count = 0;
427 for start in 0..=content_lines.len() - target_lines.len() {
428 let start_index = offsets[start];
429 let end_line = start + target_lines.len() - 1;
430 let end_index = (offsets[end_line] + content_lines[end_line].len()).max(start_index + 1);
431 if overlaps_excluded(start_index, end_index, excluded_ranges) {
432 continue;
433 }
434 let window_normalized = normalize_lines(
435 &content_lines[start..start + target_lines.len()],
436 include_depth,
437 Some(&normalized_lines[start..start + target_lines.len()]),
438 );
439 let score = target_normalized
440 .iter()
441 .zip(&window_normalized)
442 .map(|(target, actual)| similarity(target, actual))
443 .sum::<f64>()
444 / target_lines.len() as f64;
445 if score >= threshold {
446 above_threshold_count += 1;
447 }
448 if score > best_score {
449 second_best_score = best_score;
450 best_score = score;
451 best = Some(FuzzyMatch {
452 actual_text: content_lines[start..start + target_lines.len()].join("\n"),
453 start_index,
454 start_line: start as u32 + 1,
455 confidence: score,
456 });
457 } else if score > second_best_score {
458 second_best_score = score;
459 }
460 }
461 BestFuzzyMatch {
462 best,
463 above_threshold_count,
464 second_best_score,
465 }
466}
467
468fn normalize_lines(
473 lines: &[&str],
474 include_depth: bool,
475 precomputed: Option<&[String]>,
476) -> Vec<String> {
477 let depths = include_depth.then(|| relative_indent_depths(lines));
478 lines
479 .iter()
480 .enumerate()
481 .map(|(index, line)| {
482 let trimmed_normalized = match precomputed {
483 Some(precomputed) => precomputed[index].clone(),
484 None => normalize_for_fuzzy(js_trim(line)),
485 };
486 let depth = depths.as_ref().map(|values| values[index]);
487 normalize_line(depth, &trimmed_normalized)
488 })
489 .collect()
490}
491
492fn best_fuzzy_match(
499 content: &str,
500 target: &str,
501 threshold: f64,
502 excluded_ranges: &[ExcludedRange],
503) -> BestFuzzyMatch {
504 let content_lines: Vec<&str> = content.split('\n').collect();
505 let target_lines: Vec<&str> = target.split('\n').collect();
506 if target.is_empty() || target_lines.len() > content_lines.len() {
507 return BestFuzzyMatch {
508 best: None,
509 above_threshold_count: 0,
510 second_best_score: 0.0,
511 };
512 }
513 let max_target_line_len = target_lines
517 .iter()
518 .map(|line| line.len())
519 .max()
520 .unwrap_or(0);
521 let work = (content.len() as u64) * (max_target_line_len as u64);
522 if work > MAX_FUZZY_WORK {
523 return BestFuzzyMatch {
524 best: None,
525 above_threshold_count: 0,
526 second_best_score: 0.0,
527 };
528 }
529 let offsets = line_offsets(&content_lines);
530 let normalized_lines: Vec<String> = content_lines
533 .iter()
534 .map(|line| normalize_for_fuzzy(js_trim(line)))
535 .collect();
536 let mut result = best_fuzzy_match_core(
537 &content_lines,
538 &target_lines,
539 &offsets,
540 &normalized_lines,
541 threshold,
542 true,
543 excluded_ranges,
544 );
545 if result
546 .best
547 .as_ref()
548 .is_some_and(|best| best.confidence < threshold && best.confidence >= FALLBACK_THRESHOLD)
549 {
550 let without_depth = best_fuzzy_match_core(
551 &content_lines,
552 &target_lines,
553 &offsets,
554 &normalized_lines,
555 threshold,
556 false,
557 excluded_ranges,
558 );
559 if without_depth.best.as_ref().is_some_and(|candidate| {
560 result
561 .best
562 .as_ref()
563 .is_none_or(|best| candidate.confidence > best.confidence)
564 }) {
565 result = without_depth;
566 }
567 }
568 result
569}
570
571pub fn find_match(content: &str, target: &str, options: &FindMatchOptions<'_>) -> MatchOutcome {
578 if target.is_empty() {
579 return MatchOutcome::default();
580 }
581 if let Some(exact) = find_exact_match_outcome(content, target, options.excluded_ranges) {
582 return exact;
583 }
584 let threshold = options.threshold.unwrap_or(DEFAULT_FUZZY_THRESHOLD);
585 let result = best_fuzzy_match(content, target, threshold, options.excluded_ranges);
586 let Some(best) = result.best else {
587 return MatchOutcome::default();
588 };
589 if options.allow_fuzzy && best.confidence >= threshold {
590 if result.above_threshold_count == 1 {
591 return MatchOutcome {
592 matched: Some(best.clone()),
593 closest: Some(best),
594 ..MatchOutcome::default()
595 };
596 }
597 if result.above_threshold_count > 1
601 && best.confidence >= DOMINANT_FUZZY_MIN_CONFIDENCE
602 && best.confidence - result.second_best_score >= DOMINANT_FUZZY_DELTA
603 {
604 return MatchOutcome {
605 matched: Some(best.clone()),
606 closest: Some(best),
607 fuzzy_matches: Some(result.above_threshold_count),
608 dominant_fuzzy: Some(true),
609 ..MatchOutcome::default()
610 };
611 }
612 }
613 MatchOutcome {
614 closest: Some(best),
615 fuzzy_matches: Some(result.above_threshold_count),
616 ..MatchOutcome::default()
617 }
618}
619
620pub fn first_different_line<'a>(
621 old_lines: &'a [&str],
622 new_lines: &'a [&str],
623) -> (&'a str, &'a str) {
624 for index in 0..old_lines.len().max(new_lines.len()) {
625 let old = old_lines.get(index).copied().unwrap_or("");
626 let new = new_lines.get(index).copied().unwrap_or("");
627 if old != new {
628 return (old, new);
629 }
630 }
631 (
632 old_lines.first().copied().unwrap_or(""),
633 new_lines.first().copied().unwrap_or(""),
634 )
635}
636
637pub fn format_match_error(
642 path: &str,
643 search_text: &str,
644 closest: Option<&FuzzyMatch>,
645 allow_fuzzy: bool,
646 threshold: f64,
647 fuzzy_matches: Option<usize>,
648) -> String {
649 let Some(closest) = closest else {
650 return if allow_fuzzy {
651 format!("Could not find a close enough match in {path}.")
652 } else {
653 format!(
654 "Could not find the exact text in {path}. The old text must match exactly including \
655 all whitespace and newlines."
656 )
657 };
658 };
659 let similarity_percent = (closest.confidence * 100.0).round() as i64;
660 let threshold_percent = (threshold * 100.0).round() as i64;
661 let search_lines: Vec<&str> = search_text.split('\n').collect();
662 let actual_lines: Vec<&str> = closest.actual_text.split('\n').collect();
663 let (old_line, new_line) = first_different_line(&search_lines, &actual_lines);
664 let hint = if allow_fuzzy {
665 if fuzzy_matches.is_some_and(|count| count > 1) {
666 format!(
667 "Found {} high-confidence matches. Provide more context to make it unique.",
668 fuzzy_matches.unwrap_or(0)
669 )
670 } else {
671 format!("Closest match was below the {threshold_percent}% similarity threshold.")
672 }
673 } else {
674 "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence \
675 matches."
676 .to_owned()
677 };
678 let heading = if allow_fuzzy {
679 format!("Could not find a close enough match in {path}.")
680 } else {
681 format!("Could not find the exact text in {path}.")
682 };
683 format!(
684 "{heading}\n\nClosest match ({similarity_percent}% similar) at line {}:\n - {old_line}\n \
685 + {new_line}\n{hint}",
686 closest.start_line
687 )
688}
689
690pub fn format_occurrence_error(path: &str, outcome: &MatchOutcome) -> String {
694 let occurrences = outcome.occurrences.unwrap_or(0);
695 let previews = outcome
696 .occurrence_previews
697 .as_ref()
698 .map_or_else(String::new, |items| items.join("\n\n"));
699 let more = if occurrences > MAX_RECORDED_MATCHES {
700 format!(" (showing first {MAX_RECORDED_MATCHES} of {occurrences})")
701 } else {
702 String::new()
703 };
704 format!(
705 "Found {occurrences} occurrences in {path}{more}:\n\n{previews}\n\nAdd more context lines \
706 to disambiguate."
707 )
708}