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()
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}