Skip to main content

rumdl_lib/utils/
fix_utils.rs

1//! Utilities for applying fixes consistently between CLI and LSP
2//!
3//! This module provides shared logic for applying markdown fixes to ensure
4//! that both CLI batch fixes and LSP individual fixes produce identical results.
5
6use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use crate::utils::ensure_consistent_line_endings;
9use std::borrow::Cow;
10use std::ops::Range;
11
12/// Filter warnings by inline config, removing those on disabled lines.
13///
14/// Replicates the same filtering logic used in the check/reporting path
15/// (`src/lib.rs`) so that fix mode respects inline disable comments.
16pub fn filter_warnings_by_inline_config(
17    warnings: Vec<LintWarning>,
18    inline_config: &InlineConfig,
19    rule_name: &str,
20) -> Vec<LintWarning> {
21    let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
22        // Handle sub-rules like "MD029-style" -> "MD029"
23        // But only if the prefix looks like a rule ID (starts with "MD")
24        let prefix = &rule_name[..dash_pos];
25        if prefix.starts_with("MD") { prefix } else { rule_name }
26    } else {
27        rule_name
28    };
29
30    warnings
31        .into_iter()
32        .filter(|w| {
33            let end = if w.end_line >= w.line { w.end_line } else { w.line };
34            !(w.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
35        })
36        .collect()
37}
38
39/// Apply a list of warning fixes to content, simulating how the LSP client would apply them
40/// This is used for testing consistency between CLI and LSP fix methods
41pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
42    let mut fixes: Vec<(usize, &Fix)> = warnings
43        .iter()
44        .enumerate()
45        .filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
46        .flat_map(|(i, fix)| {
47            // A logical fix may carry additional edits at separate ranges
48            // (e.g. MD054 ref-emit fixes that rewrite a link in place AND
49            // append a new ref definition at EOF). Flatten so each edit
50            // participates in the same dedup/sort/apply pipeline.
51            std::iter::once((i, fix)).chain(fix.additional_edits.iter().map(move |e| (i, e)))
52        })
53        .collect();
54
55    // No-op fast path: if there are no actual fixes to apply, return the
56    // content unchanged. This avoids unnecessary line-ending normalization
57    // when all warnings were filtered out (e.g., by inline config) or had
58    // no fix attached.
59    if fixes.is_empty() {
60        return Ok(content.to_string());
61    }
62
63    // Sort ascending so the dedup/coalesce pass sees fixes that share a range
64    // as adjacent neighbors. Tie-break on warning index so declaration order
65    // is preserved when we later concatenate same-offset zero-width inserts.
66    fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
67        let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
68        if range_cmp != std::cmp::Ordering::Equal {
69            return range_cmp;
70        }
71        let end_cmp = fix_a.range.end.cmp(&fix_b.range.end);
72        if end_cmp != std::cmp::Ordering::Equal {
73            return end_cmp;
74        }
75        idx_a.cmp(idx_b)
76    });
77
78    // Dedup identical (range, replacement) pairs AND coalesce same-offset
79    // zero-width inserts into a single logical edit by concatenating their
80    // replacements in declaration order.
81    //
82    // The coalesce step is required because `replace_range(N..N, X)` followed
83    // by `replace_range(N..N, Y)` on the *same* document position produces
84    // `Y X` — `X` is already at offset N when `Y` inserts, so `Y` lands
85    // before it. With per-warning insertion (e.g., several MD054 ref-emit
86    // fixes appending different `[label]: url` definitions at EOF), that
87    // would reverse declaration order. Concatenating up front gives one
88    // `replace_range(N..N, X + Y)` that lands `X` then `Y` in source order.
89    let mut applicable: Vec<ApplicableEdit<'_>> = Vec::with_capacity(fixes.len());
90    let mut i = 0;
91    while i < fixes.len() {
92        let (_, current) = fixes[i];
93        let mut combined: Option<String> = None;
94        let is_zero_width = current.range.start == current.range.end;
95        let mut j = i + 1;
96        while j < fixes.len() {
97            let (_, next) = fixes[j];
98            if next.range != current.range {
99                break;
100            }
101            if next.replacement == current.replacement {
102                // Pure duplicate — drop and continue scanning siblings.
103                j += 1;
104                continue;
105            }
106            if !is_zero_width {
107                // Two different replacements competing for the same non-zero
108                // range is a rule-authoring bug at the call site, not something
109                // we can sensibly merge. Stop here so the apply loop sees only
110                // the first replacement (matching prior behavior).
111                break;
112            }
113            // Zero-width inserts at the same offset: concatenate.
114            let buf = combined.get_or_insert_with(|| current.replacement.clone());
115            buf.push_str(&next.replacement);
116            j += 1;
117        }
118
119        applicable.push(ApplicableEdit {
120            range: current.range.clone(),
121            replacement: match combined {
122                Some(owned) => Cow::Owned(owned),
123                None => Cow::Borrowed(current.replacement.as_str()),
124            },
125        });
126        i = j;
127    }
128
129    // Reverse-sort by range start so earlier-offset edits stay valid as later
130    // ones mutate the buffer. Coalescing collapsed the previous tertiary
131    // tiebreak case, so a simple two-key sort is enough.
132    applicable.sort_by(|a, b| {
133        let cmp = b.range.start.cmp(&a.range.start);
134        if cmp != std::cmp::Ordering::Equal {
135            return cmp;
136        }
137        b.range.end.cmp(&a.range.end)
138    });
139
140    let mut result = content.to_string();
141
142    // Track the lowest byte offset touched by an already-applied fix.
143    // Since fixes are sorted in reverse order (highest start first),
144    // any subsequent fix whose range.end > min_applied_start would
145    // overlap with an already-applied fix and corrupt the result.
146    let mut min_applied_start = usize::MAX;
147
148    for edit in applicable {
149        if edit.range.end > result.len() {
150            return Err(format!(
151                "Fix range end {} exceeds content length {}",
152                edit.range.end,
153                result.len()
154            ));
155        }
156
157        if edit.range.start > edit.range.end {
158            return Err(format!(
159                "Invalid fix range: start {} > end {}",
160                edit.range.start, edit.range.end
161            ));
162        }
163
164        // Reject ranges that do not lie on UTF-8 char boundaries. replace_range
165        // would panic on such a range; a rule emitting one is a bug, so surface
166        // it as an error rather than corrupting or crashing on the document.
167        if !result.is_char_boundary(edit.range.start) || !result.is_char_boundary(edit.range.end) {
168            return Err(format!(
169                "Fix range {}..{} does not lie on UTF-8 char boundaries",
170                edit.range.start, edit.range.end
171            ));
172        }
173
174        // Skip fixes that overlap with an already-applied fix to prevent
175        // offset corruption (e.g., nested link/image constructs in MD039).
176        if edit.range.end > min_applied_start {
177            continue;
178        }
179
180        result.replace_range(edit.range.clone(), &edit.replacement);
181        min_applied_start = edit.range.start;
182    }
183
184    // Ensure line endings are consistent with the original document
185    Ok(ensure_consistent_line_endings(content, &result))
186}
187
188/// One physical edit ready to apply. Either passes through a single `Fix`'s
189/// replacement borrow or holds the concatenation of several same-offset
190/// zero-width inserts.
191struct ApplicableEdit<'a> {
192    range: Range<usize>,
193    replacement: Cow<'a, str>,
194}
195
196/// Convert a single warning fix to a text edit-style representation
197/// This helps validate that individual warning fixes are correctly structured
198pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
199    if let Some(fix) = &warning.fix {
200        // Validate the fix range against content
201        if fix.range.end > content.len() {
202            return Err(format!(
203                "Fix range end {} exceeds content length {}",
204                fix.range.end,
205                content.len()
206            ));
207        }
208
209        Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
210    } else {
211        Err("Warning has no fix".to_string())
212    }
213}
214
215/// Helper function to validate that a fix range makes sense in the context
216pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
217    if fix.range.start > content.len() {
218        return Err(format!(
219            "Fix range start {} exceeds content length {}",
220            fix.range.start,
221            content.len()
222        ));
223    }
224
225    if fix.range.end > content.len() {
226        return Err(format!(
227            "Fix range end {} exceeds content length {}",
228            fix.range.end,
229            content.len()
230        ));
231    }
232
233    if fix.range.start > fix.range.end {
234        return Err(format!(
235            "Invalid fix range: start {} > end {}",
236            fix.range.start, fix.range.end
237        ));
238    }
239
240    // Mirror apply_warning_fixes: a range that splits a UTF-8 codepoint is
241    // invalid and would panic if applied.
242    if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
243        return Err(format!(
244            "Fix range {}..{} does not lie on UTF-8 char boundaries",
245            fix.range.start, fix.range.end
246        ));
247    }
248
249    Ok(())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::rule::{Fix, LintWarning, Severity};
256
257    #[test]
258    fn test_validate_fix_range_rejects_non_char_boundary() {
259        // "é" is 2 bytes (0xC3 0xA9); the range 1..2 splits it.
260        let content = "é world";
261        let fix = Fix::new(1..2, "x".to_string());
262        assert!(
263            validate_fix_range(content, &fix).is_err(),
264            "validate_fix_range must reject a range that splits a codepoint"
265        );
266        // A boundary-aligned range is still accepted.
267        let ok_fix = Fix::new(0..2, "x".to_string());
268        assert!(validate_fix_range(content, &ok_fix).is_ok());
269    }
270
271    #[test]
272    fn test_apply_single_fix() {
273        let content = "1.  Multiple spaces";
274        let warning = LintWarning {
275            message: "Too many spaces".to_string(),
276            line: 1,
277            column: 3,
278            end_line: 1,
279            end_column: 5,
280            severity: Severity::Warning,
281            fix: Some(Fix::new(2..4, " ".to_string())),
282            rule_name: Some("MD030".to_string()),
283        };
284
285        let result = apply_warning_fixes(content, &[warning]).unwrap();
286        assert_eq!(result, "1. Multiple spaces");
287    }
288
289    #[test]
290    fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
291        // A buggy rule could emit a fix range that lands inside a multi-byte
292        // codepoint. The central apply path must reject it rather than panic
293        // in replace_range. "é" is 2 bytes (0xC3 0xA9); 1..2 splits it.
294        let content = "é world";
295        let warning = LintWarning {
296            message: "bad range".to_string(),
297            line: 1,
298            column: 1,
299            end_line: 1,
300            end_column: 2,
301            severity: Severity::Warning,
302            fix: Some(Fix::new(1..2, "x".to_string())),
303            rule_name: Some("MDTEST".to_string()),
304        };
305
306        let result = apply_warning_fixes(content, &[warning]);
307        assert!(
308            result.is_err(),
309            "non-char-boundary range must be rejected, got {result:?}"
310        );
311    }
312
313    #[test]
314    fn test_apply_multiple_fixes() {
315        let content = "1.  First\n*   Second";
316        let warnings = vec![
317            LintWarning {
318                message: "Too many spaces".to_string(),
319                line: 1,
320                column: 3,
321                end_line: 1,
322                end_column: 5,
323                severity: Severity::Warning,
324                fix: Some(Fix::new(2..4, " ".to_string())),
325                rule_name: Some("MD030".to_string()),
326            },
327            LintWarning {
328                message: "Too many spaces".to_string(),
329                line: 2,
330                column: 2,
331                end_line: 2,
332                end_column: 5,
333                severity: Severity::Warning,
334                fix: Some(Fix::new(11..14, " ".to_string())),
335                rule_name: Some("MD030".to_string()),
336            },
337        ];
338
339        let result = apply_warning_fixes(content, &warnings).unwrap();
340        assert_eq!(result, "1. First\n* Second");
341    }
342
343    #[test]
344    fn test_apply_non_overlapping_fixes() {
345        // "Test  multiple    spaces"
346        //  0123456789012345678901234
347        //      ^^       ^^^^
348        //      4-6      14-18
349        let content = "Test  multiple    spaces";
350        let warnings = vec![
351            LintWarning {
352                message: "Too many spaces".to_string(),
353                line: 1,
354                column: 5,
355                end_line: 1,
356                end_column: 7,
357                severity: Severity::Warning,
358                fix: Some(Fix::new(4..6, " ".to_string())),
359                rule_name: Some("MD009".to_string()),
360            },
361            LintWarning {
362                message: "Too many spaces".to_string(),
363                line: 1,
364                column: 15,
365                end_line: 1,
366                end_column: 19,
367                severity: Severity::Warning,
368                fix: Some(Fix::new(14..18, " ".to_string())),
369                rule_name: Some("MD009".to_string()),
370            },
371        ];
372
373        let result = apply_warning_fixes(content, &warnings).unwrap();
374        assert_eq!(result, "Test multiple spaces");
375    }
376
377    #[test]
378    fn test_apply_duplicate_fixes() {
379        let content = "Test  content";
380        let warnings = vec![
381            LintWarning {
382                message: "Fix 1".to_string(),
383                line: 1,
384                column: 5,
385                end_line: 1,
386                end_column: 7,
387                severity: Severity::Warning,
388                fix: Some(Fix::new(4..6, " ".to_string())),
389                rule_name: Some("MD009".to_string()),
390            },
391            LintWarning {
392                message: "Fix 2 (duplicate)".to_string(),
393                line: 1,
394                column: 5,
395                end_line: 1,
396                end_column: 7,
397                severity: Severity::Warning,
398                fix: Some(Fix::new(4..6, " ".to_string())),
399                rule_name: Some("MD009".to_string()),
400            },
401        ];
402
403        // Duplicates should be deduplicated
404        let result = apply_warning_fixes(content, &warnings).unwrap();
405        assert_eq!(result, "Test content");
406    }
407
408    #[test]
409    fn test_apply_fixes_with_windows_line_endings() {
410        let content = "1.  First\r\n*   Second\r\n";
411        let warnings = vec![
412            LintWarning {
413                message: "Too many spaces".to_string(),
414                line: 1,
415                column: 3,
416                end_line: 1,
417                end_column: 5,
418                severity: Severity::Warning,
419                fix: Some(Fix::new(2..4, " ".to_string())),
420                rule_name: Some("MD030".to_string()),
421            },
422            LintWarning {
423                message: "Too many spaces".to_string(),
424                line: 2,
425                column: 2,
426                end_line: 2,
427                end_column: 5,
428                severity: Severity::Warning,
429                fix: Some(Fix::new(12..15, " ".to_string())),
430                rule_name: Some("MD030".to_string()),
431            },
432        ];
433
434        let result = apply_warning_fixes(content, &warnings).unwrap();
435        // The implementation normalizes line endings, which may double \r
436        // Just test that the fixes were applied correctly
437        assert!(result.contains("1. First"));
438        assert!(result.contains("* Second"));
439    }
440
441    #[test]
442    fn test_apply_fix_with_invalid_range() {
443        let content = "Short";
444        let warning = LintWarning {
445            message: "Invalid fix".to_string(),
446            line: 1,
447            column: 1,
448            end_line: 1,
449            end_column: 10,
450            severity: Severity::Warning,
451            fix: Some(Fix::new(0..100, "Replacement".to_string())),
452            rule_name: Some("TEST".to_string()),
453        };
454
455        let result = apply_warning_fixes(content, &[warning]);
456        assert!(result.is_err());
457        assert!(result.unwrap_err().contains("exceeds content length"));
458    }
459
460    #[test]
461    #[allow(clippy::reversed_empty_ranges)]
462    fn test_apply_fix_with_reversed_range() {
463        let content = "Hello world";
464        let warning = LintWarning {
465            message: "Invalid fix".to_string(),
466            line: 1,
467            column: 5,
468            end_line: 1,
469            end_column: 3,
470            severity: Severity::Warning,
471            fix: Some(Fix::new(10..5, "Test".to_string())),
472            rule_name: Some("TEST".to_string()),
473        };
474
475        let result = apply_warning_fixes(content, &[warning]);
476        assert!(result.is_err());
477        assert!(result.unwrap_err().contains("Invalid fix range"));
478    }
479
480    #[test]
481    fn test_apply_no_fixes() {
482        let content = "No changes needed";
483        let warnings = vec![LintWarning {
484            message: "Warning without fix".to_string(),
485            line: 1,
486            column: 1,
487            end_line: 1,
488            end_column: 5,
489            severity: Severity::Warning,
490            fix: None,
491            rule_name: Some("TEST".to_string()),
492        }];
493
494        let result = apply_warning_fixes(content, &warnings).unwrap();
495        assert_eq!(result, content);
496    }
497
498    #[test]
499    fn test_overlapping_fixes_skip_outer() {
500        // Simulates nested link/image: [ ![ alt ](img) ](url) suffix
501        // Inner fix: range 2..15 (image text)
502        // Outer fix: range 0..22 (link text) — overlaps inner
503        // Only the inner (higher start) should be applied; outer is skipped.
504        let content = "[ ![ alt ](img) ](url) suffix";
505        let warnings = vec![
506            LintWarning {
507                message: "Outer link".to_string(),
508                line: 1,
509                column: 1,
510                end_line: 1,
511                end_column: 22,
512                severity: Severity::Warning,
513                fix: Some(Fix::new(0..22, "[![alt](img)](url)".to_string())),
514                rule_name: Some("MD039".to_string()),
515            },
516            LintWarning {
517                message: "Inner image".to_string(),
518                line: 1,
519                column: 3,
520                end_line: 1,
521                end_column: 15,
522                severity: Severity::Warning,
523                fix: Some(Fix::new(2..15, "![alt](img)".to_string())),
524                rule_name: Some("MD039".to_string()),
525            },
526        ];
527
528        let result = apply_warning_fixes(content, &warnings).unwrap();
529        // Inner fix applied: "![ alt ](img)" → "![alt](img)"
530        // Outer fix skipped (overlaps). Suffix preserved.
531        assert_eq!(result, "[ ![alt](img) ](url) suffix");
532    }
533
534    #[test]
535    fn test_apply_fix_with_additional_edits_atomic() {
536        // Models the MD054 ref-emit shape: a single Fix with a primary in-place
537        // rewrite of an inline link plus an additional_edit that appends a new
538        // reference definition at EOF. apply_warning_fixes must apply both halves
539        // — applying only the primary would leave a dangling reference.
540        let content = "See [docs](https://example.com) for details.\n";
541        let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
542        let appended = "\n[docs]: https://example.com\n".to_string();
543        let warnings = vec![LintWarning {
544            message: "Inconsistent link style".to_string(),
545            line: 1,
546            column: 5,
547            end_line: 1,
548            end_column: 32,
549            severity: Severity::Warning,
550            fix: Some(Fix::with_additional_edits(
551                primary_range,
552                "[docs]".to_string(),
553                vec![Fix::new(content.len()..content.len(), appended)],
554            )),
555            rule_name: Some("MD054".to_string()),
556        }];
557
558        let result = apply_warning_fixes(content, &warnings).unwrap();
559        assert!(
560            result.contains("See [docs] for details."),
561            "primary edit must rewrite the inline link in place: {result:?}"
562        );
563        assert!(
564            result.contains("[docs]: https://example.com"),
565            "additional edit must append the ref-def at EOF: {result:?}"
566        );
567        assert!(
568            !result.contains("[docs](https://example.com)"),
569            "the inline form must be gone after the atomic fix: {result:?}"
570        );
571    }
572
573    #[test]
574    fn test_apply_two_ref_emit_fixes_preserve_source_order() {
575        // Regression for the multi-warning EOF-insert case in MD054.
576        //
577        // Two distinct inline links each rewrite to a reference-style link
578        // and append a fresh `[label]: url` definition at EOF. Each Fix carries
579        // its primary in-place rewrite plus a zero-width additional_edit at
580        // `content.len()..content.len()` with a *different* replacement.
581        //
582        // The naive reverse-sort apply pipeline would `replace_range(N..N, B)`
583        // after `replace_range(N..N, A)`, which lands B *before* A — reversing
584        // declaration order and producing `<orig> + B + A` rather than
585        // `<orig> + A + B`. Coalescing same-offset zero-width inserts into a
586        // single concatenated replacement preserves source order.
587        let content = "See [a](https://a.com) and [b](https://b.com).\n";
588        let span_a = content.find("[a](https://a.com)").unwrap()
589            ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
590        let span_b = content.find("[b](https://b.com)").unwrap()
591            ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
592        let warnings = vec![
593            LintWarning {
594                message: "Inconsistent link style".to_string(),
595                line: 1,
596                column: 5,
597                end_line: 1,
598                end_column: 0,
599                severity: Severity::Warning,
600                fix: Some(Fix::with_additional_edits(
601                    span_a,
602                    "[a]".to_string(),
603                    vec![Fix::new(
604                        content.len()..content.len(),
605                        "[a]: https://a.com\n".to_string(),
606                    )],
607                )),
608                rule_name: Some("MD054".to_string()),
609            },
610            LintWarning {
611                message: "Inconsistent link style".to_string(),
612                line: 1,
613                column: 28,
614                end_line: 1,
615                end_column: 0,
616                severity: Severity::Warning,
617                fix: Some(Fix::with_additional_edits(
618                    span_b,
619                    "[b]".to_string(),
620                    vec![Fix::new(
621                        content.len()..content.len(),
622                        "[b]: https://b.com\n".to_string(),
623                    )],
624                )),
625                rule_name: Some("MD054".to_string()),
626            },
627        ];
628
629        let result = apply_warning_fixes(content, &warnings).unwrap();
630
631        // Both primary rewrites must land.
632        assert!(
633            result.contains("See [a] and [b]."),
634            "primary rewrites missing: {result:?}"
635        );
636        assert!(!result.contains("[a](https://a.com)"));
637        assert!(!result.contains("[b](https://b.com)"));
638
639        // Both ref-defs must land in source order — `[a]` before `[b]`.
640        let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
641        let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
642        assert!(
643            pos_a < pos_b,
644            "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
645        );
646    }
647
648    #[test]
649    fn test_warning_fix_to_edit() {
650        let content = "Hello world";
651        let warning = LintWarning {
652            message: "Test".to_string(),
653            line: 1,
654            column: 1,
655            end_line: 1,
656            end_column: 5,
657            severity: Severity::Warning,
658            fix: Some(Fix::new(0..5, "Hi".to_string())),
659            rule_name: Some("TEST".to_string()),
660        };
661
662        let edit = warning_fix_to_edit(content, &warning).unwrap();
663        assert_eq!(edit, (0, 5, "Hi".to_string()));
664    }
665
666    #[test]
667    fn test_warning_fix_to_edit_no_fix() {
668        let content = "Hello world";
669        let warning = LintWarning {
670            message: "Test".to_string(),
671            line: 1,
672            column: 1,
673            end_line: 1,
674            end_column: 5,
675            severity: Severity::Warning,
676            fix: None,
677            rule_name: Some("TEST".to_string()),
678        };
679
680        let result = warning_fix_to_edit(content, &warning);
681        assert!(result.is_err());
682        assert_eq!(result.unwrap_err(), "Warning has no fix");
683    }
684
685    #[test]
686    fn test_warning_fix_to_edit_invalid_range() {
687        let content = "Short";
688        let warning = LintWarning {
689            message: "Test".to_string(),
690            line: 1,
691            column: 1,
692            end_line: 1,
693            end_column: 10,
694            severity: Severity::Warning,
695            fix: Some(Fix::new(0..100, "Long replacement".to_string())),
696            rule_name: Some("TEST".to_string()),
697        };
698
699        let result = warning_fix_to_edit(content, &warning);
700        assert!(result.is_err());
701        assert!(result.unwrap_err().contains("exceeds content length"));
702    }
703
704    #[test]
705    fn test_validate_fix_range() {
706        let content = "Hello world";
707
708        // Valid range
709        let valid_fix = Fix::new(0..5, "Hi".to_string());
710        assert!(validate_fix_range(content, &valid_fix).is_ok());
711
712        // Invalid range (end > content length)
713        let invalid_fix = Fix::new(0..20, "Hi".to_string());
714        assert!(validate_fix_range(content, &invalid_fix).is_err());
715
716        // Invalid range (start > end) - create reversed range
717        let start = 5;
718        let end = 3;
719        let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
720        assert!(validate_fix_range(content, &invalid_fix2).is_err());
721    }
722
723    #[test]
724    fn test_validate_fix_range_edge_cases() {
725        let content = "Test";
726
727        // Empty range at start
728        let fix1 = Fix::new(0..0, "Insert".to_string());
729        assert!(validate_fix_range(content, &fix1).is_ok());
730
731        // Empty range at end
732        let fix2 = Fix::new(4..4, " append".to_string());
733        assert!(validate_fix_range(content, &fix2).is_ok());
734
735        // Full content replacement
736        let fix3 = Fix::new(0..4, "Replace".to_string());
737        assert!(validate_fix_range(content, &fix3).is_ok());
738
739        // Start exceeds content
740        let fix4 = Fix::new(10..11, "Invalid".to_string());
741        let result = validate_fix_range(content, &fix4);
742        assert!(result.is_err());
743        assert!(result.unwrap_err().contains("start 10 exceeds"));
744    }
745
746    #[test]
747    fn test_fix_ordering_stability() {
748        // Test that fixes with identical ranges maintain stable ordering
749        let content = "Test content here";
750        let warnings = vec![
751            LintWarning {
752                message: "First warning".to_string(),
753                line: 1,
754                column: 6,
755                end_line: 1,
756                end_column: 13,
757                severity: Severity::Warning,
758                fix: Some(Fix::new(5..12, "stuff".to_string())),
759                rule_name: Some("MD001".to_string()),
760            },
761            LintWarning {
762                message: "Second warning".to_string(),
763                line: 1,
764                column: 6,
765                end_line: 1,
766                end_column: 13,
767                severity: Severity::Warning,
768                fix: Some(Fix::new(5..12, "stuff".to_string())),
769                rule_name: Some("MD002".to_string()),
770            },
771        ];
772
773        // Both fixes are identical, so deduplication should leave only one
774        let result = apply_warning_fixes(content, &warnings).unwrap();
775        assert_eq!(result, "Test stuff here");
776    }
777
778    #[test]
779    fn test_line_ending_preservation() {
780        // Test Unix line endings
781        let content_unix = "Line 1\nLine 2\n";
782        let warning = LintWarning {
783            message: "Add text".to_string(),
784            line: 1,
785            column: 7,
786            end_line: 1,
787            end_column: 7,
788            severity: Severity::Warning,
789            fix: Some(Fix::new(6..6, " added".to_string())),
790            rule_name: Some("TEST".to_string()),
791        };
792
793        let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
794        assert_eq!(result, "Line 1 added\nLine 2\n");
795
796        // Test that Windows line endings work (even if normalization occurs)
797        let content_windows = "Line 1\r\nLine 2\r\n";
798        let warning_windows = LintWarning {
799            message: "Add text".to_string(),
800            line: 1,
801            column: 7,
802            end_line: 1,
803            end_column: 7,
804            severity: Severity::Warning,
805            fix: Some(Fix::new(6..6, " added".to_string())),
806            rule_name: Some("TEST".to_string()),
807        };
808
809        let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
810        // Just verify the fix was applied correctly
811        assert!(result_windows.starts_with("Line 1 added"));
812        assert!(result_windows.contains("Line 2"));
813    }
814
815    fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
816        LintWarning {
817            message: "test".to_string(),
818            line,
819            column: 1,
820            end_line,
821            end_column: 1,
822            severity: Severity::Warning,
823            fix: Some(Fix::new(0..1, "x".to_string())),
824            rule_name: Some(rule_name.to_string()),
825        }
826    }
827
828    #[test]
829    fn test_filter_warnings_disable_enable_block() {
830        let content =
831            "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
832        let inline_config = InlineConfig::from_content(content);
833
834        let warnings = vec![
835            make_warning(4, 4, "MD013"), // inside disabled block
836            make_warning(6, 6, "MD013"), // outside disabled block
837        ];
838
839        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
840        assert_eq!(filtered.len(), 1);
841        assert_eq!(filtered[0].line, 6);
842    }
843
844    #[test]
845    fn test_filter_warnings_disable_line() {
846        let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
847        let inline_config = InlineConfig::from_content(content);
848
849        let warnings = vec![
850            make_warning(1, 1, "MD009"), // disabled via disable-line
851            make_warning(2, 2, "MD009"), // not disabled
852        ];
853
854        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
855        assert_eq!(filtered.len(), 1);
856        assert_eq!(filtered[0].line, 2);
857    }
858
859    #[test]
860    fn test_filter_warnings_disable_next_line() {
861        let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
862        let inline_config = InlineConfig::from_content(content);
863
864        let warnings = vec![
865            make_warning(2, 2, "MD034"), // disabled via disable-next-line
866            make_warning(3, 3, "MD034"), // not disabled
867        ];
868
869        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
870        assert_eq!(filtered.len(), 1);
871        assert_eq!(filtered[0].line, 3);
872    }
873
874    #[test]
875    fn test_filter_warnings_sub_rule_name() {
876        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
877        let inline_config = InlineConfig::from_content(content);
878
879        // Sub-rule name like "MD029-style" should be stripped to "MD029"
880        let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
881
882        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
883        assert_eq!(filtered.len(), 1);
884        assert_eq!(filtered[0].line, 4);
885    }
886
887    #[test]
888    fn test_filter_warnings_multi_line_warning() {
889        // A warning spanning lines 3-5 where line 4 is disabled
890        let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
891        let inline_config = InlineConfig::from_content(content);
892
893        let warnings = vec![
894            make_warning(3, 5, "MD013"), // spans lines 3-5, line 4 is disabled
895            make_warning(6, 6, "MD013"), // not disabled
896        ];
897
898        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
899        // The multi-line warning should be filtered because one of its lines is disabled
900        assert_eq!(filtered.len(), 1);
901        assert_eq!(filtered[0].line, 6);
902    }
903
904    #[test]
905    fn test_filter_warnings_empty_input() {
906        let inline_config = InlineConfig::from_content("");
907        let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
908        assert!(filtered.is_empty());
909    }
910
911    #[test]
912    fn test_filter_warnings_none_disabled() {
913        let content = "line 1\nline 2\n";
914        let inline_config = InlineConfig::from_content(content);
915
916        let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
917
918        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
919        assert_eq!(filtered.len(), 2);
920    }
921
922    #[test]
923    fn test_filter_warnings_all_disabled() {
924        let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
925        let inline_config = InlineConfig::from_content(content);
926
927        let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
928
929        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
930        assert!(filtered.is_empty());
931    }
932
933    #[test]
934    fn test_filter_warnings_end_line_zero_fallback() {
935        // When end_line < line (e.g., end_line=0), should fall back to checking only warning.line
936        let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
937        let inline_config = InlineConfig::from_content(content);
938
939        let warnings = vec![make_warning(1, 0, "MD013")]; // end_line=0 < line=1
940
941        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
942        assert!(filtered.is_empty());
943    }
944
945    #[test]
946    fn test_filter_non_md_rule_name_preserves_dash() {
947        // Verify that a non-MD rule name with a dash is NOT split by the helper.
948        // The helper should pass "custom-rule" as-is to InlineConfig, not "custom".
949        let content = "line 1\nline 2\n";
950        let inline_config = InlineConfig::from_content(content);
951
952        let warnings = vec![make_warning(1, 1, "custom-rule")];
953
954        // With nothing disabled, the warning should pass through
955        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
956        assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
957    }
958
959    #[test]
960    fn test_filter_md_sub_rule_name_is_split() {
961        // Verify that "MD029-style" is split to "MD029" for inline config lookup
962        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
963        let inline_config = InlineConfig::from_content(content);
964
965        let warnings = vec![
966            make_warning(2, 2, "MD029"), // disabled
967            make_warning(4, 4, "MD029"), // not disabled
968        ];
969
970        // Passing "MD029-style" as rule_name should still match "MD029" in inline config
971        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
972        assert_eq!(filtered.len(), 1);
973        assert_eq!(filtered[0].line, 4);
974    }
975
976    #[test]
977    fn test_filter_warnings_capture_restore() {
978        let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
979        let inline_config = InlineConfig::from_content(content);
980
981        let warnings = vec![
982            make_warning(2, 2, "MD013"), // disabled by initial disable
983            make_warning(5, 5, "MD013"), // re-enabled between capture/restore
984            make_warning(7, 7, "MD013"), // after restore, back to disabled state
985        ];
986
987        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
988        assert_eq!(filtered.len(), 1);
989        assert_eq!(filtered[0].line, 5);
990    }
991}