Skip to main content

mdbook_lint_core/
engine.rs

1//! Rule provider system and lint engine.
2
3use crate::config::Config;
4use crate::error::Result;
5use crate::registry::RuleRegistry;
6use serde_json::Value;
7
8/// Trait for rule providers to register rules with the engine
9pub trait RuleProvider: Send + Sync {
10    /// Unique identifier for this rule provider
11    fn provider_id(&self) -> &'static str;
12
13    /// Human-readable description of this rule provider
14    fn description(&self) -> &'static str;
15
16    /// Version of this rule provider
17    fn version(&self) -> &'static str;
18
19    /// Register all rules from this provider with the registry
20    fn register_rules(&self, registry: &mut RuleRegistry);
21
22    /// Provider-specific configuration schema
23    fn config_schema(&self) -> Option<Value> {
24        None
25    }
26
27    /// List of rule IDs that this provider registers
28    fn rule_ids(&self) -> Vec<&'static str> {
29        Vec::new()
30    }
31
32    /// Provider initialization hook
33    fn initialize(&self) -> Result<()> {
34        Ok(())
35    }
36
37    /// Register all rules from this provider with the registry, using configuration
38    /// This method allows rules to be configured at registration time.
39    /// The default implementation calls the legacy register_rules method for backward compatibility.
40    fn register_rules_with_config(&self, registry: &mut RuleRegistry, _config: Option<&Config>) {
41        // Default implementation calls the old method for backward compatibility
42        self.register_rules(registry);
43    }
44}
45
46/// Registry for managing rule providers and creating engines
47#[derive(Default)]
48pub struct PluginRegistry {
49    providers: Vec<Box<dyn RuleProvider>>,
50}
51
52impl PluginRegistry {
53    /// Create a new empty plugin registry
54    pub fn new() -> Self {
55        Self {
56            providers: Vec::new(),
57        }
58    }
59
60    /// Register a rule provider
61    pub fn register_provider(&mut self, provider: Box<dyn RuleProvider>) -> Result<()> {
62        // Initialize the provider
63        provider.initialize()?;
64
65        // Check for duplicate provider IDs
66        let provider_id = provider.provider_id();
67        if self
68            .providers
69            .iter()
70            .any(|p| p.provider_id() == provider_id)
71        {
72            return Err(crate::error::MdBookLintError::plugin_error(format!(
73                "Provider with ID '{provider_id}' is already registered"
74            )));
75        }
76
77        self.providers.push(provider);
78        Ok(())
79    }
80
81    /// Get all registered providers
82    pub fn providers(&self) -> &[Box<dyn RuleProvider>] {
83        &self.providers
84    }
85
86    /// Get a provider by ID
87    pub fn get_provider(&self, id: &str) -> Option<&dyn RuleProvider> {
88        self.providers
89            .iter()
90            .find(|p| p.provider_id() == id)
91            .map(|p| p.as_ref())
92    }
93
94    /// Create a rule registry with all registered providers
95    pub fn create_rule_registry(&self) -> Result<RuleRegistry> {
96        self.create_rule_registry_with_config(None)
97    }
98
99    /// Create a rule registry with all registered providers, using configuration
100    pub fn create_rule_registry_with_config(
101        &self,
102        config: Option<&Config>,
103    ) -> Result<RuleRegistry> {
104        let mut registry = RuleRegistry::new();
105
106        for provider in &self.providers {
107            provider.register_rules_with_config(&mut registry, config);
108        }
109
110        Ok(registry)
111    }
112
113    /// Create a lint engine with all registered providers
114    pub fn create_engine(&self) -> Result<LintEngine> {
115        self.create_engine_with_config(None)
116    }
117
118    /// Create a lint engine with all registered providers, using configuration
119    pub fn create_engine_with_config(&self, config: Option<&Config>) -> Result<LintEngine> {
120        let registry = self.create_rule_registry_with_config(config)?;
121        Ok(LintEngine::with_registry(registry))
122    }
123
124    /// List all available rule IDs from all providers
125    pub fn available_rule_ids(&self) -> Vec<String> {
126        let mut rule_ids = Vec::new();
127
128        for provider in &self.providers {
129            for rule_id in provider.rule_ids() {
130                rule_ids.push(rule_id.to_string());
131            }
132        }
133
134        rule_ids.sort();
135        rule_ids.dedup();
136        rule_ids
137    }
138
139    /// Get provider information for debugging/introspection
140    pub fn provider_info(&self) -> Vec<ProviderInfo> {
141        self.providers
142            .iter()
143            .map(|p| ProviderInfo {
144                id: p.provider_id().to_string(),
145                description: p.description().to_string(),
146                version: p.version().to_string(),
147                rule_count: p.rule_ids().len(),
148            })
149            .collect()
150    }
151}
152
153/// Information about a registered provider (for debugging/introspection)
154#[derive(Debug, Clone)]
155pub struct ProviderInfo {
156    pub id: String,
157    pub description: String,
158    pub version: String,
159    pub rule_count: usize,
160}
161
162/// Markdown linting engine
163pub struct LintEngine {
164    registry: RuleRegistry,
165}
166
167impl LintEngine {
168    /// Create a new lint engine with no rules
169    pub fn new() -> Self {
170        Self {
171            registry: RuleRegistry::new(),
172        }
173    }
174
175    /// Create a lint engine with an existing rule registry
176    pub fn with_registry(registry: RuleRegistry) -> Self {
177        Self { registry }
178    }
179
180    /// Get the underlying rule registry
181    pub fn registry(&self) -> &RuleRegistry {
182        &self.registry
183    }
184
185    /// Get a mutable reference to the rule registry
186    pub fn registry_mut(&mut self) -> &mut RuleRegistry {
187        &mut self.registry
188    }
189
190    /// Lint a document with all registered rules
191    pub fn lint_document(&self, document: &crate::Document) -> Result<Vec<crate::Violation>> {
192        self.registry.check_document_optimized(document)
193    }
194
195    /// Lint a document with specific configuration
196    pub fn lint_document_with_config(
197        &self,
198        document: &crate::Document,
199        config: &crate::Config,
200    ) -> Result<Vec<crate::Violation>> {
201        self.registry
202            .check_document_optimized_with_config(document, config)
203    }
204
205    /// Lint content string directly (convenience method)
206    ///
207    /// # Arguments
208    ///
209    /// * `content` - The markdown content to lint
210    /// * `source_label` - A label for error messages (e.g., filename). This is NOT read from disk.
211    pub fn lint_content(&self, content: &str, source_label: &str) -> Result<Vec<crate::Violation>> {
212        let document =
213            crate::Document::new(content.to_string(), std::path::PathBuf::from(source_label))?;
214        self.lint_document(&document)
215    }
216
217    /// Apply a single fix to content
218    ///
219    /// Returns `Some(fixed_content)` if the fix was applied, `None` if the violation
220    /// has no fix or the fix couldn't be applied.
221    ///
222    /// # Example
223    ///
224    /// ```rust,ignore
225    /// let violations = engine.lint_content("# Test\n\n\n\n", "test.md")?;
226    /// if let Some(v) = violations.first() {
227    ///     if let Some(fixed) = engine.apply_fix("# Test\n\n\n\n", v) {
228    ///         println!("Fixed: {}", fixed);
229    ///     }
230    /// }
231    /// ```
232    pub fn apply_fix(&self, content: &str, violation: &crate::Violation) -> Option<String> {
233        let fix = violation.fix.as_ref()?;
234
235        let range = fix.byte_range(content)?;
236        let replacement = fix.replacement.as_deref().unwrap_or("");
237        let mut result = content.to_string();
238        result.replace_range(range, replacement);
239        Some(result)
240    }
241
242    /// Apply all available fixes to content
243    ///
244    /// Applies fixes from violations that have them, processing in reverse position
245    /// order to avoid offset issues. Returns the fixed content and a list of
246    /// violations that could not be fixed.
247    ///
248    /// # Example
249    ///
250    /// ```rust,ignore
251    /// let violations = engine.lint_content(content, "test.md")?;
252    /// let (fixed_content, unfixed) = engine.apply_fixes(content, &violations);
253    /// if fixed_content != content {
254    ///     println!("Applied {} fixes", violations.len() - unfixed.len());
255    /// }
256    /// ```
257    pub fn apply_fixes(
258        &self,
259        content: &str,
260        violations: &[crate::Violation],
261    ) -> (String, Vec<crate::Violation>) {
262        if violations.is_empty() {
263            return (content.to_string(), Vec::new());
264        }
265
266        struct PlannedFix<'a> {
267            violation_index: usize,
268            start: usize,
269            end: usize,
270            replacement: &'a str,
271        }
272
273        // Resolve every source position against the original content. Resolving
274        // positions after earlier edits can shift or invalidate later ranges.
275        let mut planned = Vec::new();
276        for (violation_index, violation) in violations.iter().enumerate() {
277            let Some(fix) = violation.fix.as_ref() else {
278                continue;
279            };
280            let Some(range) = fix.byte_range(content) else {
281                continue;
282            };
283
284            let replacement = fix.replacement.as_deref().unwrap_or("");
285            planned.push(PlannedFix {
286                violation_index,
287                start: range.start,
288                end: range.end,
289                replacement,
290            });
291        }
292
293        // Conflicting edits are unsafe to compose. Exact duplicates are safe and
294        // are applied once while marking every associated violation as fixed.
295        let mut conflicted = vec![false; planned.len()];
296        for left in 0..planned.len() {
297            for right in (left + 1)..planned.len() {
298                let a = &planned[left];
299                let b = &planned[right];
300                let duplicate =
301                    a.start == b.start && a.end == b.end && a.replacement == b.replacement;
302                let overlap = (a.start < b.end && b.start < a.end) || a.start == b.start;
303
304                if !duplicate && overlap {
305                    conflicted[left] = true;
306                    conflicted[right] = true;
307                }
308            }
309        }
310
311        let mut unique_edits: Vec<&PlannedFix<'_>> = Vec::new();
312        let mut applied_indices = std::collections::HashSet::new();
313        for (planned_index, edit) in planned.iter().enumerate() {
314            if conflicted[planned_index] {
315                continue;
316            }
317
318            applied_indices.insert(edit.violation_index);
319            if !unique_edits.iter().any(|existing| {
320                existing.start == edit.start
321                    && existing.end == edit.end
322                    && existing.replacement == edit.replacement
323            }) {
324                unique_edits.push(edit);
325            }
326        }
327
328        // Descending byte offsets keep all original ranges valid while editing.
329        unique_edits.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| b.end.cmp(&a.end)));
330
331        let mut result = content.to_string();
332        for edit in unique_edits {
333            result.replace_range(edit.start..edit.end, edit.replacement);
334        }
335
336        // Collect violations that weren't fixed
337        let unfixed: Vec<crate::Violation> = violations
338            .iter()
339            .enumerate()
340            .filter(|(idx, v)| v.fix.is_none() || !applied_indices.contains(idx))
341            .map(|(_, v)| v.clone())
342            .collect();
343
344        (result, unfixed)
345    }
346
347    /// Get all available rule IDs
348    pub fn available_rules(&self) -> Vec<&'static str> {
349        self.registry.rule_ids()
350    }
351
352    /// Get enabled rules based on configuration
353    pub fn enabled_rules(&self, config: &crate::Config) -> Vec<&dyn crate::rule::Rule> {
354        self.registry.get_enabled_rules(config)
355    }
356
357    /// Lint a collection of documents with collection rules
358    ///
359    /// Collection rules analyze multiple documents together for cross-document validation.
360    /// This method runs all registered collection rules against the provided documents.
361    pub fn lint_collection(&self, documents: &[crate::Document]) -> Result<Vec<crate::Violation>> {
362        self.registry.check_collection(documents)
363    }
364
365    /// Lint a collection of documents with specific configuration
366    pub fn lint_collection_with_config(
367        &self,
368        documents: &[crate::Document],
369        config: &crate::Config,
370    ) -> Result<Vec<crate::Violation>> {
371        self.registry
372            .check_collection_with_config(documents, config)
373    }
374
375    /// Get all available collection rule IDs
376    pub fn available_collection_rules(&self) -> Vec<&'static str> {
377        self.registry.collection_rule_ids()
378    }
379
380    /// Check if there are any collection rules registered
381    pub fn has_collection_rules(&self) -> bool {
382        self.registry.has_collection_rules()
383    }
384}
385
386impl Default for LintEngine {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::rule::{Rule, RuleCategory, RuleMetadata};
396    use std::path::PathBuf;
397
398    // Test rule for plugin system testing
399    struct TestRule;
400
401    impl Rule for TestRule {
402        fn id(&self) -> &'static str {
403            "TEST001"
404        }
405        fn name(&self) -> &'static str {
406            "test-rule"
407        }
408        fn description(&self) -> &'static str {
409            "A test rule"
410        }
411        fn metadata(&self) -> RuleMetadata {
412            RuleMetadata::stable(RuleCategory::Structure)
413        }
414        fn check_with_ast<'a>(
415            &self,
416            _document: &crate::Document,
417            _ast: Option<&'a comrak::nodes::AstNode<'a>>,
418        ) -> Result<Vec<crate::Violation>> {
419            Ok(vec![])
420        }
421    }
422
423    // Test provider
424    struct TestProvider;
425
426    impl RuleProvider for TestProvider {
427        fn provider_id(&self) -> &'static str {
428            "test-provider"
429        }
430        fn description(&self) -> &'static str {
431            "Test provider"
432        }
433        fn version(&self) -> &'static str {
434            "0.1.0"
435        }
436
437        fn register_rules(&self, registry: &mut RuleRegistry) {
438            registry.register(Box::new(TestRule));
439        }
440
441        fn rule_ids(&self) -> Vec<&'static str> {
442            vec!["TEST001"]
443        }
444    }
445
446    #[test]
447    fn test_plugin_registry_basic() {
448        let mut registry = PluginRegistry::new();
449        assert_eq!(registry.providers().len(), 0);
450
451        registry.register_provider(Box::new(TestProvider)).unwrap();
452        assert_eq!(registry.providers().len(), 1);
453
454        let provider = registry.get_provider("test-provider").unwrap();
455        assert_eq!(provider.provider_id(), "test-provider");
456        assert_eq!(provider.description(), "Test provider");
457    }
458
459    #[test]
460    fn test_plugin_registry_duplicate_id() {
461        let mut registry = PluginRegistry::new();
462        registry.register_provider(Box::new(TestProvider)).unwrap();
463
464        // Should fail with duplicate ID
465        let result = registry.register_provider(Box::new(TestProvider));
466        assert!(result.is_err());
467        assert!(
468            result
469                .unwrap_err()
470                .to_string()
471                .contains("already registered")
472        );
473    }
474
475    #[test]
476    fn test_create_engine_from_registry() {
477        let mut registry = PluginRegistry::new();
478        registry.register_provider(Box::new(TestProvider)).unwrap();
479
480        let engine = registry.create_engine().unwrap();
481        let rule_ids = engine.available_rules();
482        assert!(rule_ids.contains(&"TEST001"));
483    }
484
485    #[test]
486    fn test_available_rule_ids() {
487        let mut registry = PluginRegistry::new();
488        registry.register_provider(Box::new(TestProvider)).unwrap();
489
490        let rule_ids = registry.available_rule_ids();
491        assert_eq!(rule_ids, vec!["TEST001"]);
492    }
493
494    #[test]
495    fn test_provider_info() {
496        let mut registry = PluginRegistry::new();
497        registry.register_provider(Box::new(TestProvider)).unwrap();
498
499        let info = registry.provider_info();
500        assert_eq!(info.len(), 1);
501        assert_eq!(info[0].id, "test-provider");
502        assert_eq!(info[0].description, "Test provider");
503        assert_eq!(info[0].version, "0.1.0");
504        assert_eq!(info[0].rule_count, 1);
505    }
506
507    #[test]
508    fn test_get_provider_not_found() {
509        let registry = PluginRegistry::new();
510        assert!(registry.get_provider("nonexistent").is_none());
511    }
512
513    #[test]
514    fn test_create_rule_registry() {
515        let mut registry = PluginRegistry::new();
516        registry.register_provider(Box::new(TestProvider)).unwrap();
517
518        let rule_registry = registry.create_rule_registry().unwrap();
519        assert!(!rule_registry.is_empty());
520    }
521
522    // Test provider with initialization failure
523    struct FailingProvider;
524
525    impl RuleProvider for FailingProvider {
526        fn provider_id(&self) -> &'static str {
527            "failing-provider"
528        }
529        fn description(&self) -> &'static str {
530            "Failing test provider"
531        }
532        fn version(&self) -> &'static str {
533            "0.1.0"
534        }
535        fn register_rules(&self, _registry: &mut RuleRegistry) {}
536        fn initialize(&self) -> Result<()> {
537            Err(crate::error::MdBookLintError::plugin_error(
538                "Initialization failed",
539            ))
540        }
541    }
542
543    #[test]
544    fn test_provider_initialization_failure() {
545        let mut registry = PluginRegistry::new();
546        let result = registry.register_provider(Box::new(FailingProvider));
547        assert!(result.is_err());
548        assert!(
549            result
550                .unwrap_err()
551                .to_string()
552                .contains("Initialization failed")
553        );
554    }
555
556    // Test provider with config schema
557    struct ConfigurableProvider;
558
559    impl RuleProvider for ConfigurableProvider {
560        fn provider_id(&self) -> &'static str {
561            "configurable-provider"
562        }
563        fn description(&self) -> &'static str {
564            "Configurable test provider"
565        }
566        fn version(&self) -> &'static str {
567            "0.1.0"
568        }
569        fn register_rules(&self, _registry: &mut RuleRegistry) {}
570        fn config_schema(&self) -> Option<Value> {
571            Some(serde_json::json!({
572                "type": "object",
573                "properties": {
574                    "enabled": {"type": "boolean"}
575                }
576            }))
577        }
578    }
579
580    #[test]
581    fn test_provider_with_config_schema() {
582        let provider = ConfigurableProvider;
583        let schema = provider.config_schema();
584        assert!(schema.is_some());
585        let schema = schema.unwrap();
586        assert_eq!(schema["type"], "object");
587    }
588
589    #[test]
590    fn test_lint_engine_with_registry() {
591        let mut rule_registry = RuleRegistry::new();
592        rule_registry.register(Box::new(TestRule));
593
594        let engine = LintEngine::with_registry(rule_registry);
595        let rules = engine.available_rules();
596        assert!(rules.contains(&"TEST001"));
597    }
598
599    #[test]
600    fn test_lint_engine_api() {
601        let mut registry = PluginRegistry::new();
602        registry.register_provider(Box::new(TestProvider)).unwrap();
603        let engine = registry.create_engine().unwrap();
604
605        // Test basic content linting
606        let _violations = engine.lint_content("# Test\n", "test.md").unwrap();
607
608        // Test document linting
609        let document =
610            crate::Document::new("# Test".to_string(), PathBuf::from("test.md")).unwrap();
611        let _violations = engine.lint_document(&document).unwrap();
612    }
613
614    #[test]
615    fn test_position_to_offset() {
616        let text = "line1\nline2\nline3";
617
618        // Line 1, column 1 = offset 0
619        assert_eq!(
620            crate::violation::Position { line: 1, column: 1 }.to_byte_offset(text),
621            Some(0)
622        );
623
624        // Line 1, column 3 = offset 2 ('n' in 'line1')
625        assert_eq!(
626            crate::violation::Position { line: 1, column: 3 }.to_byte_offset(text),
627            Some(2)
628        );
629
630        // Line 2, column 1 = offset 6 (after 'line1\n')
631        assert_eq!(
632            crate::violation::Position { line: 2, column: 1 }.to_byte_offset(text),
633            Some(6)
634        );
635
636        // Line 3, column 1 = offset 12
637        assert_eq!(
638            crate::violation::Position { line: 3, column: 1 }.to_byte_offset(text),
639            Some(12)
640        );
641
642        // Invalid position
643        assert_eq!(
644            crate::violation::Position {
645                line: 10,
646                column: 1,
647            }
648            .to_byte_offset(text),
649            None
650        );
651    }
652
653    #[test]
654    fn test_apply_fix_simple() {
655        let engine = LintEngine::new();
656        let content = "hello world";
657
658        // Create a violation with a fix to replace "world" with "rust"
659        let violation = crate::Violation {
660            rule_id: "TEST".to_string(),
661            rule_name: "test".to_string(),
662            message: "test".to_string(),
663            line: 1,
664            column: 7,
665            severity: crate::Severity::Warning,
666            fix: Some(crate::violation::Fix {
667                description: "Replace world with rust".to_string(),
668                replacement: Some("rust".to_string()),
669                start: crate::violation::Position { line: 1, column: 7 },
670                end: crate::violation::Position {
671                    line: 1,
672                    column: 12,
673                },
674            }),
675        };
676
677        let result = engine.apply_fix(content, &violation);
678        assert_eq!(result, Some("hello rust".to_string()));
679    }
680
681    #[test]
682    fn test_apply_fix_no_fix() {
683        let engine = LintEngine::new();
684        let content = "hello world";
685
686        let violation = crate::Violation {
687            rule_id: "TEST".to_string(),
688            rule_name: "test".to_string(),
689            message: "test".to_string(),
690            line: 1,
691            column: 1,
692            severity: crate::Severity::Warning,
693            fix: None,
694        };
695
696        let result = engine.apply_fix(content, &violation);
697        assert_eq!(result, None);
698    }
699
700    #[test]
701    fn test_apply_fixes_multiple() {
702        let engine = LintEngine::new();
703        let content = "aaa bbb ccc";
704
705        let violations = vec![
706            crate::Violation {
707                rule_id: "TEST".to_string(),
708                rule_name: "test".to_string(),
709                message: "test".to_string(),
710                line: 1,
711                column: 1,
712                severity: crate::Severity::Warning,
713                fix: Some(crate::violation::Fix {
714                    description: "Replace aaa with AAA".to_string(),
715                    replacement: Some("AAA".to_string()),
716                    start: crate::violation::Position { line: 1, column: 1 },
717                    end: crate::violation::Position { line: 1, column: 4 },
718                }),
719            },
720            crate::Violation {
721                rule_id: "TEST".to_string(),
722                rule_name: "test".to_string(),
723                message: "test".to_string(),
724                line: 1,
725                column: 9,
726                severity: crate::Severity::Warning,
727                fix: Some(crate::violation::Fix {
728                    description: "Replace ccc with CCC".to_string(),
729                    replacement: Some("CCC".to_string()),
730                    start: crate::violation::Position { line: 1, column: 9 },
731                    end: crate::violation::Position {
732                        line: 1,
733                        column: 12,
734                    },
735                }),
736            },
737        ];
738
739        let (fixed, unfixed) = engine.apply_fixes(content, &violations);
740        assert_eq!(fixed, "AAA bbb CCC");
741        assert!(unfixed.is_empty());
742    }
743
744    #[test]
745    fn test_apply_fixes_mixed() {
746        let engine = LintEngine::new();
747        let content = "hello world";
748
749        let violations = vec![
750            crate::Violation {
751                rule_id: "TEST1".to_string(),
752                rule_name: "test".to_string(),
753                message: "has fix".to_string(),
754                line: 1,
755                column: 7,
756                severity: crate::Severity::Warning,
757                fix: Some(crate::violation::Fix {
758                    description: "Replace".to_string(),
759                    replacement: Some("rust".to_string()),
760                    start: crate::violation::Position { line: 1, column: 7 },
761                    end: crate::violation::Position {
762                        line: 1,
763                        column: 12,
764                    },
765                }),
766            },
767            crate::Violation {
768                rule_id: "TEST2".to_string(),
769                rule_name: "test".to_string(),
770                message: "no fix".to_string(),
771                line: 1,
772                column: 1,
773                severity: crate::Severity::Warning,
774                fix: None,
775            },
776        ];
777
778        let (fixed, unfixed) = engine.apply_fixes(content, &violations);
779        assert_eq!(fixed, "hello rust");
780        assert_eq!(unfixed.len(), 1);
781        assert_eq!(unfixed[0].rule_id, "TEST2");
782    }
783
784    #[test]
785    fn test_apply_fix_newline_handling() {
786        // Test that fixes ending with newline don't cause double newlines
787        let engine = LintEngine::new();
788
789        // Content has a line that will be replaced, followed by another line
790        let content = "# Old Heading\nNext line\n";
791
792        // Fix replaces the heading line (including its newline) with a new heading
793        let violation = crate::Violation {
794            rule_id: "TEST".to_string(),
795            rule_name: "test".to_string(),
796            message: "Replace heading".to_string(),
797            line: 1,
798            column: 1,
799            severity: crate::Severity::Warning,
800            fix: Some(crate::violation::Fix {
801                description: "Replace heading".to_string(),
802                start: crate::violation::Position { line: 1, column: 1 },
803                // Ending at the following line explicitly consumes the original
804                // line terminator.
805                end: crate::violation::Position { line: 2, column: 1 },
806                replacement: Some("# New Heading\n".to_string()),
807            }),
808        };
809
810        let result = engine.apply_fix(content, &violation);
811        assert!(result.is_some());
812        let fixed = result.unwrap();
813
814        // Should have exactly one newline between the heading and next line, not two
815        assert_eq!(fixed, "# New Heading\nNext line\n");
816        assert!(!fixed.contains("\n\n"), "Should not have double newlines");
817    }
818
819    #[test]
820    fn test_apply_fix_does_not_infer_newline_consumption() {
821        let engine = LintEngine::new();
822        let content = "old\nnext\n";
823        let violation = crate::Violation {
824            rule_id: "TEST".to_string(),
825            rule_name: "test".to_string(),
826            message: "Replace line content only".to_string(),
827            line: 1,
828            column: 1,
829            severity: crate::Severity::Warning,
830            fix: Some(crate::violation::Fix {
831                description: "Replace line content only".to_string(),
832                start: crate::violation::Position::line_start(1),
833                end: crate::violation::Position::line_end(1, "old"),
834                replacement: Some("new\n".to_string()),
835            }),
836        };
837
838        // The replacement contains a newline, but the exact source range does
839        // not consume one. The engine must not reinterpret that intent.
840        assert_eq!(
841            engine.apply_fix(content, &violation),
842            Some("new\n\nnext\n".to_string())
843        );
844    }
845
846    #[test]
847    fn test_apply_fix_no_newline_no_adjustment() {
848        // Test that fixes NOT ending with newline work normally
849        let engine = LintEngine::new();
850        let content = "hello world";
851
852        let violation = crate::Violation {
853            rule_id: "TEST".to_string(),
854            rule_name: "test".to_string(),
855            message: "Replace word".to_string(),
856            line: 1,
857            column: 7,
858            severity: crate::Severity::Warning,
859            fix: Some(crate::violation::Fix {
860                description: "Replace word".to_string(),
861                start: crate::violation::Position { line: 1, column: 7 },
862                end: crate::violation::Position {
863                    line: 1,
864                    column: 12,
865                },
866                replacement: Some("rust".to_string()),
867            }),
868        };
869
870        let result = engine.apply_fix(content, &violation);
871        assert!(result.is_some());
872        assert_eq!(result.unwrap(), "hello rust");
873    }
874
875    #[test]
876    fn test_apply_fixes_skips_conflicting_ranges() {
877        let engine = LintEngine::new();
878        let content = "### C#\ntext\n";
879        let violations = vec![
880            crate::Violation {
881                rule_id: "MD003".to_string(),
882                rule_name: "heading-style".to_string(),
883                message: "test".to_string(),
884                line: 1,
885                column: 1,
886                severity: crate::Severity::Error,
887                fix: Some(crate::violation::Fix {
888                    description: "First whole-line replacement".to_string(),
889                    replacement: Some("### C\n".to_string()),
890                    start: crate::violation::Position { line: 1, column: 1 },
891                    end: crate::violation::Position { line: 1, column: 7 },
892                }),
893            },
894            crate::Violation {
895                rule_id: "MD020".to_string(),
896                rule_name: "no-missing-space-closed-atx".to_string(),
897                message: "test".to_string(),
898                line: 1,
899                column: 1,
900                severity: crate::Severity::Warning,
901                fix: Some(crate::violation::Fix {
902                    description: "Second whole-line replacement".to_string(),
903                    replacement: Some("###C#\n".to_string()),
904                    start: crate::violation::Position { line: 1, column: 1 },
905                    end: crate::violation::Position { line: 1, column: 7 },
906                }),
907            },
908        ];
909
910        let (fixed, unfixed) = engine.apply_fixes(content, &violations);
911        assert_eq!(fixed, content);
912        assert_eq!(unfixed.len(), 2);
913    }
914
915    #[test]
916    fn test_apply_fixes_deduplicates_identical_ranges() {
917        let engine = LintEngine::new();
918        let content = "#Bad#\nnext\n";
919        let make_violation = |rule_id: &str| crate::Violation {
920            rule_id: rule_id.to_string(),
921            rule_name: "test".to_string(),
922            message: "test".to_string(),
923            line: 1,
924            column: 1,
925            severity: crate::Severity::Warning,
926            fix: Some(crate::violation::Fix {
927                description: "Normalize heading".to_string(),
928                replacement: Some("# Bad #\n".to_string()),
929                start: crate::violation::Position { line: 1, column: 1 },
930                end: crate::violation::Position { line: 2, column: 1 },
931            }),
932        };
933        let violations = vec![make_violation("TEST1"), make_violation("TEST2")];
934
935        let (fixed, unfixed) = engine.apply_fixes(content, &violations);
936        assert_eq!(fixed, "# Bad #\nnext\n");
937        assert!(unfixed.is_empty());
938    }
939}