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().map(|heading| heading.line_num).collect()
81 }
82
83 fn warning(&self, ctx: &LintContext, span: (usize, usize), construct: Construct) -> LintWarning {
84 let (line, column) = ctx.offset_to_line_col(span.0);
85 let source = &ctx.content[span.0..span.1];
86 LintWarning {
87 rule_name: Some(self.name().to_string()),
88 severity: Severity::Warning,
89 line,
90 column,
91 end_line: line,
92 end_column: column + source.chars().count(),
93 message: format!("{} in heading: {}", construct.noun(), excerpt(source)),
94 fix: None,
95 }
96 }
97}
98
99/// One line of source, shortened for a message.
100fn excerpt(source: &str) -> String {
101 match source.char_indices().nth(MAX_EXCERPT_CHARS) {
102 Some((cut, _)) => format!("{}...", &source[..cut]),
103 None => source.to_string(),
104 }
105}
106
107impl Rule for MD093NoFormattingInHeadings {
108 fn name(&self) -> &'static str {
109 "MD093"
110 }
111
112 fn description(&self) -> &'static str {
113 "Headings should not contain inline formatting"
114 }
115
116 fn category(&self) -> RuleCategory {
117 RuleCategory::Heading
118 }
119
120 fn should_skip(&self, _ctx: &LintContext) -> bool {
121 // Every switch off is the only cheap answer. Whether a document has
122 // headings is not decidable from a character: an ATX heading needs `#`,
123 // a setext heading needs neither.
124 !self.config.code && !self.config.strong && !self.config.emphasis
125 }
126
127 fn check(&self, ctx: &LintContext) -> LintResult {
128 let heading_lines = Self::heading_lines(ctx);
129 if heading_lines.is_empty() {
130 return Ok(Vec::new());
131 }
132
133 let mut warnings = Vec::new();
134
135 if self.config.code {
136 for span in ctx.code_spans().iter() {
137 if heading_lines.contains(&span.line) {
138 warnings.push(self.warning(ctx, (span.byte_offset, span.byte_end), Construct::Code));
139 }
140 }
141 }
142
143 for span in ctx.emphasis_spans().iter() {
144 let construct = if span.is_strong {
145 Construct::Strong
146 } else {
147 Construct::Emphasis
148 };
149 let wanted = if span.is_strong {
150 self.config.strong
151 } else {
152 self.config.emphasis
153 };
154 if wanted && heading_lines.contains(&span.line) {
155 warnings.push(self.warning(ctx, (span.byte_offset, span.byte_end), construct));
156 }
157 }
158
159 warnings.sort_by_key(|warning| (warning.line, warning.column));
160 Ok(warnings)
161 }
162
163 fn fix_capability(&self) -> FixCapability {
164 FixCapability::Unfixable
165 }
166
167 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
168 // Detection only: see the module comment. Every mechanical rewrite of
169 // the markers changes the rendered heading.
170 Ok(ctx.content.to_string())
171 }
172
173 fn as_any(&self) -> &dyn std::any::Any {
174 self
175 }
176
177 crate::impl_rule_config_methods!(MD093Config);
178}