Skip to main content

rumdl_lib/
merge_conflict.rs

1//! MD092 detects unresolved conflicts and protects the entire document from edits.
2//!
3//! Scan raw lines, including fenced code: Git can insert conflicts anywhere.
4//! Opening or closing markers suffice because conflicts may be partially resolved.
5//! Separators alone are valid Setext headings, and diff3 base markers alone can
6//! be table content, so neither is evidence of a conflict on its own.
7
8use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9
10pub const RULE_NAME: &str = "MD092";
11
12#[derive(Debug, Clone, Default)]
13pub struct MD092MergeConflict;
14
15impl Rule for MD092MergeConflict {
16    fn name(&self) -> &'static str {
17        RULE_NAME
18    }
19    fn description(&self) -> &'static str {
20        "Unresolved merge conflict markers"
21    }
22    fn category(&self) -> RuleCategory {
23        RuleCategory::Other
24    }
25    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
26        // The lint engine records suppressed findings for MD087 before filtering.
27        Ok(markers(ctx.content).collect())
28    }
29    fn fix_capability(&self) -> FixCapability {
30        FixCapability::Unfixable
31    }
32    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
33        Ok(ctx.content.to_string())
34    }
35    fn as_any(&self) -> &dyn std::any::Any {
36        self
37    }
38    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule> {
39        Box::new(Self)
40    }
41}
42
43/// Find the first conflict that the document configuration has not suppressed.
44/// Scan every marker: a documented example must not conceal a later conflict.
45/// Keep this guard before normalization, fixes, and external tool execution.
46///
47/// Configuration alone decides whether the rule runs, so this is the entry point
48/// for callers with no rule list of their own, and for callers whose list is a
49/// role-scoped subset rather than an invocation's selection (the LSP indexes
50/// with MD051 and MD057 alone).
51pub fn detect_configured(
52    content: &str,
53    config: &crate::config::Config,
54    path: Option<&std::path::Path>,
55) -> Option<LintWarning> {
56    // Most documents contain no markers; avoid parsing directives in that case.
57    detect(content)?;
58    let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
59    if crate::rules::filter_rules(&rules, &config.global).is_empty() {
60        return None;
61    }
62    detect_suppressed(content, config, path)
63}
64
65/// The finding for a conflicted document, if the invocation reports MD092 for it.
66///
67/// `rules` is the invocation's effective rule set, already resolved from
68/// configuration and CLI rule selection, so it is the whole answer to whether the
69/// rule runs: `--enable MD092` re-enables a rule the configuration disabled, the
70/// way it does for every other rule, and this guard follows it. A caller holding a
71/// role-scoped subset instead wants `detect_configured`, or its guard silently
72/// disappears along with the rules it never listed.
73pub fn detect_for_rules(
74    content: &str,
75    rules: &[Box<dyn Rule>],
76    config: &crate::config::Config,
77    path: Option<&std::path::Path>,
78) -> Option<LintWarning> {
79    // Most documents contain no markers; avoid parsing directives in that case.
80    detect(content)?;
81    if !rules.iter().any(|rule| rule.name() == RULE_NAME) {
82        return None;
83    }
84    detect_suppressed(content, config, path)
85}
86
87/// The first marker this document's own suppressions leave standing, at the
88/// severity configuration gives the rule. The caller has already decided that
89/// the rule runs at all.
90fn detect_suppressed(
91    content: &str,
92    config: &crate::config::Config,
93    path: Option<&std::path::Path>,
94) -> Option<LintWarning> {
95    if path.is_some_and(|path| config.get_ignored_rules_for_file(path).contains(RULE_NAME)) {
96        return None;
97    }
98    let inline = crate::inline_config::InlineConfig::from_content(content);
99    let mut warning = markers(content).find(|warning| !inline.is_rule_disabled(RULE_NAME, warning.line))?;
100    if let Some(severity) = config.get_rule_severity(RULE_NAME) {
101        warning.severity = severity;
102    }
103    Some(warning)
104}
105
106/// Find the first Git conflict marker, including custom marker widths >= 7.
107/// Labels must be separated by whitespace, as in Git's marker syntax.
108/// Raw detection, without configuration or inline suppression.
109pub fn detect(content: &str) -> Option<LintWarning> {
110    markers(content).next()
111}
112
113fn markers(content: &str) -> impl Iterator<Item = LintWarning> + '_ {
114    content.lines().enumerate().filter_map(|(index, line)| {
115        let column = if index == 0 && line.starts_with('\u{feff}') {
116            2
117        } else {
118            1
119        };
120        let line = if index == 0 {
121            line.trim_start_matches('\u{feff}')
122        } else {
123            line
124        };
125        let marker = *line.as_bytes().first()?;
126        if !matches!(marker, b'<' | b'>') {
127            return None;
128        }
129        let width = line.bytes().take_while(|&byte| byte == marker).count();
130        if width < 7 || !matches!(line.as_bytes().get(width), None | Some(b' ' | b'\t')) {
131            return None;
132        }
133        Some(LintWarning {
134            rule_name: Some(RULE_NAME.to_string()),
135            message: "Unresolved merge conflict; formatting skipped until conflict markers are removed".to_string(),
136            line: index + 1,
137            column,
138            end_line: index + 1,
139            end_column: width + column,
140            severity: Severity::Error,
141            fix: None,
142        })
143    })
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn detects_partial_custom_and_fenced_conflicts() {
152        for content in [
153            "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n",
154            ">>>>>>> branch",
155            "<<<<<<<",
156            "<<<<<<<<< HEAD",
157            "```text\n<<<<<<< HEAD\n```\n",
158            "\u{feff}<<<<<<< HEAD\r\n",
159            "<<<<<<< HEAD\nours\n||||||| base\nbase\n=======\ntheirs\n>>>>>>> branch",
160        ] {
161            assert!(detect(content).is_some(), "{content:?}");
162        }
163        assert_eq!(detect("# Title\r\n\r\n>>>>>>> branch").unwrap().line, 3);
164    }
165
166    #[test]
167    fn merge_conflict_protects_shared_fix_engine_and_document_run() {
168        let content = "# Title\r\n\n<<<<<<< HEAD\r\ntext   ";
169        let config = crate::config::Config::default();
170        let rules = crate::rules::all_rules(&config);
171        let mut fixed = content.to_string();
172        let result = crate::fix_coordinator::FixCoordinator::new()
173            .apply_fixes_iterative(&rules, &[], &mut fixed, &config, 100, None)
174            .unwrap();
175        assert_eq!(fixed, content);
176        assert_eq!(result.rules_fixed, 0);
177        assert_eq!(result.iterations, 0);
178        let run = crate::document_run::DocumentRun::new(content, &rules, &config);
179        assert_eq!(run.fix(100).unwrap().0, content);
180        let analysis = run.analyze().unwrap();
181        assert_eq!(analysis.warnings.len(), 1);
182        assert!(analysis.warnings[0].fix.is_none());
183    }
184
185    #[test]
186    fn merge_conflict_line_suppression_does_not_hide_other_markers() {
187        let config = crate::config::Config::default();
188        let content = "<!-- rumdl-disable-next-line merge-conflict -->\n<<<<<<< HEAD\ntext\n>>>>>>> side";
189        let warning = detect_configured(content, &config, None).unwrap();
190        assert_eq!(warning.line, 4);
191        assert_eq!(warning.rule_name.as_deref(), Some(RULE_NAME));
192    }
193
194    #[test]
195    fn merge_conflict_configured_index_keeps_documented_headings() {
196        // A run's own selection decides, and the cache fast path is handed the
197        // same one, so the two paths index this document identically whether the
198        // configuration keeps the rule or drops it.
199        let content = "# Example\n\n```text\n<<<<<<< HEAD\n```\n";
200        for (disabled, indexed) in [(true, true), (false, false)] {
201            let mut config = crate::config::Config::default();
202            if disabled {
203                config.global.disable.push(RULE_NAME.into());
204            }
205            let rules = crate::rules::filter_rules(&crate::rules::all_rules(&config), &config.global);
206            let run = crate::document_run::DocumentRun::new(content, &rules, &config);
207            let normal = run.analyze().unwrap().file_index;
208            let cached =
209                crate::build_file_index_only_for_selection(content, &rules, config.markdown_flavor(), None, &config);
210            assert_eq!(!normal.headings.is_empty(), indexed, "disabled: {disabled}");
211            assert_eq!(normal.headings.len(), cached.headings.len(), "disabled: {disabled}");
212        }
213    }
214
215    #[test]
216    fn merge_conflict_selection_outranks_a_configuration_disable() {
217        let content = "<<<<<<< HEAD\ntext\n>>>>>>> side\n";
218        let mut config = crate::config::Config::default();
219        config.global.disable.push(RULE_NAME.into());
220        // What `--enable MD092` resolves to: a selection the configuration lost.
221        let selection: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
222        assert!(detect_for_rules(content, &selection, &config, None).is_some());
223        assert!(detect_configured(content, &config, None).is_none());
224        // A selection without the rule reports nothing, whatever configuration says.
225        assert!(detect_for_rules(content, &[], &crate::config::Config::default(), None).is_none());
226    }
227
228    #[test]
229    fn merge_conflict_index_guard_survives_a_role_scoped_rule_list() {
230        // The LSP indexes with the cross-file rules alone, which never include
231        // MD092, so configuration has to be what decides there.
232        let content = "# Title\n\n<<<<<<< HEAD\ntext\n>>>>>>> side\n";
233        let config = crate::config::Config::default();
234        let cross_file: Vec<Box<dyn Rule>> = vec![Box::new(crate::rules::MD051LinkFragments::new())];
235        let index =
236            crate::build_file_index_only_with_config(content, &cross_file, config.markdown_flavor(), None, &config);
237        assert!(index.headings.is_empty());
238        let unconflicted =
239            crate::build_file_index_only_with_config("# Title\n", &cross_file, config.markdown_flavor(), None, &config);
240        assert_eq!(unconflicted.headings.len(), 1);
241    }
242
243    #[test]
244    fn preserves_ordinary_markdown_syntax() {
245        for content in [
246            "Title\n=======\n",
247            "||||||| base",
248            "<<<<<< HEAD",
249            ">>> quote",
250            ">>>>>>>quote",
251            "<<<<<<<not-a-label",
252            "text <<<<<<< HEAD",
253            "    <<<<<<< HEAD",
254            "> > > > > > > quote",
255        ] {
256            assert!(detect(content).is_none(), "{content:?}");
257        }
258    }
259}