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