Skip to main content

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}