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