1use 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
14pub struct ConflictScan<'a> {
17 content: Cow<'a, str>,
18 markers: Vec<Marker>,
19}
20
21impl<'a> ConflictScan<'a> {
22 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 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 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
119pub fn detect_configured(
128 content: &str,
129 config: &crate::config::Config,
130 path: Option<&std::path::Path>,
131) -> Option<LintWarning> {
132 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
145pub 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
162pub 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
177pub 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
206fn 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
232pub fn detect(content: &str) -> Option<LintWarning> {
236 scan_markers(strip_bom(content)).next().map(Marker::warning)
237}
238
239pub(crate) fn scan_markers(content: &str) -> impl Iterator<Item = Marker> + '_ {
242 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 let hidden = format!("\u{feff}{conflicted}");
275 assert!(super::detect_configured_for_analysis(&hidden, &config, None).is_none());
276 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 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 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 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 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 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 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 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}