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