rumdl_lib/rules/md093_no_formatting_in_headings.rs
1//! Rule MD093: inline formatting inside a heading.
2//!
3//! A heading is not only text on the page. It is also the source of generated
4//! artifacts — a table of contents, an anchor, a sidebar or outline entry, a
5//! PDF bookmark — and inline markup reaches those inconsistently. One generator
6//! strips the markers, another emits them literally, a third keeps the markup in
7//! the page and drops it from the anchor, so the same heading can read
8//! `Method map()` in the body and ``Method `map()` `` in the table of contents.
9//! Nothing in the source says which will happen, because the answer belongs to
10//! the tool that consumes the document rather than to the document.
11//!
12//! The other half of the problem is that markup in a heading is often
13//! unintentional. A heading that names a file or an identifier containing `_`
14//! or `*` becomes emphasis on its own: the heading `## __tests__/gt.test.js`
15//! renders as *tests*/gt.test.js. The underscores are gone from the page, from
16//! the anchor and from the table of contents, and the document lints clean, so
17//! nothing tells the author the path they published is not the path they wrote.
18//!
19//! The rule is off by default: a code span naming a method in a heading is
20//! normal practice in plenty of projects, and a project that wants its headings
21//! free of markup enables the rule deliberately.
22//!
23//! **Detection only, by design.** Removing the markers changes what renders
24//! rather than only how the source reads:
25//!
26//! - a heading whose path sits in a code span — dropping the backticks turns
27//! the path into bold text;
28//! - `## **tests**/sort.test.js` — dropping the emphasis yields
29//! `tests/sort.test.js`, a plausible path that was never in the source.
30//!
31//! Both rewrites are silent corruptions, and which one the author meant (rewrite
32//! the heading, escape the markers, move the identifier out of the heading) is
33//! not derivable from the source.
34
35mod md093_config;
36#[cfg(test)]
37mod tests;
38
39use std::collections::HashSet;
40
41use crate::lint_context::LintContext;
42use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
43use md093_config::MD093Config;
44
45/// Longest excerpt of the offending span shown in a message.
46const MAX_EXCERPT_CHARS: usize = 40;
47
48/// What was found, for the message.
49#[derive(Clone, Copy)]
50enum Construct {
51 Code,
52 Strong,
53 Emphasis,
54}
55
56impl Construct {
57 fn noun(self) -> &'static str {
58 match self {
59 Construct::Code => "Inline code",
60 Construct::Strong => "Strong emphasis",
61 Construct::Emphasis => "Emphasis",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Default)]
67pub struct MD093NoFormattingInHeadings {
68 config: MD093Config,
69}
70
71impl MD093NoFormattingInHeadings {
72 fn from_config_struct(config: MD093Config) -> Self {
73 Self { config }
74 }
75
76 /// Lines carrying the text of a valid heading. For a setext heading that is
77 /// the text line rather than the underline, which is also the line every
78 /// inline span of that heading sits on.
79 fn heading_lines(ctx: &LintContext) -> HashSet<usize> {
80 ctx.headings()
81 .filter(|heading| heading.heading.is_valid)
82 .map(|heading| heading.line_num)
83 .collect()
84 }
85
86 fn warning(&self, ctx: &LintContext, span: (usize, usize), construct: Construct) -> LintWarning {
87 let (line, column) = ctx.offset_to_line_col(span.0);
88 let source = &ctx.content[span.0..span.1];
89 LintWarning {
90 rule_name: Some(self.name().to_string()),
91 severity: Severity::Warning,
92 line,
93 column,
94 end_line: line,
95 end_column: column + source.chars().count(),
96 message: format!("{} in heading: {}", construct.noun(), excerpt(source)),
97 fix: None,
98 }
99 }
100}
101
102/// One line of source, shortened for a message.
103fn excerpt(source: &str) -> String {
104 match source.char_indices().nth(MAX_EXCERPT_CHARS) {
105 Some((cut, _)) => format!("{}...", &source[..cut]),
106 None => source.to_string(),
107 }
108}
109
110impl Rule for MD093NoFormattingInHeadings {
111 fn name(&self) -> &'static str {
112 "MD093"
113 }
114
115 fn description(&self) -> &'static str {
116 "Headings should not contain inline formatting"
117 }
118
119 fn category(&self) -> RuleCategory {
120 RuleCategory::Heading
121 }
122
123 fn should_skip(&self, _ctx: &LintContext) -> bool {
124 // Every switch off is the only cheap answer. Whether a document has
125 // headings is not decidable from a character: an ATX heading needs `#`,
126 // a setext heading needs neither.
127 !self.config.code && !self.config.strong && !self.config.emphasis
128 }
129
130 fn check(&self, ctx: &LintContext) -> LintResult {
131 let heading_lines = Self::heading_lines(ctx);
132 if heading_lines.is_empty() {
133 return Ok(Vec::new());
134 }
135
136 let mut warnings = Vec::new();
137
138 if self.config.code {
139 for span in ctx.code_spans().iter() {
140 if heading_lines.contains(&span.line) {
141 warnings.push(self.warning(ctx, (span.byte_offset, span.byte_end), Construct::Code));
142 }
143 }
144 }
145
146 for span in ctx.emphasis_spans().iter() {
147 let construct = if span.is_strong {
148 Construct::Strong
149 } else {
150 Construct::Emphasis
151 };
152 let wanted = if span.is_strong {
153 self.config.strong
154 } else {
155 self.config.emphasis
156 };
157 if wanted && heading_lines.contains(&span.line) {
158 warnings.push(self.warning(ctx, (span.byte_offset, span.byte_end), construct));
159 }
160 }
161
162 warnings.sort_by_key(|warning| (warning.line, warning.column));
163 Ok(warnings)
164 }
165
166 fn fix_capability(&self) -> FixCapability {
167 FixCapability::Unfixable
168 }
169
170 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
171 // Detection only: see the module comment. Every mechanical rewrite of
172 // the markers changes the rendered heading.
173 Ok(ctx.content.to_string())
174 }
175
176 fn as_any(&self) -> &dyn std::any::Any {
177 self
178 }
179
180 crate::impl_rule_config_methods!(MD093Config);
181}