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