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    // The edits that survive, collected last to first and assembled in one
141    // forward pass, so applying many fixes costs one copy of the document
142    // rather than a shift of its tail per edit.
143    let mut kept: Vec<ApplicableEdit<'_>> = Vec::with_capacity(applicable.len());
144
145    // Track the lowest byte offset touched by an already-applied fix.
146    // Since fixes are sorted in reverse order (highest start first),
147    // any subsequent fix whose range.end > min_applied_start would
148    // overlap with an already-applied fix and corrupt the result.
149    let mut min_applied_start = usize::MAX;
150
151    for edit in applicable {
152        // Every range addresses the content as the rule saw it, so validate against
153        // that and not the buffer edits have already shrunk. Measuring the mutated
154        // buffer rejected ranges that were merely about to be skipped as
155        // overlapping, and the whole document's fix failed instead: MD039 declined
156        // to fix `[ a ![ x ](i.png) b ](t.md)` at all, and whether it did depended
157        // on how much unrelated text the document held after that line.
158        if edit.range.end > content.len() {
159            return Err(format!(
160                "Fix range end {} exceeds content length {}",
161                edit.range.end,
162                content.len()
163            ));
164        }
165
166        if edit.range.start > edit.range.end {
167            return Err(format!(
168                "Invalid fix range: start {} > end {}",
169                edit.range.start, edit.range.end
170            ));
171        }
172
173        // Reject ranges that do not lie on UTF-8 char boundaries. replace_range
174        // would panic on such a range; a rule emitting one is a bug, so surface
175        // it as an error rather than corrupting or crashing on the document.
176        if !content.is_char_boundary(edit.range.start) || !content.is_char_boundary(edit.range.end) {
177            return Err(format!(
178                "Fix range {}..{} does not lie on UTF-8 char boundaries",
179                edit.range.start, edit.range.end
180            ));
181        }
182
183        // Skip fixes that overlap with an already-applied fix to prevent
184        // offset corruption (e.g., nested link/image constructs in MD039).
185        if edit.range.end > min_applied_start {
186            continue;
187        }
188
189        min_applied_start = edit.range.start;
190        kept.push(edit);
191    }
192
193    let growth: usize = kept.iter().map(|edit| edit.replacement.len()).sum();
194    let mut result = String::with_capacity(content.len() + growth);
195    let mut copied = 0;
196    for edit in kept.iter().rev() {
197        result.push_str(&content[copied..edit.range.start]);
198        result.push_str(&edit.replacement);
199        copied = edit.range.end;
200    }
201    result.push_str(&content[copied..]);
202
203    // Ensure line endings are consistent with the original document
204    Ok(ensure_consistent_line_endings(content, &result))
205}
206
207/// One physical edit ready to apply. Either passes through a single `Fix`'s
208/// replacement borrow or holds the concatenation of several same-offset
209/// zero-width inserts.
210struct ApplicableEdit<'a> {
211    range: Range<usize>,
212    replacement: Cow<'a, str>,
213}
214
215/// Convert a single warning fix to a text edit-style representation
216/// This helps validate that individual warning fixes are correctly structured
217pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
218    if let Some(fix) = &warning.fix {
219        // Validate the fix range against content
220        if fix.range.end > content.len() {
221            return Err(format!(
222                "Fix range end {} exceeds content length {}",
223                fix.range.end,
224                content.len()
225            ));
226        }
227
228        Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
229    } else {
230        Err("Warning has no fix".to_string())
231    }
232}
233
234/// Helper function to validate that a fix range makes sense in the context
235pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
236    if fix.range.start > content.len() {
237        return Err(format!(
238            "Fix range start {} exceeds content length {}",
239            fix.range.start,
240            content.len()
241        ));
242    }
243
244    if fix.range.end > content.len() {
245        return Err(format!(
246            "Fix range end {} exceeds content length {}",
247            fix.range.end,
248            content.len()
249        ));
250    }
251
252    if fix.range.start > fix.range.end {
253        return Err(format!(
254            "Invalid fix range: start {} > end {}",
255            fix.range.start, fix.range.end
256        ));
257    }
258
259    // Mirror apply_warning_fixes: a range that splits a UTF-8 codepoint is
260    // invalid and would panic if applied.
261    if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
262        return Err(format!(
263            "Fix range {}..{} does not lie on UTF-8 char boundaries",
264            fix.range.start, fix.range.end
265        ));
266    }
267
268    Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::rule::{Fix, LintWarning, Severity};
275
276    #[test]
277    fn test_validate_fix_range_rejects_non_char_boundary() {
278        // "é" is 2 bytes (0xC3 0xA9); the range 1..2 splits it.
279        let content = "é world";
280        let fix = Fix::new(1..2, "x".to_string());
281        assert!(
282            validate_fix_range(content, &fix).is_err(),
283            "validate_fix_range must reject a range that splits a codepoint"
284        );
285        // A boundary-aligned range is still accepted.
286        let ok_fix = Fix::new(0..2, "x".to_string());
287        assert!(validate_fix_range(content, &ok_fix).is_ok());
288    }
289
290    #[test]
291    fn test_apply_single_fix() {
292        let content = "1.  Multiple spaces";
293        let warning = LintWarning {
294            message: "Too many spaces".to_string(),
295            line: 1,
296            column: 3,
297            end_line: 1,
298            end_column: 5,
299            severity: Severity::Warning,
300            fix: Some(Fix::new(2..4, " ".to_string())),
301            rule_name: Some("MD030".to_string()),
302        };
303
304        let result = apply_warning_fixes(content, &[warning]).unwrap();
305        assert_eq!(result, "1. Multiple spaces");
306    }
307
308    #[test]
309    fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
310        // A buggy rule could emit a fix range that lands inside a multi-byte
311        // codepoint. The central apply path must reject it rather than panic
312        // in replace_range. "é" is 2 bytes (0xC3 0xA9); 1..2 splits it.
313        let content = "é world";
314        let warning = LintWarning {
315            message: "bad range".to_string(),
316            line: 1,
317            column: 1,
318            end_line: 1,
319            end_column: 2,
320            severity: Severity::Warning,
321            fix: Some(Fix::new(1..2, "x".to_string())),
322            rule_name: Some("MDTEST".to_string()),
323        };
324
325        let result = apply_warning_fixes(content, &[warning]);
326        assert!(
327            result.is_err(),
328            "non-char-boundary range must be rejected, got {result:?}"
329        );
330    }
331
332    #[test]
333    fn test_apply_multiple_fixes() {
334        let content = "1.  First\n*   Second";
335        let warnings = vec![
336            LintWarning {
337                message: "Too many spaces".to_string(),
338                line: 1,
339                column: 3,
340                end_line: 1,
341                end_column: 5,
342                severity: Severity::Warning,
343                fix: Some(Fix::new(2..4, " ".to_string())),
344                rule_name: Some("MD030".to_string()),
345            },
346            LintWarning {
347                message: "Too many spaces".to_string(),
348                line: 2,
349                column: 2,
350                end_line: 2,
351                end_column: 5,
352                severity: Severity::Warning,
353                fix: Some(Fix::new(11..14, " ".to_string())),
354                rule_name: Some("MD030".to_string()),
355            },
356        ];
357
358        let result = apply_warning_fixes(content, &warnings).unwrap();
359        assert_eq!(result, "1. First\n* Second");
360    }
361
362    #[test]
363    fn test_apply_non_overlapping_fixes() {
364        // "Test  multiple    spaces"
365        //  0123456789012345678901234
366        //      ^^       ^^^^
367        //      4-6      14-18
368        let content = "Test  multiple    spaces";
369        let warnings = vec![
370            LintWarning {
371                message: "Too many spaces".to_string(),
372                line: 1,
373                column: 5,
374                end_line: 1,
375                end_column: 7,
376                severity: Severity::Warning,
377                fix: Some(Fix::new(4..6, " ".to_string())),
378                rule_name: Some("MD009".to_string()),
379            },
380            LintWarning {
381                message: "Too many spaces".to_string(),
382                line: 1,
383                column: 15,
384                end_line: 1,
385                end_column: 19,
386                severity: Severity::Warning,
387                fix: Some(Fix::new(14..18, " ".to_string())),
388                rule_name: Some("MD009".to_string()),
389            },
390        ];
391
392        let result = apply_warning_fixes(content, &warnings).unwrap();
393        assert_eq!(result, "Test multiple spaces");
394    }
395
396    #[test]
397    fn test_apply_duplicate_fixes() {
398        let content = "Test  content";
399        let warnings = vec![
400            LintWarning {
401                message: "Fix 1".to_string(),
402                line: 1,
403                column: 5,
404                end_line: 1,
405                end_column: 7,
406                severity: Severity::Warning,
407                fix: Some(Fix::new(4..6, " ".to_string())),
408                rule_name: Some("MD009".to_string()),
409            },
410            LintWarning {
411                message: "Fix 2 (duplicate)".to_string(),
412                line: 1,
413                column: 5,
414                end_line: 1,
415                end_column: 7,
416                severity: Severity::Warning,
417                fix: Some(Fix::new(4..6, " ".to_string())),
418                rule_name: Some("MD009".to_string()),
419            },
420        ];
421
422        // Duplicates should be deduplicated
423        let result = apply_warning_fixes(content, &warnings).unwrap();
424        assert_eq!(result, "Test content");
425    }
426
427    #[test]
428    fn test_apply_fixes_with_windows_line_endings() {
429        let content = "1.  First\r\n*   Second\r\n";
430        let warnings = vec![
431            LintWarning {
432                message: "Too many spaces".to_string(),
433                line: 1,
434                column: 3,
435                end_line: 1,
436                end_column: 5,
437                severity: Severity::Warning,
438                fix: Some(Fix::new(2..4, " ".to_string())),
439                rule_name: Some("MD030".to_string()),
440            },
441            LintWarning {
442                message: "Too many spaces".to_string(),
443                line: 2,
444                column: 2,
445                end_line: 2,
446                end_column: 5,
447                severity: Severity::Warning,
448                fix: Some(Fix::new(12..15, " ".to_string())),
449                rule_name: Some("MD030".to_string()),
450            },
451        ];
452
453        let result = apply_warning_fixes(content, &warnings).unwrap();
454        // The implementation normalizes line endings, which may double \r
455        // Just test that the fixes were applied correctly
456        assert!(result.contains("1. First"));
457        assert!(result.contains("* Second"));
458    }
459
460    #[test]
461    fn test_apply_fix_with_invalid_range() {
462        let content = "Short";
463        let warning = LintWarning {
464            message: "Invalid fix".to_string(),
465            line: 1,
466            column: 1,
467            end_line: 1,
468            end_column: 10,
469            severity: Severity::Warning,
470            fix: Some(Fix::new(0..100, "Replacement".to_string())),
471            rule_name: Some("TEST".to_string()),
472        };
473
474        let result = apply_warning_fixes(content, &[warning]);
475        assert!(result.is_err());
476        assert!(result.unwrap_err().contains("exceeds content length"));
477    }
478
479    #[test]
480    #[allow(clippy::reversed_empty_ranges)]
481    fn test_apply_fix_with_reversed_range() {
482        let content = "Hello world";
483        let warning = LintWarning {
484            message: "Invalid fix".to_string(),
485            line: 1,
486            column: 5,
487            end_line: 1,
488            end_column: 3,
489            severity: Severity::Warning,
490            fix: Some(Fix::new(10..5, "Test".to_string())),
491            rule_name: Some("TEST".to_string()),
492        };
493
494        let result = apply_warning_fixes(content, &[warning]);
495        assert!(result.is_err());
496        assert!(result.unwrap_err().contains("Invalid fix range"));
497    }
498
499    #[test]
500    fn test_apply_no_fixes() {
501        let content = "No changes needed";
502        let warnings = vec![LintWarning {
503            message: "Warning without fix".to_string(),
504            line: 1,
505            column: 1,
506            end_line: 1,
507            end_column: 5,
508            severity: Severity::Warning,
509            fix: None,
510            rule_name: Some("TEST".to_string()),
511        }];
512
513        let result = apply_warning_fixes(content, &warnings).unwrap();
514        assert_eq!(result, content);
515    }
516
517    #[test]
518    fn test_overlapping_fixes_skip_outer() {
519        // Simulates nested link/image: [ ![ alt ](img) ](url) suffix
520        // Inner fix: range 2..15 (image text)
521        // Outer fix: range 0..22 (link text) — overlaps inner
522        // Only the inner (higher start) should be applied; outer is skipped.
523        let content = "[ ![ alt ](img) ](url) suffix";
524        let warnings = vec![
525            LintWarning {
526                message: "Outer link".to_string(),
527                line: 1,
528                column: 1,
529                end_line: 1,
530                end_column: 22,
531                severity: Severity::Warning,
532                fix: Some(Fix::new(0..22, "[![alt](img)](url)".to_string())),
533                rule_name: Some("MD039".to_string()),
534            },
535            LintWarning {
536                message: "Inner image".to_string(),
537                line: 1,
538                column: 3,
539                end_line: 1,
540                end_column: 15,
541                severity: Severity::Warning,
542                fix: Some(Fix::new(2..15, "![alt](img)".to_string())),
543                rule_name: Some("MD039".to_string()),
544            },
545        ];
546
547        let result = apply_warning_fixes(content, &warnings).unwrap();
548        // Inner fix applied: "![ alt ](img)" → "![alt](img)"
549        // Outer fix skipped (overlaps). Suffix preserved.
550        assert_eq!(result, "[ ![alt](img) ](url) suffix");
551    }
552
553    #[test]
554    fn test_overlapping_outer_fix_is_skipped_without_trailing_slack() {
555        // Same nested shape with nothing after the link, which is what MD039
556        // actually produces for a whole-line link. Applying the inner fix shrinks
557        // the buffer past the outer fix's end, so measuring bounds against the
558        // buffer turned a skippable overlap into a hard error and threw away the
559        // inner fix along with it. The trailing text in the test above was the
560        // only reason it passed.
561        let content = "[ a ![ x ](i.png) b ](t.md)\n";
562        let warnings = vec![
563            LintWarning {
564                message: "Outer link".to_string(),
565                line: 1,
566                column: 1,
567                end_line: 1,
568                end_column: 28,
569                severity: Severity::Warning,
570                fix: Some(Fix::new(0..27, "[a ![ x ](i.png) b](t.md)".to_string())),
571                rule_name: Some("MD039".to_string()),
572            },
573            LintWarning {
574                message: "Inner image".to_string(),
575                line: 1,
576                column: 5,
577                end_line: 1,
578                end_column: 18,
579                severity: Severity::Warning,
580                fix: Some(Fix::new(4..17, "![x](i.png)".to_string())),
581                rule_name: Some("MD039".to_string()),
582            },
583        ];
584
585        let result = apply_warning_fixes(content, &warnings).unwrap();
586        assert_eq!(result, "[ a ![x](i.png) b ](t.md)\n");
587    }
588
589    #[test]
590    fn test_out_of_bounds_fix_is_still_rejected() {
591        // The bounds check still has to catch a rule addressing content that does
592        // not exist. Measuring the original rather than the edited buffer must not
593        // turn this into a silent no-op.
594        let content = "short\n";
595        let warnings = vec![LintWarning {
596            message: "Past the end".to_string(),
597            line: 1,
598            column: 1,
599            end_line: 1,
600            end_column: 1,
601            severity: Severity::Warning,
602            fix: Some(Fix::new(0..99, "x".to_string())),
603            rule_name: Some("MDTEST".to_string()),
604        }];
605
606        // Asserting the message, not just an error: the char-boundary check below
607        // also rejects an index past the end, so `is_err()` alone would pass with
608        // the bounds check deleted and prove nothing about it.
609        let err = apply_warning_fixes(content, &warnings).expect_err("out-of-bounds range must be rejected");
610        assert_eq!(err, "Fix range end 99 exceeds content length 6");
611    }
612
613    #[test]
614    fn test_apply_fix_with_additional_edits_atomic() {
615        // Models the MD054 ref-emit shape: a single Fix with a primary in-place
616        // rewrite of an inline link plus an additional_edit that appends a new
617        // reference definition at EOF. apply_warning_fixes must apply both halves
618        // — applying only the primary would leave a dangling reference.
619        let content = "See [docs](https://example.com) for details.\n";
620        let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
621        let appended = "\n[docs]: https://example.com\n".to_string();
622        let warnings = vec![LintWarning {
623            message: "Inconsistent link style".to_string(),
624            line: 1,
625            column: 5,
626            end_line: 1,
627            end_column: 32,
628            severity: Severity::Warning,
629            fix: Some(Fix::with_additional_edits(
630                primary_range,
631                "[docs]".to_string(),
632                vec![Fix::new(content.len()..content.len(), appended)],
633            )),
634            rule_name: Some("MD054".to_string()),
635        }];
636
637        let result = apply_warning_fixes(content, &warnings).unwrap();
638        assert!(
639            result.contains("See [docs] for details."),
640            "primary edit must rewrite the inline link in place: {result:?}"
641        );
642        assert!(
643            result.contains("[docs]: https://example.com"),
644            "additional edit must append the ref-def at EOF: {result:?}"
645        );
646        assert!(
647            !result.contains("[docs](https://example.com)"),
648            "the inline form must be gone after the atomic fix: {result:?}"
649        );
650    }
651
652    #[test]
653    fn test_apply_two_ref_emit_fixes_preserve_source_order() {
654        // Regression for the multi-warning EOF-insert case in MD054.
655        //
656        // Two distinct inline links each rewrite to a reference-style link
657        // and append a fresh `[label]: url` definition at EOF. Each Fix carries
658        // its primary in-place rewrite plus a zero-width additional_edit at
659        // `content.len()..content.len()` with a *different* replacement.
660        //
661        // The naive reverse-sort apply pipeline would `replace_range(N..N, B)`
662        // after `replace_range(N..N, A)`, which lands B *before* A — reversing
663        // declaration order and producing `<orig> + B + A` rather than
664        // `<orig> + A + B`. Coalescing same-offset zero-width inserts into a
665        // single concatenated replacement preserves source order.
666        let content = "See [a](https://a.com) and [b](https://b.com).\n";
667        let span_a = content.find("[a](https://a.com)").unwrap()
668            ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
669        let span_b = content.find("[b](https://b.com)").unwrap()
670            ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
671        let warnings = vec![
672            LintWarning {
673                message: "Inconsistent link style".to_string(),
674                line: 1,
675                column: 5,
676                end_line: 1,
677                end_column: 0,
678                severity: Severity::Warning,
679                fix: Some(Fix::with_additional_edits(
680                    span_a,
681                    "[a]".to_string(),
682                    vec![Fix::new(
683                        content.len()..content.len(),
684                        "[a]: https://a.com\n".to_string(),
685                    )],
686                )),
687                rule_name: Some("MD054".to_string()),
688            },
689            LintWarning {
690                message: "Inconsistent link style".to_string(),
691                line: 1,
692                column: 28,
693                end_line: 1,
694                end_column: 0,
695                severity: Severity::Warning,
696                fix: Some(Fix::with_additional_edits(
697                    span_b,
698                    "[b]".to_string(),
699                    vec![Fix::new(
700                        content.len()..content.len(),
701                        "[b]: https://b.com\n".to_string(),
702                    )],
703                )),
704                rule_name: Some("MD054".to_string()),
705            },
706        ];
707
708        let result = apply_warning_fixes(content, &warnings).unwrap();
709
710        // Both primary rewrites must land.
711        assert!(
712            result.contains("See [a] and [b]."),
713            "primary rewrites missing: {result:?}"
714        );
715        assert!(!result.contains("[a](https://a.com)"));
716        assert!(!result.contains("[b](https://b.com)"));
717
718        // Both ref-defs must land in source order — `[a]` before `[b]`.
719        let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
720        let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
721        assert!(
722            pos_a < pos_b,
723            "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
724        );
725    }
726
727    #[test]
728    fn test_warning_fix_to_edit() {
729        let content = "Hello world";
730        let warning = LintWarning {
731            message: "Test".to_string(),
732            line: 1,
733            column: 1,
734            end_line: 1,
735            end_column: 5,
736            severity: Severity::Warning,
737            fix: Some(Fix::new(0..5, "Hi".to_string())),
738            rule_name: Some("TEST".to_string()),
739        };
740
741        let edit = warning_fix_to_edit(content, &warning).unwrap();
742        assert_eq!(edit, (0, 5, "Hi".to_string()));
743    }
744
745    #[test]
746    fn test_warning_fix_to_edit_no_fix() {
747        let content = "Hello world";
748        let warning = LintWarning {
749            message: "Test".to_string(),
750            line: 1,
751            column: 1,
752            end_line: 1,
753            end_column: 5,
754            severity: Severity::Warning,
755            fix: None,
756            rule_name: Some("TEST".to_string()),
757        };
758
759        let result = warning_fix_to_edit(content, &warning);
760        assert!(result.is_err());
761        assert_eq!(result.unwrap_err(), "Warning has no fix");
762    }
763
764    #[test]
765    fn test_warning_fix_to_edit_invalid_range() {
766        let content = "Short";
767        let warning = LintWarning {
768            message: "Test".to_string(),
769            line: 1,
770            column: 1,
771            end_line: 1,
772            end_column: 10,
773            severity: Severity::Warning,
774            fix: Some(Fix::new(0..100, "Long replacement".to_string())),
775            rule_name: Some("TEST".to_string()),
776        };
777
778        let result = warning_fix_to_edit(content, &warning);
779        assert!(result.is_err());
780        assert!(result.unwrap_err().contains("exceeds content length"));
781    }
782
783    #[test]
784    fn test_validate_fix_range() {
785        let content = "Hello world";
786
787        // Valid range
788        let valid_fix = Fix::new(0..5, "Hi".to_string());
789        assert!(validate_fix_range(content, &valid_fix).is_ok());
790
791        // Invalid range (end > content length)
792        let invalid_fix = Fix::new(0..20, "Hi".to_string());
793        assert!(validate_fix_range(content, &invalid_fix).is_err());
794
795        // Invalid range (start > end) - create reversed range
796        let start = 5;
797        let end = 3;
798        let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
799        assert!(validate_fix_range(content, &invalid_fix2).is_err());
800    }
801
802    #[test]
803    fn test_validate_fix_range_edge_cases() {
804        let content = "Test";
805
806        // Empty range at start
807        let fix1 = Fix::new(0..0, "Insert".to_string());
808        assert!(validate_fix_range(content, &fix1).is_ok());
809
810        // Empty range at end
811        let fix2 = Fix::new(4..4, " append".to_string());
812        assert!(validate_fix_range(content, &fix2).is_ok());
813
814        // Full content replacement
815        let fix3 = Fix::new(0..4, "Replace".to_string());
816        assert!(validate_fix_range(content, &fix3).is_ok());
817
818        // Start exceeds content
819        let fix4 = Fix::new(10..11, "Invalid".to_string());
820        let result = validate_fix_range(content, &fix4);
821        assert!(result.is_err());
822        assert!(result.unwrap_err().contains("start 10 exceeds"));
823    }
824
825    #[test]
826    fn test_fix_ordering_stability() {
827        // Test that fixes with identical ranges maintain stable ordering
828        let content = "Test content here";
829        let warnings = vec![
830            LintWarning {
831                message: "First warning".to_string(),
832                line: 1,
833                column: 6,
834                end_line: 1,
835                end_column: 13,
836                severity: Severity::Warning,
837                fix: Some(Fix::new(5..12, "stuff".to_string())),
838                rule_name: Some("MD001".to_string()),
839            },
840            LintWarning {
841                message: "Second warning".to_string(),
842                line: 1,
843                column: 6,
844                end_line: 1,
845                end_column: 13,
846                severity: Severity::Warning,
847                fix: Some(Fix::new(5..12, "stuff".to_string())),
848                rule_name: Some("MD002".to_string()),
849            },
850        ];
851
852        // Both fixes are identical, so deduplication should leave only one
853        let result = apply_warning_fixes(content, &warnings).unwrap();
854        assert_eq!(result, "Test stuff here");
855    }
856
857    #[test]
858    fn test_line_ending_preservation() {
859        // Test Unix line endings
860        let content_unix = "Line 1\nLine 2\n";
861        let warning = LintWarning {
862            message: "Add text".to_string(),
863            line: 1,
864            column: 7,
865            end_line: 1,
866            end_column: 7,
867            severity: Severity::Warning,
868            fix: Some(Fix::new(6..6, " added".to_string())),
869            rule_name: Some("TEST".to_string()),
870        };
871
872        let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
873        assert_eq!(result, "Line 1 added\nLine 2\n");
874
875        // Test that Windows line endings work (even if normalization occurs)
876        let content_windows = "Line 1\r\nLine 2\r\n";
877        let warning_windows = LintWarning {
878            message: "Add text".to_string(),
879            line: 1,
880            column: 7,
881            end_line: 1,
882            end_column: 7,
883            severity: Severity::Warning,
884            fix: Some(Fix::new(6..6, " added".to_string())),
885            rule_name: Some("TEST".to_string()),
886        };
887
888        let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
889        // Just verify the fix was applied correctly
890        assert!(result_windows.starts_with("Line 1 added"));
891        assert!(result_windows.contains("Line 2"));
892    }
893
894    fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
895        LintWarning {
896            message: "test".to_string(),
897            line,
898            column: 1,
899            end_line,
900            end_column: 1,
901            severity: Severity::Warning,
902            fix: Some(Fix::new(0..1, "x".to_string())),
903            rule_name: Some(rule_name.to_string()),
904        }
905    }
906
907    #[test]
908    fn test_filter_warnings_disable_enable_block() {
909        let content =
910            "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
911        let inline_config = InlineConfig::from_content(content);
912
913        let warnings = vec![
914            make_warning(4, 4, "MD013"), // inside disabled block
915            make_warning(6, 6, "MD013"), // outside disabled block
916        ];
917
918        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
919        assert_eq!(filtered.len(), 1);
920        assert_eq!(filtered[0].line, 6);
921    }
922
923    #[test]
924    fn test_filter_warnings_disable_line() {
925        let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
926        let inline_config = InlineConfig::from_content(content);
927
928        let warnings = vec![
929            make_warning(1, 1, "MD009"), // disabled via disable-line
930            make_warning(2, 2, "MD009"), // not disabled
931        ];
932
933        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
934        assert_eq!(filtered.len(), 1);
935        assert_eq!(filtered[0].line, 2);
936    }
937
938    #[test]
939    fn test_filter_warnings_disable_next_line() {
940        let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
941        let inline_config = InlineConfig::from_content(content);
942
943        let warnings = vec![
944            make_warning(2, 2, "MD034"), // disabled via disable-next-line
945            make_warning(3, 3, "MD034"), // not disabled
946        ];
947
948        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
949        assert_eq!(filtered.len(), 1);
950        assert_eq!(filtered[0].line, 3);
951    }
952
953    #[test]
954    fn test_filter_warnings_sub_rule_name() {
955        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
956        let inline_config = InlineConfig::from_content(content);
957
958        // Sub-rule name like "MD029-style" should be stripped to "MD029"
959        let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
960
961        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
962        assert_eq!(filtered.len(), 1);
963        assert_eq!(filtered[0].line, 4);
964    }
965
966    #[test]
967    fn test_filter_warnings_multi_line_warning() {
968        // A warning spanning lines 3-5 where line 4 is disabled
969        let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
970        let inline_config = InlineConfig::from_content(content);
971
972        let warnings = vec![
973            make_warning(3, 5, "MD013"), // spans lines 3-5, line 4 is disabled
974            make_warning(6, 6, "MD013"), // not disabled
975        ];
976
977        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
978        // The multi-line warning should be filtered because one of its lines is disabled
979        assert_eq!(filtered.len(), 1);
980        assert_eq!(filtered[0].line, 6);
981    }
982
983    #[test]
984    fn test_filter_warnings_empty_input() {
985        let inline_config = InlineConfig::from_content("");
986        let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
987        assert!(filtered.is_empty());
988    }
989
990    #[test]
991    fn test_filter_warnings_none_disabled() {
992        let content = "line 1\nline 2\n";
993        let inline_config = InlineConfig::from_content(content);
994
995        let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
996
997        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
998        assert_eq!(filtered.len(), 2);
999    }
1000
1001    #[test]
1002    fn test_filter_warnings_all_disabled() {
1003        let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
1004        let inline_config = InlineConfig::from_content(content);
1005
1006        let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
1007
1008        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1009        assert!(filtered.is_empty());
1010    }
1011
1012    #[test]
1013    fn test_filter_warnings_end_line_zero_fallback() {
1014        // When end_line < line (e.g., end_line=0), should fall back to checking only warning.line
1015        let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
1016        let inline_config = InlineConfig::from_content(content);
1017
1018        let warnings = vec![make_warning(1, 0, "MD013")]; // end_line=0 < line=1
1019
1020        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1021        assert!(filtered.is_empty());
1022    }
1023
1024    #[test]
1025    fn test_filter_non_md_rule_name_preserves_dash() {
1026        // Verify that a non-MD rule name with a dash is NOT split by the helper.
1027        // The helper should pass "custom-rule" as-is to InlineConfig, not "custom".
1028        let content = "line 1\nline 2\n";
1029        let inline_config = InlineConfig::from_content(content);
1030
1031        let warnings = vec![make_warning(1, 1, "custom-rule")];
1032
1033        // With nothing disabled, the warning should pass through
1034        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
1035        assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
1036    }
1037
1038    #[test]
1039    fn test_filter_md_sub_rule_name_is_split() {
1040        // Verify that "MD029-style" is split to "MD029" for inline config lookup
1041        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1042        let inline_config = InlineConfig::from_content(content);
1043
1044        let warnings = vec![
1045            make_warning(2, 2, "MD029"), // disabled
1046            make_warning(4, 4, "MD029"), // not disabled
1047        ];
1048
1049        // Passing "MD029-style" as rule_name should still match "MD029" in inline config
1050        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1051        assert_eq!(filtered.len(), 1);
1052        assert_eq!(filtered[0].line, 4);
1053    }
1054
1055    #[test]
1056    fn test_filter_warnings_capture_restore() {
1057        let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
1058        let inline_config = InlineConfig::from_content(content);
1059
1060        let warnings = vec![
1061            make_warning(2, 2, "MD013"), // disabled by initial disable
1062            make_warning(5, 5, "MD013"), // re-enabled between capture/restore
1063            make_warning(7, 7, "MD013"), // after restore, back to disabled state
1064        ];
1065
1066        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1067        assert_eq!(filtered.len(), 1);
1068        assert_eq!(filtered[0].line, 5);
1069    }
1070}