Skip to main content

rumdl_lib/rules/
md079_chunk_label_spaces.rs

1//! Rule MD079: Quarto chunk labels must not contain whitespace.
2//!
3//! Whitespace in chunk labels silently breaks Quarto cross-references
4//! (`@fig-foo`) and produces unstable HTML anchors. This rule catches:
5//!
6//! - Implicit-positional spaces: ` ```{r several words} ` — multiple bare
7//!   words before any `key=value` are interpreted by knitr/Quarto as a
8//!   single space-separated label.
9//! - Quoted-value spaces: ` ```{r, label="my label"} `.
10//! - Hashpipe spaces: `#| label: my label`.
11//!
12//! Quarto flavor only; a no-op for every other flavor. No auto-fix —
13//! renaming a label is a semantic choice (hyphen vs underscore vs collapse).
14
15use crate::config::MarkdownFlavor;
16use crate::lint_context::LintContext;
17use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
18use crate::utils::quarto_chunks::{
19    ChunkLabelSource, is_executable_chunk, parse_hashpipe_labels, parse_inline_chunk_header,
20};
21
22#[derive(Debug, Clone, Default)]
23pub struct MD079ChunkLabelSpaces;
24
25impl Rule for MD079ChunkLabelSpaces {
26    fn name(&self) -> &'static str {
27        "MD079"
28    }
29
30    fn description(&self) -> &'static str {
31        "Quarto chunk labels must not contain whitespace"
32    }
33
34    fn check(&self, ctx: &LintContext) -> LintResult {
35        if ctx.flavor != MarkdownFlavor::Quarto {
36            return Ok(Vec::new());
37        }
38
39        let mut warnings = Vec::new();
40        for detail in &ctx.code_block_details {
41            if !detail.is_fenced || !is_executable_chunk(&detail.info_string) {
42                continue;
43            }
44
45            // Inline labels.
46            if let Some(header) = parse_inline_chunk_header(&detail.info_string) {
47                // Implicit-positional run: two or more bare words before any
48                // key=value parse as one space-separated label per Quarto.
49                let positional: Vec<_> = header
50                    .labels
51                    .iter()
52                    .filter(|l| l.source == ChunkLabelSource::InlinePositional)
53                    .collect();
54                if positional.len() >= 2 {
55                    let combined = positional
56                        .iter()
57                        .map(|l| l.value.as_str())
58                        .collect::<Vec<_>>()
59                        .join(" ");
60                    warnings.push(make_warning(
61                        self.name(),
62                        ctx,
63                        detail.start,
64                        &detail.info_string,
65                        &combined,
66                    ));
67                } else if let Some(label) = positional.first()
68                    && label.value.chars().any(char::is_whitespace)
69                {
70                    // Quoted positional like `{r "my label"}` is a single
71                    // token whose value already contains the offending space.
72                    warnings.push(make_warning(
73                        self.name(),
74                        ctx,
75                        detail.start,
76                        &detail.info_string,
77                        &label.value,
78                    ));
79                }
80
81                // Quoted `label="..."` containing spaces.
82                for label in header.labels.iter().filter(|l| l.source == ChunkLabelSource::InlineKey) {
83                    if label.value.chars().any(char::is_whitespace) {
84                        warnings.push(make_warning(
85                            self.name(),
86                            ctx,
87                            detail.start,
88                            &detail.info_string,
89                            &label.value,
90                        ));
91                    }
92                }
93            }
94
95            // Hashpipe `#| label: ...` containing spaces.
96            let body = block_body(ctx.content, detail.start);
97            for label in parse_hashpipe_labels(body) {
98                if label.value.chars().any(char::is_whitespace) {
99                    warnings.push(make_warning(
100                        self.name(),
101                        ctx,
102                        detail.start,
103                        &detail.info_string,
104                        &label.value,
105                    ));
106                }
107            }
108        }
109        Ok(warnings)
110    }
111
112    fn fix_capability(&self) -> FixCapability {
113        // Renaming a label is a human decision; the rule is diagnostic-only.
114        FixCapability::Unfixable
115    }
116
117    fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
118        // Renaming a label is a human decision (hyphen, underscore, or collapse).
119        Err(LintError::FixFailed("MD079 has no auto-fix".to_string()))
120    }
121
122    fn category(&self) -> RuleCategory {
123        RuleCategory::CodeBlock
124    }
125
126    fn should_skip(&self, ctx: &LintContext) -> bool {
127        ctx.flavor != MarkdownFlavor::Quarto || ctx.code_block_details.is_empty()
128    }
129
130    fn as_any(&self) -> &dyn std::any::Any {
131        self
132    }
133
134    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
135    where
136        Self: Sized,
137    {
138        Box::new(Self)
139    }
140}
141
142fn block_body(content: &str, block_start: usize) -> &str {
143    let rest = &content[block_start..];
144    match rest.find('\n') {
145        Some(idx) => &rest[idx + 1..],
146        None => "",
147    }
148}
149
150fn make_warning(
151    rule_name: &str,
152    ctx: &LintContext,
153    block_start: usize,
154    info_string: &str,
155    label_value: &str,
156) -> LintWarning {
157    let line_idx = ctx
158        .line_offsets
159        .binary_search(&block_start)
160        .unwrap_or_else(|i| i.saturating_sub(1));
161    let line_start = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
162    let line_end = ctx.line_offsets.get(line_idx + 1).copied().unwrap_or(ctx.content.len());
163    let line_text = &ctx.content[line_start..line_end];
164
165    let trimmed = info_string.trim();
166    let (start_col, end_col) = match line_text.find(trimmed) {
167        Some(off) => {
168            let start = off + 1;
169            let end = start + trimmed.chars().count();
170            (start, end)
171        }
172        None => (1, line_text.trim_end_matches('\n').chars().count().max(1) + 1),
173    };
174
175    LintWarning {
176        rule_name: Some(rule_name.to_string()),
177        line: line_idx + 1,
178        column: start_col,
179        end_line: line_idx + 1,
180        end_column: end_col,
181        severity: Severity::Warning,
182        message: format!("Chunk label `{label_value}` contains whitespace; use a hyphen or underscore instead"),
183        fix: None,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::lint_context::LintContext;
191
192    fn check_quarto(content: &str) -> Vec<LintWarning> {
193        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
194        MD079ChunkLabelSpaces.check(&ctx).unwrap()
195    }
196
197    fn check_standard(content: &str) -> Vec<LintWarning> {
198        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
199        MD079ChunkLabelSpaces.check(&ctx).unwrap()
200    }
201
202    #[test]
203    fn declares_unfixable() {
204        // fix() returns Err, so the declared capability must be Unfixable to
205        // avoid a wasted fix() call on every fix pass.
206        assert_eq!(MD079ChunkLabelSpaces.fix_capability(), FixCapability::Unfixable);
207    }
208
209    #[test]
210    fn flags_implicit_positional_spaces() {
211        let warnings = check_quarto("```{r several words}\n1 + 1\n```\n");
212        assert_eq!(warnings.len(), 1);
213        assert!(warnings[0].message.contains("several words"));
214    }
215
216    #[test]
217    fn flags_quoted_label_with_spaces() {
218        let warnings = check_quarto("```{r, label=\"my label\"}\n1 + 1\n```\n");
219        assert_eq!(warnings.len(), 1);
220        assert!(warnings[0].message.contains("my label"));
221    }
222
223    #[test]
224    fn flags_hashpipe_label_with_spaces() {
225        let warnings = check_quarto("```{r}\n#| label: my label\n1 + 1\n```\n");
226        assert_eq!(warnings.len(), 1);
227        assert!(warnings[0].message.contains("my label"));
228    }
229
230    #[test]
231    fn accepts_single_positional_label() {
232        let warnings = check_quarto("```{r setup}\n1 + 1\n```\n");
233        assert!(warnings.is_empty());
234    }
235
236    #[test]
237    fn accepts_hyphenated_or_underscored_labels() {
238        assert!(check_quarto("```{r my-label}\n1\n```\n").is_empty());
239        assert!(check_quarto("```{r, label=my_label}\n1\n```\n").is_empty());
240        assert!(check_quarto("```{r}\n#| label: my-label\n1\n```\n").is_empty());
241    }
242
243    #[test]
244    fn ignores_display_blocks() {
245        // Plain ` ```r several words ` is a display block, not a chunk.
246        // The trailing text is an info-string class list, not a label.
247        let warnings = check_quarto("```r several words\n1 + 1\n```\n");
248        assert!(warnings.is_empty());
249    }
250
251    #[test]
252    fn no_warnings_under_standard_flavor() {
253        let warnings = check_standard("```{r several words}\n1 + 1\n```\n");
254        assert!(warnings.is_empty());
255    }
256
257    #[test]
258    fn does_not_flag_options_after_label() {
259        // First bare word is the label, subsequent key=value args are options.
260        let warnings = check_quarto("```{r setup, echo=FALSE}\n1 + 1\n```\n");
261        assert!(warnings.is_empty());
262    }
263
264    #[test]
265    fn no_auto_fix_offered() {
266        let warnings = check_quarto("```{r several words}\n1 + 1\n```\n");
267        assert!(warnings[0].fix.is_none());
268    }
269
270    #[test]
271    fn flags_quoted_positional_with_spaces() {
272        // `{r "my label"}` parses as a single quoted positional. The value
273        // still contains a space, so it must be flagged.
274        let warnings = check_quarto("```{r \"my label\"}\n1 + 1\n```\n");
275        assert_eq!(warnings.len(), 1);
276        assert!(warnings[0].message.contains("my label"));
277    }
278}