use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::range_utils::calculate_heading_range;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct MD043Config {
#[serde(default = "default_headings")]
pub headings: Vec<String>,
#[serde(default = "default_match_case")]
pub match_case: bool,
}
impl Default for MD043Config {
fn default() -> Self {
Self {
headings: default_headings(),
match_case: default_match_case(),
}
}
}
fn default_headings() -> Vec<String> {
Vec::new()
}
fn default_match_case() -> bool {
false
}
impl RuleConfig for MD043Config {
const RULE_NAME: &'static str = "MD043";
}
#[derive(Clone, Default)]
pub struct MD043RequiredHeadings {
config: MD043Config,
}
#[derive(Debug, Clone)]
struct DocumentHeading {
text: String,
match_key: String,
line_index: usize,
}
#[derive(Debug, Clone, Copy)]
enum WildcardKind {
One,
OneOrMore,
}
impl WildcardKind {
fn pattern(self) -> &'static str {
match self {
Self::One => "?",
Self::OneOrMore => "+",
}
}
fn requirement(self) -> &'static str {
match self {
Self::One => "one heading",
Self::OneOrMore => "one or more headings",
}
}
}
#[derive(Debug, Clone, Copy)]
enum PatternToken {
Literal { source_index: usize },
RequiredWildcard { source_index: usize, kind: WildcardKind },
RepeatingWildcard { anchor_index: Option<usize> },
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct AlignmentScore {
cost: usize,
exact_literals: usize,
wildcard_absorptions: usize,
substitutions: usize,
}
impl AlignmentScore {
fn with_cost(mut self) -> Self {
self.cost += 1;
self
}
fn with_exact(mut self) -> Self {
self.exact_literals += 1;
self
}
fn with_absorption(mut self) -> Self {
self.wildcard_absorptions += 1;
self
}
fn with_substitution(mut self) -> Self {
self.cost += 1;
self.substitutions += 1;
self
}
fn is_better_than(self, other: Self) -> bool {
self.cost < other.cost
|| (self.cost == other.cost
&& (self.exact_literals > other.exact_literals
|| (self.exact_literals == other.exact_literals
&& (self.wildcard_absorptions > other.wildcard_absorptions
|| (self.wildcard_absorptions == other.wildcard_absorptions
&& self.substitutions > other.substitutions)))))
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AlignmentDecision {
Done,
MatchLiteral,
Substitute,
OmitLiteral,
ConsumeRequiredWildcard,
OmitRequiredWildcard,
ConsumeRepeatingWildcard,
SkipRepeatingWildcard,
Unexpected,
}
impl AlignmentDecision {
fn tie_break_priority(self) -> u8 {
match self {
Self::MatchLiteral | Self::Substitute | Self::ConsumeRequiredWildcard | Self::SkipRepeatingWildcard => 3,
Self::OmitLiteral | Self::OmitRequiredWildcard | Self::ConsumeRepeatingWildcard => 2,
Self::Unexpected => 1,
Self::Done => 0,
}
}
}
#[derive(Debug, Clone, Copy)]
struct AlignmentCell {
score: AlignmentScore,
decision: AlignmentDecision,
}
#[derive(Debug, Clone, Copy)]
enum AlignmentStep {
MatchLiteral {
expected_index: usize,
actual_index: usize,
},
Substitution {
expected_index: usize,
actual_index: usize,
},
MissingLiteral {
expected_index: usize,
next_actual_index: usize,
},
RequiredWildcardConsumed {
source_index: usize,
actual_index: usize,
},
RepeatingWildcardConsumed {
actual_index: usize,
},
UnsatisfiedWildcard {
source_index: usize,
kind: WildcardKind,
next_actual_index: usize,
},
Unexpected {
actual_index: usize,
},
}
#[derive(Debug, Clone, Copy)]
enum AlignmentEvent {
LiteralMatch {
expected_index: usize,
actual_index: usize,
},
RequiredWildcardMatch {
source_index: usize,
actual_index: usize,
},
RepeatingWildcardMatch {
actual_index: usize,
},
Substitution {
expected_index: usize,
actual_index: usize,
},
MissingLiteral {
expected_index: usize,
next_actual_index: usize,
},
UnsatisfiedWildcard {
source_index: usize,
kind: WildcardKind,
next_actual_index: usize,
},
Unexpected {
actual_index: usize,
},
OutOfOrder {
expected_index: usize,
actual_index: usize,
},
}
#[derive(Debug)]
struct AlignmentResult {
events: Vec<AlignmentEvent>,
}
impl MD043RequiredHeadings {
pub fn new(headings: Vec<String>) -> Self {
Self {
config: MD043Config {
headings,
match_case: default_match_case(),
},
}
}
pub fn from_config_struct(config: MD043Config) -> Self {
Self { config }
}
fn headings_match(&self, expected: &str, actual: &str) -> bool {
self.match_key(expected) == self.match_key(actual)
}
fn match_key(&self, heading: &str) -> String {
if self.config.match_case {
heading.to_string()
} else {
heading.to_lowercase()
}
}
fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<DocumentHeading> {
let mut result = Vec::new();
for (line_index, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading {
if !heading.is_valid {
continue;
}
let full_heading = format!("{} {}", heading.marker, heading.text.trim());
let match_key = self.match_key(&full_heading);
result.push(DocumentHeading {
text: full_heading,
match_key,
line_index,
});
}
}
result
}
fn compile_pattern(&self) -> Vec<PatternToken> {
let mut tokens = Vec::new();
let mut index = 0;
while index < self.config.headings.len() {
if !matches!(self.config.headings[index].as_str(), "*" | "+" | "?") {
tokens.push(PatternToken::Literal { source_index: index });
index += 1;
continue;
}
let run_start = index;
while index < self.config.headings.len() && matches!(self.config.headings[index].as_str(), "*" | "+" | "?")
{
index += 1;
}
let anchor_index = (index < self.config.headings.len()).then_some(index);
let mut has_repeating_slot = false;
for source_index in run_start..index {
match self.config.headings[source_index].as_str() {
"?" => tokens.push(PatternToken::RequiredWildcard {
source_index,
kind: WildcardKind::One,
}),
"+" => {
tokens.push(PatternToken::RequiredWildcard {
source_index,
kind: WildcardKind::OneOrMore,
});
has_repeating_slot = true;
}
"*" => has_repeating_slot = true,
_ => unreachable!(),
}
}
if has_repeating_slot {
tokens.push(PatternToken::RepeatingWildcard { anchor_index });
}
}
tokens
}
fn is_anchor(actual: &DocumentHeading, anchor_index: Option<usize>, expected_keys: &[String]) -> bool {
anchor_index.is_some_and(|index| expected_keys[index] == actual.match_key)
}
fn align_steps(&self, actual: &[DocumentHeading], expected_keys: &[String]) -> Vec<AlignmentStep> {
let tokens = self.compile_pattern();
let columns = actual.len() + 1;
let cell_count = (tokens.len() + 1)
.checked_mul(columns)
.expect("MD043 alignment table dimensions overflow");
let mut decisions = vec![AlignmentDecision::Done; cell_count];
let table_index = |token_index: usize, actual_index: usize| token_index * columns + actual_index;
let mut next_scores = vec![AlignmentScore::default(); columns];
let mut current_scores = vec![AlignmentScore::default(); columns];
for actual_index in (0..actual.len()).rev() {
next_scores[actual_index] = next_scores[actual_index + 1].with_cost();
decisions[table_index(tokens.len(), actual_index)] = AlignmentDecision::Unexpected;
}
for token_index in (0..tokens.len()).rev() {
for actual_index in (0..=actual.len()).rev() {
let mut best = AlignmentCell {
score: AlignmentScore {
cost: usize::MAX,
..AlignmentScore::default()
},
decision: AlignmentDecision::Done,
};
let mut consider = |score: AlignmentScore, decision: AlignmentDecision| {
if score.is_better_than(best.score)
|| (score == best.score && decision.tie_break_priority() > best.decision.tie_break_priority())
{
best = AlignmentCell { score, decision };
}
};
match tokens[token_index] {
PatternToken::Literal { source_index } => {
if actual_index < actual.len() {
let diagonal = next_scores[actual_index + 1];
if expected_keys[source_index] == actual[actual_index].match_key {
consider(diagonal.with_exact(), AlignmentDecision::MatchLiteral);
} else {
consider(diagonal.with_substitution(), AlignmentDecision::Substitute);
}
}
consider(next_scores[actual_index].with_cost(), AlignmentDecision::OmitLiteral);
if actual_index < actual.len() {
consider(
current_scores[actual_index + 1].with_cost(),
AlignmentDecision::Unexpected,
);
}
}
PatternToken::RequiredWildcard { .. } => {
if actual_index < actual.len() {
consider(
next_scores[actual_index + 1].with_absorption(),
AlignmentDecision::ConsumeRequiredWildcard,
);
}
consider(
next_scores[actual_index].with_cost(),
AlignmentDecision::OmitRequiredWildcard,
);
}
PatternToken::RepeatingWildcard { anchor_index } => {
consider(next_scores[actual_index], AlignmentDecision::SkipRepeatingWildcard);
if actual_index < actual.len()
&& !Self::is_anchor(&actual[actual_index], anchor_index, expected_keys)
{
consider(
current_scores[actual_index + 1].with_absorption(),
AlignmentDecision::ConsumeRepeatingWildcard,
);
}
}
}
current_scores[actual_index] = best.score;
decisions[table_index(token_index, actual_index)] = best.decision;
}
std::mem::swap(&mut current_scores, &mut next_scores);
}
let mut steps = Vec::new();
let mut token_index = 0;
let mut actual_index = 0;
while token_index < tokens.len() || actual_index < actual.len() {
let decision = decisions[table_index(token_index, actual_index)];
match decision {
AlignmentDecision::Done => break,
AlignmentDecision::MatchLiteral => {
let PatternToken::Literal { source_index } = tokens[token_index] else {
unreachable!()
};
steps.push(AlignmentStep::MatchLiteral {
expected_index: source_index,
actual_index,
});
token_index += 1;
actual_index += 1;
}
AlignmentDecision::Substitute => {
let PatternToken::Literal { source_index } = tokens[token_index] else {
unreachable!()
};
steps.push(AlignmentStep::Substitution {
expected_index: source_index,
actual_index,
});
token_index += 1;
actual_index += 1;
}
AlignmentDecision::OmitLiteral => {
let PatternToken::Literal { source_index } = tokens[token_index] else {
unreachable!()
};
steps.push(AlignmentStep::MissingLiteral {
expected_index: source_index,
next_actual_index: actual_index,
});
token_index += 1;
}
AlignmentDecision::ConsumeRequiredWildcard => {
let PatternToken::RequiredWildcard { source_index, .. } = tokens[token_index] else {
unreachable!()
};
steps.push(AlignmentStep::RequiredWildcardConsumed {
source_index,
actual_index,
});
token_index += 1;
actual_index += 1;
}
AlignmentDecision::OmitRequiredWildcard => {
let PatternToken::RequiredWildcard { source_index, kind, .. } = tokens[token_index] else {
unreachable!()
};
steps.push(AlignmentStep::UnsatisfiedWildcard {
source_index,
kind,
next_actual_index: actual_index,
});
token_index += 1;
}
AlignmentDecision::ConsumeRepeatingWildcard => {
steps.push(AlignmentStep::RepeatingWildcardConsumed { actual_index });
actual_index += 1;
}
AlignmentDecision::SkipRepeatingWildcard => token_index += 1,
AlignmentDecision::Unexpected => {
steps.push(AlignmentStep::Unexpected { actual_index });
actual_index += 1;
}
}
}
steps
}
fn alignment(&self, actual: &[DocumentHeading]) -> AlignmentResult {
let expected_keys = self
.config
.headings
.iter()
.map(|heading| self.match_key(heading))
.collect::<Vec<_>>();
let steps = self.align_steps(actual, &expected_keys);
let mut paired_expected = vec![false; steps.len()];
let mut reordered = vec![None; steps.len()];
let unmatched_expected = steps
.iter()
.enumerate()
.filter_map(|(step_index, step)| match step {
AlignmentStep::MissingLiteral { expected_index, .. }
| AlignmentStep::Substitution { expected_index, .. } => Some((step_index, *expected_index)),
_ => None,
})
.collect::<Vec<_>>();
let candidates = steps
.iter()
.enumerate()
.filter_map(|(step_index, step)| match step {
AlignmentStep::Unexpected { actual_index }
| AlignmentStep::RepeatingWildcardConsumed { actual_index }
| AlignmentStep::Substitution { actual_index, .. } => Some((step_index, *actual_index)),
_ => None,
})
.collect::<Vec<_>>();
let mut used_candidates = vec![false; candidates.len()];
for (expected_step, expected_index) in unmatched_expected {
if let Some((candidate_slot, (candidate_step, _actual_index))) =
candidates
.iter()
.enumerate()
.find(|(candidate_slot, (_, actual_index))| {
!used_candidates[*candidate_slot]
&& expected_keys[expected_index] == actual[*actual_index].match_key
})
{
used_candidates[candidate_slot] = true;
paired_expected[expected_step] = true;
reordered[*candidate_step] = Some(expected_index);
}
}
let mut events = Vec::with_capacity(steps.len());
for (step_index, step) in steps.iter().enumerate() {
match *step {
AlignmentStep::MatchLiteral {
expected_index,
actual_index,
} => events.push(AlignmentEvent::LiteralMatch {
expected_index,
actual_index,
}),
AlignmentStep::RequiredWildcardConsumed {
source_index,
actual_index,
} => events.push(AlignmentEvent::RequiredWildcardMatch {
source_index,
actual_index,
}),
AlignmentStep::RepeatingWildcardConsumed { actual_index } => {
if let Some(expected_index) = reordered[step_index] {
events.push(AlignmentEvent::OutOfOrder {
expected_index,
actual_index,
});
} else {
events.push(AlignmentEvent::RepeatingWildcardMatch { actual_index });
}
}
AlignmentStep::Substitution {
expected_index,
actual_index,
} => match (paired_expected[step_index], reordered[step_index]) {
(false, None) => events.push(AlignmentEvent::Substitution {
expected_index,
actual_index,
}),
(true, None) => events.push(AlignmentEvent::Unexpected { actual_index }),
(true, Some(reordered_expected)) => events.push(AlignmentEvent::OutOfOrder {
expected_index: reordered_expected,
actual_index,
}),
(false, Some(reordered_expected)) => {
events.push(AlignmentEvent::MissingLiteral {
expected_index,
next_actual_index: actual_index,
});
events.push(AlignmentEvent::OutOfOrder {
expected_index: reordered_expected,
actual_index,
});
}
},
AlignmentStep::MissingLiteral {
expected_index,
next_actual_index,
} => {
if !paired_expected[step_index] {
events.push(AlignmentEvent::MissingLiteral {
expected_index,
next_actual_index,
});
}
}
AlignmentStep::UnsatisfiedWildcard {
source_index,
kind,
next_actual_index,
} => events.push(AlignmentEvent::UnsatisfiedWildcard {
source_index,
kind,
next_actual_index,
}),
AlignmentStep::Unexpected { actual_index } => {
if let Some(expected_index) = reordered[step_index] {
events.push(AlignmentEvent::OutOfOrder {
expected_index,
actual_index,
});
} else {
events.push(AlignmentEvent::Unexpected { actual_index });
}
}
}
}
AlignmentResult { events }
}
fn heading_range(
&self,
heading: &DocumentHeading,
ctx: &crate::lint_context::LintContext,
) -> (usize, usize, usize, usize) {
calculate_heading_range(
heading.line_index + 1,
ctx.lines[heading.line_index].content(ctx.content),
)
}
fn omission_range(
&self,
next_actual_index: usize,
actual: &[DocumentHeading],
ctx: &crate::lint_context::LintContext,
) -> (usize, usize, usize, usize) {
actual
.get(next_actual_index)
.or_else(|| actual.last())
.map_or((1, 1, 1, 2), |heading| self.heading_range(heading, ctx))
}
fn reorder_message(&self, expected_index: usize, actual: &str) -> String {
let previous = self.config.headings[..expected_index]
.iter()
.rev()
.find(|heading| !matches!(heading.as_str(), "*" | "+" | "?"));
let next = self.config.headings[expected_index + 1..]
.iter()
.find(|heading| !matches!(heading.as_str(), "*" | "+" | "?"));
let location = match (previous, next) {
(Some(previous), Some(next)) => format!("expected between '{previous}' and '{next}'"),
(Some(previous), None) => format!("expected after '{previous}'"),
(None, Some(next)) => format!("expected before '{next}'"),
(None, None) => "expected at its configured position".to_string(),
};
format!("Heading structure does not match required structure. Heading '{actual}' is out of order; {location}")
}
fn warning(&self, range: (usize, usize, usize, usize), message: String) -> LintWarning {
LintWarning {
rule_name: Some(self.name().to_string()),
line: range.0,
column: range.1,
end_line: range.2,
end_column: range.3,
message,
severity: Severity::Warning,
fix: None,
}
}
}
impl Rule for MD043RequiredHeadings {
fn name(&self) -> &'static str {
"MD043"
}
fn description(&self) -> &'static str {
"Required heading structure"
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if self.config.headings.is_empty() || ctx.content.is_empty() {
return Ok(Vec::new());
}
let actual = self.extract_headings(ctx);
let alignment = self.alignment(&actual);
let prefix = "Heading structure does not match required structure.";
let mut warnings = Vec::new();
for event in alignment.events {
match event {
AlignmentEvent::LiteralMatch {
expected_index,
actual_index,
} => debug_assert!(
self.headings_match(&self.config.headings[expected_index], &actual[actual_index].text)
),
AlignmentEvent::RequiredWildcardMatch {
source_index,
actual_index,
} => {
debug_assert!(matches!(self.config.headings[source_index].as_str(), "+" | "?"));
debug_assert!(actual_index < actual.len());
}
AlignmentEvent::RepeatingWildcardMatch { actual_index } => {
debug_assert!(actual_index < actual.len());
}
AlignmentEvent::Substitution {
expected_index,
actual_index,
} => warnings.push(self.warning(
self.heading_range(&actual[actual_index], ctx),
format!(
"{prefix} Expected heading '{}', but found '{}'",
self.config.headings[expected_index], actual[actual_index].text
),
)),
AlignmentEvent::MissingLiteral {
expected_index,
next_actual_index,
} => warnings.push(self.warning(
self.omission_range(next_actual_index, &actual, ctx),
format!(
"{prefix} Missing required heading '{}'",
self.config.headings[expected_index]
),
)),
AlignmentEvent::UnsatisfiedWildcard {
source_index,
kind,
next_actual_index,
} => warnings.push(self.warning(
self.omission_range(next_actual_index, &actual, ctx),
format!(
"{prefix} Wildcard '{}' at pattern position {} requires {}, but none was available",
kind.pattern(),
source_index + 1,
kind.requirement()
),
)),
AlignmentEvent::Unexpected { actual_index } => warnings.push(self.warning(
self.heading_range(&actual[actual_index], ctx),
format!(
"{prefix} Unexpected heading '{}' at position {}",
actual[actual_index].text,
actual_index + 1
),
)),
AlignmentEvent::OutOfOrder {
expected_index,
actual_index,
} => warnings.push(self.warning(
self.heading_range(&actual[actual_index], ctx),
self.reorder_message(expected_index, &actual[actual_index].text),
)),
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if self.config.headings.is_empty() || ctx.content.is_empty() {
return true;
}
let has_valid_heading = ctx
.lines
.iter()
.any(|line| line.heading.as_ref().is_some_and(|heading| heading.is_valid));
!has_valid_heading && self.config.headings.iter().all(|pattern| pattern == "*")
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD043Config);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_extract_headings_code_blocks() {
let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
let rule = MD043RequiredHeadings::new(required);
let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## Another heading in code block\n```\n\n## Real heading 2\n\nSome content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let actual_headings = rule.extract_headings(&ctx);
assert_eq!(
actual_headings
.iter()
.map(|heading| heading.text.clone())
.collect::<Vec<_>>(),
vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
"Should extract correct headings and ignore code blocks"
);
let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## This should be ignored\n```\n\n## Not Real heading 2\n\nSome content.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let actual_headings = rule.extract_headings(&ctx);
assert_eq!(
actual_headings
.iter()
.map(|heading| heading.text.clone())
.collect::<Vec<_>>(),
vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
"Should extract actual headings including mismatched ones"
);
}
#[test]
fn test_with_document_structure() {
let required = vec![
"# Introduction".to_string(),
"# Method".to_string(),
"# Results".to_string(),
];
let rule = MD043RequiredHeadings::new(required);
let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
let warnings = rule
.check(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None,
))
.unwrap();
assert!(warnings.is_empty(), "Expected no warnings for matching headings");
let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
let warnings = rule
.check(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None,
))
.unwrap();
assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
let content = "No headings here, just plain text";
let warnings = rule
.check(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None,
))
.unwrap();
assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
let required_setext = vec![
"=========== Introduction".to_string(),
"------ Method".to_string(),
"======= Results".to_string(),
];
let rule_setext = MD043RequiredHeadings::new(required_setext);
let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
let warnings = rule_setext
.check(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None,
))
.unwrap();
assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
}
#[test]
fn test_should_not_skip_headingless_documents_with_literal_requirements() {
let required = vec!["Test".to_string()];
let rule = MD043RequiredHeadings::new(required);
let content = "This paragraph contains a # character but is not a heading";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check headingless content when a literal heading is required"
);
let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check content whose only heading syntax is in a code block"
);
let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check headingless list content"
);
let content = "Some text\n\n---\n\nMore text below the horizontal rule";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check headingless content containing a horizontal rule"
);
let content = "This is a normal paragraph with equals sign x = y + z";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check headingless content containing an equals sign"
);
let content = "This is a normal paragraph with minus sign x - y = z";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should check headingless content containing a minus sign"
);
let optional = MD043RequiredHeadings::new(vec!["*".to_string()]);
assert!(optional.should_skip(&LintContext::new(
"No headings",
crate::config::MarkdownFlavor::Standard,
None
)));
}
#[test]
fn test_should_skip_heading_detection() {
let required = vec!["Test".to_string()];
let rule = MD043RequiredHeadings::new(required);
let content = "# This is a heading\n\nAnd some content";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should not skip content with ATX heading"
);
let content = "This is a heading\n================\n\nAnd some content";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should not skip content with Setext heading (=)"
);
let content = "This is a subheading\n------------------\n\nAnd some content";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should not skip content with Setext heading (-)"
);
let content = "## This is a heading ##\n\nAnd some content";
assert!(
!rule.should_skip(&LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
None
)),
"Should not skip content with ATX heading with closing hashes"
);
}
#[test]
fn test_config_match_case_sensitive() {
let config = MD043Config {
headings: vec!["# Introduction".to_string(), "# Method".to_string()],
match_case: true,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# introduction\n\n# method";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should detect case mismatch when match_case is true"
);
}
#[test]
fn test_config_match_case_insensitive() {
let config = MD043Config {
headings: vec!["# Introduction".to_string(), "# Method".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# introduction\n\n# method";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
}
#[test]
fn test_config_case_insensitive_mixed() {
let config = MD043Config {
headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# INTRODUCTION\n\n# method";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should allow mixed case variations when match_case is false"
);
}
#[test]
fn test_config_case_sensitive_exact_match() {
let config = MD043Config {
headings: vec!["# Introduction".to_string(), "# Method".to_string()],
match_case: true,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Introduction\n\n# Method";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should pass with exact case match when match_case is true"
);
}
#[test]
fn test_default_config() {
let rule = MD043RequiredHeadings::default();
let content = "# Any heading\n\n# Another heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should be disabled with default empty headings");
}
#[test]
fn test_default_config_section() {
let rule = MD043RequiredHeadings::default();
let config_section = rule.default_config_section();
assert!(config_section.is_some());
let (name, value) = config_section.unwrap();
assert_eq!(name, "MD043");
if let toml::Value::Table(table) = value {
assert!(table.contains_key("headings"));
assert!(table.contains_key("match-case"));
assert_eq!(table["headings"], toml::Value::Array(vec![]));
assert_eq!(table["match-case"], toml::Value::Boolean(false));
} else {
panic!("Expected TOML table");
}
}
#[test]
fn test_headings_match_case_sensitive() {
let config = MD043Config {
headings: vec![],
match_case: true,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
assert!(rule.headings_match("Test", "Test"));
assert!(!rule.headings_match("Test", "test"));
assert!(!rule.headings_match("test", "Test"));
}
#[test]
fn test_headings_match_case_insensitive() {
let config = MD043Config {
headings: vec![],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
assert!(rule.headings_match("Test", "Test"));
assert!(rule.headings_match("Test", "test"));
assert!(rule.headings_match("test", "Test"));
assert!(rule.headings_match("TEST", "test"));
}
#[test]
fn test_config_empty_headings() {
let config = MD043Config {
headings: vec![],
match_case: true,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Any heading\n\n# Another heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should be disabled with empty headings list");
}
#[test]
fn test_fix_respects_configuration() {
let config = MD043Config {
headings: vec!["# Title".to_string(), "# Content".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "Wrong content";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Wrong content";
assert_eq!(fixed, expected);
}
#[test]
fn test_asterisk_wildcard_zero_headings() {
let config = MD043Config {
headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* should allow zero headings between Start and End");
}
#[test]
fn test_asterisk_wildcard_multiple_headings() {
let config = MD043Config {
headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"* should allow multiple headings between Start and End"
);
}
#[test]
fn test_asterisk_wildcard_at_end() {
let config = MD043Config {
headings: vec!["# Introduction".to_string(), "*".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* at end should allow any trailing headings");
}
#[test]
fn test_plus_wildcard_requires_at_least_one() {
let config = MD043Config {
headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "+ should require at least one heading");
}
#[test]
fn test_plus_wildcard_allows_multiple() {
let config = MD043Config {
headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n## Middle\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ should allow one heading");
let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ should allow multiple headings");
}
#[test]
fn test_question_wildcard_exactly_one() {
let config = MD043Config {
headings: vec!["?".to_string(), "## Description".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Project Name\n\n## Description";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "? should allow exactly one heading");
}
#[test]
fn test_question_wildcard_fails_with_zero() {
let config = MD043Config {
headings: vec!["?".to_string(), "## Description".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "## Description";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "? should require exactly one heading");
}
#[test]
fn test_complex_wildcard_pattern() {
let config = MD043Config {
headings: vec![
"?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# My Project\n\n## Overview\n\n## License";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Complex pattern should match minimal structure");
let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Complex pattern should match with optional sections");
}
#[test]
fn test_multiple_asterisks() {
let config = MD043Config {
headings: vec![
"# Title".to_string(),
"*".to_string(),
"## Middle".to_string(),
"*".to_string(),
"# End".to_string(),
],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Middle\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Multiple * wildcards should work");
let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Multiple * wildcards should allow flexible structure"
);
}
#[test]
fn test_wildcard_with_case_sensitivity() {
let config = MD043Config {
headings: vec![
"?".to_string(),
"## Description".to_string(), ],
match_case: true,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Description";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
let content = "# Title\n\n## description";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Case-sensitive matching should detect case mismatch"
);
}
#[test]
fn test_all_wildcards_pattern() {
let config = MD043Config {
headings: vec!["*".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Any\n\n## Headings\n\n### Work";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* alone should allow any heading structure");
let content = "No headings here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* alone should allow no headings");
}
#[test]
fn test_wildcard_edge_cases() {
let config = MD043Config {
headings: vec!["# Start".to_string(), "+".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "+ at end should require at least one more heading");
let content = "# Start\n\n## More";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ at end should allow additional headings");
}
#[test]
fn test_fix_with_wildcards() {
let config = MD043Config {
headings: vec!["?".to_string(), "## Description".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Project\n\n## Description";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
let content = "# Project\n\n## Other";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Fix should preserve non-matching content to prevent data loss"
);
}
#[test]
fn test_consecutive_wildcards() {
let config = MD043Config {
headings: vec![
"# Start".to_string(),
"*".to_string(),
"+".to_string(),
"# End".to_string(),
],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n## Middle\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Consecutive * and + should work together");
let content = "# Start\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should fail when + is not satisfied");
}
#[test]
fn test_question_mark_doesnt_consume_literal_match() {
let config = MD043Config {
headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Description\n\n## License";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "? should consume exactly one heading");
let content = "## Description\n\n## License";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "? requires exactly one heading to match");
}
#[test]
fn test_asterisk_between_literals_complex() {
let config = MD043Config {
headings: vec![
"# Title".to_string(),
"## Section A".to_string(),
"*".to_string(),
"## Section B".to_string(),
],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Section A\n\n## Section B";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* should allow zero headings");
let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* should allow multiple headings");
let content = "# Title\n\n## Section A\n\n### Sub1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should fail when required heading after * is missing"
);
}
#[test]
fn test_plus_requires_consumption() {
let config = MD043Config {
headings: vec!["+".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "No headings here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "+ should fail with zero headings");
let content = "# Any heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ should pass with one heading");
let content = "# First\n\n## Second\n\n### Third";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ should pass with multiple headings");
}
#[test]
fn test_mixed_wildcard_and_literal_ordering() {
let config = MD043Config {
headings: vec![
"# A".to_string(),
"*".to_string(),
"# B".to_string(),
"*".to_string(),
"# C".to_string(),
],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# A\n\n# B\n\n# C";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should match literals in correct order");
let content = "# A\n\n# C\n\n# B";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should fail when literals are out of order");
let content = "# A\n\n# C";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should fail when required literal is missing");
}
#[test]
fn test_only_wildcards_with_headings() {
let config = MD043Config {
headings: vec!["?".to_string(), "+".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# First\n\n## Second";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "? followed by + should require at least 2 headings");
let content = "# First";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should fail with only 1 heading when ? + is required"
);
}
#[test]
fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
let config = MD043Config {
headings: vec![
"# Start".to_string(),
"*".to_string(),
"## Target".to_string(),
"# End".to_string(),
],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Start\n\n## Other\n\n## Target\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* should correctly skip to next literal match");
let content = "# Start\n\n## Target\n\n## Target\n\n# End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should fail with extra headings that don't match pattern"
);
}
#[test]
fn test_wildcard_at_start() {
let config = MD043Config {
headings: vec!["*".to_string(), "## End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Random\n\n## Stuff\n\n## End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* at start should allow any preceding headings");
let config = MD043Config {
headings: vec!["+".to_string(), "## End".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "## End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "+ at start should require at least one heading");
let content = "# First\n\n## End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "+ at start should allow headings before End");
}
#[test]
fn test_wildcard_with_setext_headings() {
let config = MD043Config {
headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Wildcards should work with setext headings");
}
#[test]
fn test_empty_document_with_required_wildcards() {
let config = MD043Config {
headings: vec!["?".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "No headings";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Empty document should fail with ? requirement");
let config = MD043Config {
headings: vec!["+".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "No headings";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Empty document should fail with + requirement");
}
#[test]
fn test_trailing_headings_after_pattern_completion() {
let config = MD043Config {
headings: vec!["# Title".to_string(), "## Section".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Section\n\n### Extra";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
let config = MD043Config {
headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
match_case: false,
};
let rule = MD043RequiredHeadings::from_config_struct(config);
let content = "# Title\n\n## Section\n\n### Extra";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "* at end should allow trailing headings");
}
#[test]
fn test_reordering_respects_case_levels_and_duplicate_occurrences() {
let insensitive = MD043RequiredHeadings::from_config_struct(MD043Config {
headings: vec!["# A".into(), "# B".into(), "# A".into()],
match_case: false,
});
let duplicate_move = LintContext::new("# A\n# a\n# B", crate::config::MarkdownFlavor::Standard, None);
let result = insensitive.check(&duplicate_move).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].message,
"Heading structure does not match required structure. Heading '# B' is out of order; expected between '# A' and '# A'"
);
assert_eq!(result[0].line, 3);
let sensitive = MD043RequiredHeadings::from_config_struct(MD043Config {
headings: vec!["# A".into(), "# B".into()],
match_case: true,
});
let case_mismatch = LintContext::new("# B\n# a", crate::config::MarkdownFlavor::Standard, None);
let result = sensitive.check(&case_mismatch).unwrap();
assert!(result.iter().all(|warning| !warning.message.contains("out of order")));
let wrong_level = LintContext::new("# B\n## A", crate::config::MarkdownFlavor::Standard, None);
let result = sensitive.check(&wrong_level).unwrap();
assert!(result.iter().all(|warning| !warning.message.contains("out of order")));
}
#[test]
fn test_leading_and_trailing_moves_report_configured_neighbors() {
let rule = MD043RequiredHeadings::new(vec!["# A".into(), "# B".into(), "# C".into()]);
let leading = LintContext::new("# C\n# A\n# B", crate::config::MarkdownFlavor::Standard, None);
let leading_result = rule.check(&leading).unwrap();
assert_eq!(leading_result.len(), 1);
assert_eq!(
leading_result[0].message,
"Heading structure does not match required structure. Heading '# C' is out of order; expected after '# B'"
);
let trailing = LintContext::new("# B\n# C\n# A", crate::config::MarkdownFlavor::Standard, None);
let trailing_result = rule.check(&trailing).unwrap();
assert_eq!(trailing_result.len(), 1);
assert_eq!(
trailing_result[0].message,
"Heading structure does not match required structure. Heading '# A' is out of order; expected before '# B'"
);
}
#[test]
fn test_exhaustive_small_alignments_are_deterministic_and_owned() {
let pattern_values = ["# A", "# B", "*", "+", "?"];
let actual_values = ["# A", "# B", "# X"];
for pattern_len in 1..=3 {
for pattern_number in 0..pattern_values.len().pow(pattern_len as u32) {
let mut number = pattern_number;
let mut headings = Vec::with_capacity(pattern_len);
for _ in 0..pattern_len {
headings.push(pattern_values[number % pattern_values.len()].to_string());
number /= pattern_values.len();
}
let obligations = headings.iter().filter(|heading| heading.as_str() != "*").count();
for actual_len in 0..=3 {
for actual_number in 0..actual_values.len().pow(actual_len as u32) {
let mut number = actual_number;
let mut actual = Vec::with_capacity(actual_len);
for _ in 0..actual_len {
actual.push(actual_values[number % actual_values.len()]);
number /= actual_values.len();
}
let content = if actual.is_empty() {
"plain text".to_string()
} else {
actual.join("\n")
};
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
for match_case in [false, true] {
let rule = MD043RequiredHeadings::from_config_struct(MD043Config {
headings: headings.clone(),
match_case,
});
let first = rule.check(&ctx).unwrap();
let second = rule.check(&ctx).unwrap();
assert_eq!(first, second, "pattern={headings:?}, actual={actual:?}");
let extracted = rule.extract_headings(&ctx);
let alignment = rule.alignment(&extracted);
let mut expected_uses = vec![0; headings.len()];
let mut actual_uses = vec![0; extracted.len()];
for event in &alignment.events {
match *event {
AlignmentEvent::LiteralMatch {
expected_index,
actual_index,
}
| AlignmentEvent::Substitution {
expected_index,
actual_index,
}
| AlignmentEvent::OutOfOrder {
expected_index,
actual_index,
} => {
expected_uses[expected_index] += 1;
actual_uses[actual_index] += 1;
}
AlignmentEvent::RequiredWildcardMatch {
source_index,
actual_index,
} => {
expected_uses[source_index] += 1;
actual_uses[actual_index] += 1;
}
AlignmentEvent::RepeatingWildcardMatch { actual_index }
| AlignmentEvent::Unexpected { actual_index } => {
actual_uses[actual_index] += 1;
}
AlignmentEvent::MissingLiteral { expected_index, .. } => {
expected_uses[expected_index] += 1;
}
AlignmentEvent::UnsatisfiedWildcard { source_index, .. } => {
expected_uses[source_index] += 1;
}
}
}
for (index, pattern) in headings.iter().enumerate() {
let expected_count = usize::from(pattern != "*");
assert_eq!(
expected_uses[index], expected_count,
"pattern={headings:?}, actual={actual:?}, events={:?}",
alignment.events
);
}
assert!(
actual_uses.iter().all(|uses| *uses == 1),
"pattern={headings:?}, actual={actual:?}, events={:?}",
alignment.events
);
assert_eq!(
first.is_empty(),
wildcard_language_accepts(&rule, &extracted),
"pattern={headings:?}, actual={actual:?}, events={:?}",
alignment.events
);
assert!(first.len() <= obligations + actual.len());
}
}
}
}
}
}
#[test]
fn test_exhaustive_len4_alignment_matches_wildcard_language() {
let pattern_values = ["# A", "# B", "*", "+", "?"];
let actual_values = ["# A", "# B", "# X"];
let mut contents = Vec::new();
for actual_len in 0..=4usize {
for actual_number in 0..actual_values.len().pow(actual_len as u32) {
let mut number = actual_number;
let mut actual = Vec::with_capacity(actual_len);
for _ in 0..actual_len {
actual.push(actual_values[number % actual_values.len()]);
number /= actual_values.len();
}
contents.push((actual.join("\n"), actual));
}
}
let documents: Vec<(LintContext, &Vec<&str>)> = contents
.iter()
.map(|(content, actual)| {
let text = if actual.is_empty() { "plain text" } else { content };
(
LintContext::new(text, crate::config::MarkdownFlavor::Standard, None),
actual,
)
})
.collect();
for pattern_len in 1..=4usize {
for pattern_number in 0..pattern_values.len().pow(pattern_len as u32) {
let mut number = pattern_number;
let mut headings = Vec::with_capacity(pattern_len);
for _ in 0..pattern_len {
headings.push(pattern_values[number % pattern_values.len()].to_string());
number /= pattern_values.len();
}
let rule = MD043RequiredHeadings::from_config_struct(MD043Config {
headings: headings.clone(),
match_case: false,
});
for (ctx, actual) in &documents {
let extracted = rule.extract_headings(ctx);
assert_eq!(
rule.check(ctx).unwrap().is_empty(),
wildcard_language_accepts(&rule, &extracted),
"pattern={headings:?}, actual={actual:?}"
);
}
}
}
}
#[test]
fn test_fully_equal_scores_prefer_the_earliest_literal_occurrence() {
let rule = MD043RequiredHeadings::new(vec!["# A".into(), "# A".into()]);
let ctx = LintContext::new("# A", crate::config::MarkdownFlavor::Standard, None);
let actual = rule.extract_headings(&ctx);
assert!(matches!(
rule.alignment(&actual).events.as_slice(),
[
AlignmentEvent::LiteralMatch {
expected_index: 0,
actual_index: 0
},
AlignmentEvent::MissingLiteral { expected_index: 1, .. }
]
));
let rule = MD043RequiredHeadings::new(vec!["# A".into()]);
let ctx = LintContext::new("# A\n# A", crate::config::MarkdownFlavor::Standard, None);
let actual = rule.extract_headings(&ctx);
assert!(matches!(
rule.alignment(&actual).events.as_slice(),
[
AlignmentEvent::LiteralMatch {
expected_index: 0,
actual_index: 0
},
AlignmentEvent::Unexpected { actual_index: 1 }
]
));
}
fn wildcard_language_accepts(rule: &MD043RequiredHeadings, actual: &[DocumentHeading]) -> bool {
let mut pattern_index = 0;
let mut actual_index = 0;
while pattern_index < rule.config.headings.len() {
if !matches!(rule.config.headings[pattern_index].as_str(), "*" | "+" | "?") {
if actual
.get(actual_index)
.is_none_or(|actual| !rule.headings_match(&rule.config.headings[pattern_index], &actual.text))
{
return false;
}
pattern_index += 1;
actual_index += 1;
continue;
}
let run_start = pattern_index;
while pattern_index < rule.config.headings.len()
&& matches!(rule.config.headings[pattern_index].as_str(), "*" | "+" | "?")
{
pattern_index += 1;
}
let required = rule.config.headings[run_start..pattern_index]
.iter()
.filter(|pattern| matches!(pattern.as_str(), "+" | "?"))
.count();
if actual.len().saturating_sub(actual_index) < required {
return false;
}
actual_index += required;
let repeats = rule.config.headings[run_start..pattern_index]
.iter()
.any(|pattern| matches!(pattern.as_str(), "*" | "+"));
if repeats {
if let Some(anchor) = rule.config.headings.get(pattern_index) {
while actual_index < actual.len() && !rule.headings_match(anchor, &actual[actual_index].text) {
actual_index += 1;
}
} else {
actual_index = actual.len();
}
}
}
actual_index == actual.len()
}
}