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