Skip to main content

rumdl_lib/utils/
fix_utils.rs

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