1use 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 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
43pub fn detect_configured(
52 content: &str,
53 config: &crate::config::Config,
54 path: Option<&std::path::Path>,
55) -> Option<LintWarning> {
56 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
65pub 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 detect(content)?;
81 if !rules.iter().any(|rule| rule.name() == RULE_NAME) {
82 return None;
83 }
84 detect_suppressed(content, config, path)
85}
86
87fn 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
106pub 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 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 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 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 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}