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};
9use crate::utils::bom::strip_bom;
10use std::borrow::Cow;
11
12pub const RULE_NAME: &str = "MD092";
13
14/// Conflict-marker facts attached to immutable source text. Configuration and
15/// suppression are evaluated separately for each caller's effective rule set.
16pub struct ConflictScan<'a> {
17    content: Cow<'a, str>,
18    markers: Vec<Marker>,
19}
20
21impl<'a> ConflictScan<'a> {
22    /// Scan supplied document text, including its encoding marker if present.
23    /// Analysis text must use the `detect_for_analysis` entry point instead.
24    pub fn new(content: impl Into<Cow<'a, str>>) -> Self {
25        let content = content.into();
26        let markers = scan_markers(strip_bom(&content)).collect();
27        Self { content, markers }
28    }
29
30    pub fn content(&self) -> &str {
31        &self.content
32    }
33
34    /// Retain the scan for unchanged LF input; rescan any normalized text.
35    /// For example, `<<<<<<<\r\r\n` becomes a marker only after normalization.
36    pub fn normalize_lf(mut self) -> Self {
37        if let Cow::Owned(normalized) = crate::utils::normalize_line_ending(&self.content, crate::utils::LineEnding::Lf)
38        {
39            self.markers = scan_markers(strip_bom(&normalized)).collect();
40            self.content = Cow::Owned(normalized);
41        }
42        self
43    }
44
45    pub fn into_content(self) -> Cow<'a, str> {
46        self.content
47    }
48
49    pub fn detect_for_rules(
50        &self,
51        rules: &[Box<dyn Rule>],
52        config: &crate::config::Config,
53        path: Option<&std::path::Path>,
54    ) -> Option<LintWarning> {
55        if self.markers.is_empty() || !rules.iter().any(|rule| rule.name() == RULE_NAME) {
56            return None;
57        }
58        detect_suppressed(strip_bom(self.content()), &self.markers, config, path)
59    }
60
61    pub(crate) fn markers(&self) -> &[Marker] {
62        &self.markers
63    }
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub(crate) struct Marker {
68    line: usize,
69    column: usize,
70    width: usize,
71}
72
73impl Marker {
74    pub(crate) fn warning(self) -> LintWarning {
75        LintWarning {
76            rule_name: Some(RULE_NAME.to_string()),
77            message: "Unresolved merge conflict; formatting skipped until conflict markers are removed".to_string(),
78            line: self.line,
79            column: self.column,
80            end_line: self.line,
81            end_column: self.width + self.column,
82            severity: Severity::Error,
83            fix: None,
84        }
85    }
86}
87
88#[derive(Debug, Clone, Default)]
89pub struct MD092MergeConflict;
90
91impl Rule for MD092MergeConflict {
92    fn name(&self) -> &'static str {
93        RULE_NAME
94    }
95    fn description(&self) -> &'static str {
96        "Unresolved merge conflict markers"
97    }
98    fn category(&self) -> RuleCategory {
99        RuleCategory::Other
100    }
101    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
102        // The lint engine records suppressed findings for MD087 before filtering.
103        Ok(ctx.merge_conflicts().iter().map(|marker| marker.warning()).collect())
104    }
105    fn fix_capability(&self) -> FixCapability {
106        FixCapability::Unfixable
107    }
108    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
109        Ok(ctx.content.to_string())
110    }
111    fn as_any(&self) -> &dyn std::any::Any {
112        self
113    }
114    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule> {
115        Box::new(Self)
116    }
117}
118
119/// Find the first conflict that the document configuration has not suppressed.
120/// Scan every marker: a documented example must not conceal a later conflict.
121/// Keep this guard before normalization, fixes, and external tool execution.
122///
123/// Configuration alone decides whether the rule runs, so this is the entry point
124/// for callers with no rule list of their own, and for callers whose list is a
125/// role-scoped subset rather than an invocation's selection (the LSP indexes
126/// with MD051 and MD057 alone).
127pub fn detect_configured(
128    content: &str,
129    config: &crate::config::Config,
130    path: Option<&std::path::Path>,
131) -> Option<LintWarning> {
132    // Most documents contain no markers; avoid parsing directives in that case.
133    let scan = ConflictScan::new(content);
134    let content = strip_bom(content);
135    if scan.markers.is_empty() {
136        return None;
137    }
138    let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
139    if crate::rules::filter_rules(&rules, &config.global).is_empty() {
140        return None;
141    }
142    detect_suppressed(content, &scan.markers, config, path)
143}
144
145/// The finding for a conflicted document, if the invocation reports MD092 for it.
146///
147/// `rules` is the invocation's effective rule set, already resolved from
148/// configuration and CLI rule selection, so it is the whole answer to whether the
149/// rule runs: `--enable MD092` re-enables a rule the configuration disabled, the
150/// way it does for every other rule, and this guard follows it. A caller holding a
151/// role-scoped subset instead wants `detect_configured`, or its guard silently
152/// disappears along with the rules it never listed.
153pub fn detect_for_rules(
154    content: &str,
155    rules: &[Box<dyn Rule>],
156    config: &crate::config::Config,
157    path: Option<&std::path::Path>,
158) -> Option<LintWarning> {
159    ConflictScan::new(content).detect_for_rules(rules, config, path)
160}
161
162/// Like `detect_for_rules` for text that is already analysis text (the leading
163/// byte order mark removed), so a second mark is not stripped again.
164pub fn detect_for_analysis(
165    analysis: &str,
166    rules: &[Box<dyn Rule>],
167    config: &crate::config::Config,
168    path: Option<&std::path::Path>,
169) -> Option<LintWarning> {
170    if !rules.iter().any(|rule| rule.name() == RULE_NAME) {
171        return None;
172    }
173    let markers: Vec<Marker> = scan_markers(analysis).collect();
174    detect_suppressed(analysis, &markers, config, path)
175}
176
177/// Like `detect_configured` for text that is already analysis text.
178pub fn detect_configured_for_analysis(
179    analysis: &str,
180    config: &crate::config::Config,
181    path: Option<&std::path::Path>,
182) -> Option<LintWarning> {
183    let markers: Vec<Marker> = scan_markers(analysis).collect();
184    if markers.is_empty() {
185        return None;
186    }
187    let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
188    if crate::rules::filter_rules(&rules, &config.global).is_empty() {
189        return None;
190    }
191    detect_suppressed(analysis, &markers, config, path)
192}
193
194pub(crate) fn detect_for_context(
195    ctx: &crate::lint_context::LintContext<'_>,
196    rules: &[Box<dyn Rule>],
197    config: &crate::config::Config,
198    path: Option<&std::path::Path>,
199) -> Option<LintWarning> {
200    if !rules.iter().any(|rule| rule.name() == RULE_NAME) {
201        return None;
202    }
203    detect_suppressed(ctx.content, ctx.merge_conflicts(), config, path)
204}
205
206/// The first marker this document's own suppressions leave standing, at the
207/// severity configuration gives the rule. The caller has already decided that
208/// the rule runs at all.
209fn detect_suppressed(
210    content: &str,
211    markers: &[Marker],
212    config: &crate::config::Config,
213    path: Option<&std::path::Path>,
214) -> Option<LintWarning> {
215    if markers.is_empty() {
216        return None;
217    }
218    if path.is_some_and(|path| config.get_ignored_rules_for_file(path).contains(RULE_NAME)) {
219        return None;
220    }
221    let inline = crate::inline_config::InlineConfig::from_content(content);
222    let mut warning = markers
223        .iter()
224        .find(|marker| !inline.is_rule_disabled(RULE_NAME, marker.line))?
225        .warning();
226    if let Some(severity) = config.get_rule_severity(RULE_NAME) {
227        warning.severity = severity;
228    }
229    Some(warning)
230}
231
232/// Find the first Git conflict marker, including custom marker widths >= 7.
233/// Labels must be separated by whitespace, as in Git's marker syntax.
234/// Raw detection, without configuration or inline suppression.
235pub fn detect(content: &str) -> Option<LintWarning> {
236    scan_markers(strip_bom(content)).next().map(Marker::warning)
237}
238
239/// Markers in analysis text. The caller removes the byte order mark first, so
240/// line 1 starts at column 1 and a second mark is ordinary content.
241pub(crate) fn scan_markers(content: &str) -> impl Iterator<Item = Marker> + '_ {
242    // Search for the required runs before walking lines. Most documents have
243    // none, even when they contain HTML and blockquotes.
244    let content = if content.contains("<<<<<<<") || content.contains(">>>>>>>") {
245        content
246    } else {
247        ""
248    };
249    content.lines().enumerate().filter_map(|(index, line)| {
250        let marker = *line.as_bytes().first()?;
251        if !matches!(marker, b'<' | b'>') {
252            return None;
253        }
254        let width = line.bytes().take_while(|&byte| byte == marker).count();
255        if width < 7 || !matches!(line.as_bytes().get(width), None | Some(b' ' | b'\t')) {
256            return None;
257        }
258        Some(Marker {
259            line: index + 1,
260            column: 1,
261            width,
262        })
263    })
264}
265
266#[cfg(test)]
267mod tests {
268    #[test]
269    fn analysis_detection_leaves_a_second_mark_in_front_of_the_marker() {
270        let config = crate::config::Config::default();
271        let conflicted = "<<<<<<< HEAD\nours\n";
272        assert!(super::detect_configured_for_analysis(conflicted, &config, None).is_some());
273        // Not stripped again: the mark hides the marker, as in `detect_configured`.
274        let hidden = format!("\u{feff}{conflicted}");
275        assert!(super::detect_configured_for_analysis(&hidden, &config, None).is_none());
276        // The carrier entry point strips one mark, so one mark in front still finds it.
277        assert!(super::detect_configured(&hidden, &config, None).is_some());
278    }
279
280    use super::*;
281    use proptest::prelude::*;
282
283    proptest! {
284        #[test]
285        fn scan_and_normalization_match_raw_line_detection(
286            parts in prop::collection::vec(prop_oneof![
287                Just("\n".to_string()), Just("\r".to_string()),
288                Just("\u{feff}".to_string()), Just("<<<<<<<".to_string()),
289                Just(">>>>>>>".to_string()), Just(" ".to_string()),
290                Just("\t".to_string()), any::<char>().prop_map(|c| c.to_string()),
291            ], 0..160)
292        ) {
293            let original = parts.concat();
294            // The raw-line reading, with exactly one leading mark removed, without the substring prefilter.
295            let reference = |content: &str| {
296                crate::utils::bom::strip_bom(content).lines().enumerate().filter_map(|(index, line)| {
297                    let first = *line.as_bytes().first()?;
298                    let width = line.bytes().take_while(|&byte| byte == first).count();
299                    (matches!(first, b'<' | b'>') && width >= 7
300                        && matches!(line.as_bytes().get(width), None | Some(b' ' | b'\t')))
301                        .then_some(Marker { line: index + 1, column: 1, width })
302                }).collect::<Vec<_>>()
303            };
304            let scan = ConflictScan::new(&original);
305            prop_assert_eq!(scan.markers(), reference(&original));
306            let scan = scan.normalize_lf();
307            let normalized = original.replace("\r\n", "\n");
308            prop_assert_eq!(scan.content(), normalized.as_str());
309            prop_assert_eq!(scan.markers(), reference(&normalized));
310            prop_assert_eq!(scan.into_content(), normalized);
311        }
312    }
313
314    #[test]
315    fn normalization_rechecks_repeated_carriage_returns() {
316        let original = "<<<<<<<\r\r\ntext   \n";
317        let config = crate::config::Config::default();
318        let rules = crate::rules::all_rules(&config);
319        let scan = ConflictScan::new(original);
320        assert!(scan.detect_for_rules(&rules, &config, None).is_none());
321        let scan = scan.normalize_lf();
322        assert!(scan.detect_for_rules(&rules, &config, None).is_some());
323        let result = crate::document_run::DocumentRun::from_scanned(&scan, &rules, &config).analyze_and_fix(100);
324        assert_eq!(result.warnings.unwrap().len(), 1);
325        let (fixed, stats) = result.fixes.unwrap().unwrap();
326        assert_eq!(fixed, scan.content());
327        assert_eq!(stats.iterations, 0);
328    }
329
330    #[test]
331    fn shared_scan_keeps_selection_configuration_and_suppression_local() {
332        let content = "<!-- rumdl-disable-next-line MD092 -->\n<<<<<<< HEAD\ntext\n>>>>>>> side\n";
333        let scan = ConflictScan::new(content);
334        let mut config = crate::config::Config::default();
335        config.global.disable.push(RULE_NAME.into());
336        let selection: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
337        assert!(scan.detect_for_rules(&[], &config, None).is_none());
338        assert_eq!(scan.detect_for_rules(&selection, &config, None).unwrap().line, 4);
339        let run = crate::document_run::DocumentRun::from_scanned(&scan, &selection, &config);
340        assert_eq!(run.analyze().unwrap().warnings[0].line, 4);
341
342        // Suppressed raw findings remain available to MD087's observer.
343        let ctx = crate::lint_context::LintContext::new(content, config.markdown_flavor(), None)
344            .with_merge_conflicts(scan.markers());
345        assert_eq!(MD092MergeConflict.check(&ctx).unwrap().len(), 2);
346    }
347
348    #[test]
349    fn detects_partial_custom_and_fenced_conflicts() {
350        for content in [
351            "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n",
352            ">>>>>>> branch",
353            "<<<<<<<",
354            "<<<<<<<<< HEAD",
355            "```text\n<<<<<<< HEAD\n```\n",
356            "\u{feff}<<<<<<< HEAD\r\n",
357            "<<<<<<< HEAD\nours\n||||||| base\nbase\n=======\ntheirs\n>>>>>>> branch",
358        ] {
359            assert!(detect(content).is_some(), "{content:?}");
360        }
361        assert_eq!(detect("# Title\r\n\r\n>>>>>>> branch").unwrap().line, 3);
362    }
363
364    #[test]
365    fn a_marker_behind_the_byte_order_mark_is_at_column_one() {
366        let warning = detect("\u{feff}<<<<<<< HEAD\n").unwrap();
367        assert_eq!((warning.line, warning.column), (1, 1));
368    }
369
370    #[test]
371    fn a_second_byte_order_mark_hides_the_marker_and_is_not_stripped_again() {
372        let content = "\u{feff}\u{feff}<<<<<<< HEAD\n";
373        assert!(detect(content).is_none());
374        assert!(ConflictScan::new(content).markers().is_empty());
375        let config = crate::config::Config::default();
376        let rules = crate::rules::all_rules(&config);
377        assert!(detect_for_analysis("<<<<<<< HEAD\n", &rules, &config, None).is_some());
378        // Analysis text already lost its one mark; the remaining mark is content.
379        assert!(detect_for_analysis(crate::utils::bom::strip_bom(content), &rules, &config, None).is_none());
380    }
381
382    #[test]
383    fn merge_conflict_protects_shared_fix_engine_and_document_run() {
384        let content = "# Title\r\n\n<<<<<<< HEAD\r\ntext   ";
385        let config = crate::config::Config::default();
386        let rules = crate::rules::all_rules(&config);
387        let mut fixed = content.to_string();
388        let result = crate::fix_coordinator::FixCoordinator::new()
389            .apply_fixes_iterative(&rules, &[], &mut fixed, &config, 100, None)
390            .unwrap();
391        assert_eq!(fixed, content);
392        assert_eq!(result.rules_fixed, 0);
393        assert_eq!(result.iterations, 0);
394        let run = crate::document_run::DocumentRun::new(content, &rules, &config);
395        assert_eq!(run.fix(100).unwrap().0, content);
396        let analysis = run.analyze().unwrap();
397        assert_eq!(analysis.warnings.len(), 1);
398        assert!(analysis.warnings[0].fix.is_none());
399    }
400
401    #[test]
402    fn merge_conflict_line_suppression_does_not_hide_other_markers() {
403        let config = crate::config::Config::default();
404        let content = "<!-- rumdl-disable-next-line merge-conflict -->\n<<<<<<< HEAD\ntext\n>>>>>>> side";
405        let warning = detect_configured(content, &config, None).unwrap();
406        assert_eq!(warning.line, 4);
407        assert_eq!(warning.rule_name.as_deref(), Some(RULE_NAME));
408    }
409
410    #[test]
411    fn merge_conflict_configured_index_keeps_documented_headings() {
412        // A run's own selection decides, and the cache fast path is handed the
413        // same one, so the two paths index this document identically whether the
414        // configuration keeps the rule or drops it.
415        let content = "# Example\n\n```text\n<<<<<<< HEAD\n```\n";
416        for (disabled, indexed) in [(true, true), (false, false)] {
417            let mut config = crate::config::Config::default();
418            if disabled {
419                config.global.disable.push(RULE_NAME.into());
420            }
421            let rules = crate::rules::filter_rules(&crate::rules::all_rules(&config), &config.global);
422            let run = crate::document_run::DocumentRun::new(content, &rules, &config);
423            let normal = run.analyze().unwrap().file_index;
424            let cached =
425                crate::build_file_index_only_for_selection(content, &rules, config.markdown_flavor(), None, &config);
426            assert_eq!(!normal.headings.is_empty(), indexed, "disabled: {disabled}");
427            assert_eq!(normal.headings.len(), cached.headings.len(), "disabled: {disabled}");
428        }
429    }
430
431    #[test]
432    fn merge_conflict_selection_outranks_a_configuration_disable() {
433        let content = "<<<<<<< HEAD\ntext\n>>>>>>> side\n";
434        let mut config = crate::config::Config::default();
435        config.global.disable.push(RULE_NAME.into());
436        // What `--enable MD092` resolves to: a selection the configuration lost.
437        let selection: Vec<Box<dyn Rule>> = vec![Box::new(MD092MergeConflict)];
438        assert!(detect_for_rules(content, &selection, &config, None).is_some());
439        assert!(detect_configured(content, &config, None).is_none());
440        // A selection without the rule reports nothing, whatever configuration says.
441        assert!(detect_for_rules(content, &[], &crate::config::Config::default(), None).is_none());
442    }
443
444    #[test]
445    fn merge_conflict_index_guard_survives_a_role_scoped_rule_list() {
446        // The LSP indexes with the cross-file rules alone, which never include
447        // MD092, so configuration has to be what decides there.
448        let content = "# Title\n\n<<<<<<< HEAD\ntext\n>>>>>>> side\n";
449        let config = crate::config::Config::default();
450        let cross_file: Vec<Box<dyn Rule>> = vec![Box::new(crate::rules::MD051LinkFragments::new())];
451        let index =
452            crate::build_file_index_only_with_config(content, &cross_file, config.markdown_flavor(), None, &config);
453        assert!(index.headings.is_empty());
454        let unconflicted =
455            crate::build_file_index_only_with_config("# Title\n", &cross_file, config.markdown_flavor(), None, &config);
456        assert_eq!(unconflicted.headings.len(), 1);
457    }
458
459    #[test]
460    fn preserves_ordinary_markdown_syntax() {
461        for content in [
462            "Title\n=======\n",
463            "||||||| base",
464            "<<<<<< HEAD",
465            ">>> quote",
466            ">>>>>>>quote",
467            "<<<<<<<not-a-label",
468            "text <<<<<<< HEAD",
469            "    <<<<<<< HEAD",
470            "> > > > > > > quote",
471        ] {
472            assert!(detect(content).is_none(), "{content:?}");
473        }
474    }
475}