Skip to main content

rumdl_lib/rules/
md078_missing_chunk_labels.rs

1//! Rule MD078: Executable Quarto/RMarkdown chunks should have a label.
2//!
3//! Labels are required for figure/table cross-references, caching, and stable
4//! anchors. This rule reports executable chunks (e.g. ` ```{r} `, ` ```{python} `)
5//! that have neither an inline label nor a `#| label:` hashpipe option.
6//!
7//! Quarto flavor only; a no-op for every other flavor.
8
9use crate::config::MarkdownFlavor;
10use crate::lint_context::LintContext;
11use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::quarto_chunks::{is_executable_chunk, parse_hashpipe_labels, parse_inline_chunk_header};
13use crate::utils::range_utils::byte_to_char_count;
14
15#[derive(Debug, Clone, Default)]
16pub struct MD078MissingChunkLabels;
17
18impl Rule for MD078MissingChunkLabels {
19    fn name(&self) -> &'static str {
20        "MD078"
21    }
22
23    fn description(&self) -> &'static str {
24        "Executable Quarto chunks should have a label"
25    }
26
27    fn check(&self, ctx: &LintContext) -> LintResult {
28        if ctx.flavor != MarkdownFlavor::Quarto {
29            return Ok(Vec::new());
30        }
31
32        let mut warnings = Vec::new();
33        for detail in &ctx.code_block_details {
34            if !detail.is_fenced || !is_executable_chunk(&detail.info_string) {
35                continue;
36            }
37
38            // Inline label?
39            let Some(header) = parse_inline_chunk_header(&detail.info_string) else {
40                continue;
41            };
42            if !header.labels.is_empty() {
43                continue;
44            }
45
46            // Hashpipe label inside the block body?
47            let body = block_body(ctx.content, detail.start);
48            if !parse_hashpipe_labels(body).is_empty() {
49                continue;
50            }
51
52            let (line, column, end_column) = info_string_span(ctx, detail.start, &detail.info_string);
53            warnings.push(LintWarning {
54                rule_name: Some(self.name().to_string()),
55                line,
56                column,
57                end_line: line,
58                end_column,
59                severity: Severity::Warning,
60                message: format!(
61                    "Executable chunk `{}` has no label; add `#| label: ...` or `{{{}, label=...}}`",
62                    detail.info_string.trim(),
63                    header.engine,
64                ),
65                fix: None,
66            });
67        }
68        Ok(warnings)
69    }
70
71    fn fix_capability(&self) -> FixCapability {
72        // A chunk label is a human-chosen identifier; the rule is diagnostic-only.
73        FixCapability::Unfixable
74    }
75
76    fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
77        // MD078 has no auto-fix: a label is a human-chosen identifier.
78        Err(LintError::FixFailed("MD078 has no auto-fix".to_string()))
79    }
80
81    fn category(&self) -> RuleCategory {
82        RuleCategory::CodeBlock
83    }
84
85    fn should_skip(&self, ctx: &LintContext) -> bool {
86        ctx.flavor != MarkdownFlavor::Quarto || ctx.code_block_details.is_empty()
87    }
88
89    fn as_any(&self) -> &dyn std::any::Any {
90        self
91    }
92
93    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
94    where
95        Self: Sized,
96    {
97        Box::new(Self)
98    }
99}
100
101/// Slice the body of a fenced code block: everything after the opening fence
102/// line. The closing fence line, if present, will be encountered by the
103/// caller's scanner as a non-hashpipe line and stop further parsing.
104fn block_body(content: &str, block_start: usize) -> &str {
105    let rest = &content[block_start..];
106    match rest.find('\n') {
107        Some(idx) => &rest[idx + 1..],
108        None => "",
109    }
110}
111
112/// Compute the (line, start_column, end_column) span covering the chunk header
113/// on its line. 1-indexed for the LSP.
114fn info_string_span(ctx: &LintContext, block_start: usize, info_string: &str) -> (usize, usize, usize) {
115    let line_idx = ctx
116        .line_offsets
117        .binary_search(&block_start)
118        .unwrap_or_else(|i| i.saturating_sub(1));
119    let line_start = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
120    let line_end = ctx.line_offsets.get(line_idx + 1).copied().unwrap_or(ctx.content.len());
121    let line_text = &ctx.content[line_start..line_end];
122
123    let (start_col, end_col) = match line_text.find(info_string.trim()) {
124        Some(off) => {
125            // `off` is a byte offset within the line; the column is a character offset.
126            let start = byte_to_char_count(line_text, off);
127            let end = start + info_string.trim().chars().count();
128            (start, end)
129        }
130        None => (1, line_text.trim_end_matches('\n').chars().count().max(1) + 1),
131    };
132
133    (line_idx + 1, start_col, end_col)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::lint_context::LintContext;
140
141    fn check_quarto(content: &str) -> Vec<LintWarning> {
142        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
143        MD078MissingChunkLabels.check(&ctx).unwrap()
144    }
145
146    fn check_standard(content: &str) -> Vec<LintWarning> {
147        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
148        MD078MissingChunkLabels.check(&ctx).unwrap()
149    }
150
151    #[test]
152    fn declares_unfixable() {
153        // fix() returns Err, so the declared capability must be Unfixable to
154        // avoid a wasted fix() call on every fix pass.
155        assert_eq!(MD078MissingChunkLabels.fix_capability(), FixCapability::Unfixable);
156    }
157
158    #[test]
159    fn flags_executable_chunk_without_label() {
160        let warnings = check_quarto("```{r}\n1 + 1\n```\n");
161        assert_eq!(warnings.len(), 1);
162        assert_eq!(warnings[0].rule_name.as_deref(), Some("MD078"));
163    }
164
165    #[test]
166    fn accepts_inline_positional_label() {
167        let warnings = check_quarto("```{r setup}\n1 + 1\n```\n");
168        assert!(warnings.is_empty());
169    }
170
171    #[test]
172    fn accepts_inline_key_label() {
173        let warnings = check_quarto("```{r, label=setup}\n1 + 1\n```\n");
174        assert!(warnings.is_empty());
175    }
176
177    #[test]
178    fn accepts_hashpipe_label() {
179        let warnings = check_quarto("```{r}\n#| label: setup\n1 + 1\n```\n");
180        assert!(warnings.is_empty());
181    }
182
183    #[test]
184    fn ignores_display_blocks() {
185        let warnings = check_quarto("```r\n1 + 1\n```\n");
186        assert!(warnings.is_empty());
187    }
188
189    #[test]
190    fn no_warnings_under_standard_flavor() {
191        // Even a missing label in a Quarto-looking chunk must not fire under
192        // Standard, since braced info strings are non-standard CommonMark.
193        let warnings = check_standard("```{r}\n1 + 1\n```\n");
194        assert!(warnings.is_empty());
195    }
196
197    #[test]
198    fn flags_each_unlabeled_chunk_independently() {
199        let content = "```{r}\n1 + 1\n```\n\n```{python}\nprint(1)\n```\n";
200        let warnings = check_quarto(content);
201        assert_eq!(warnings.len(), 2);
202    }
203
204    #[test]
205    fn hashpipe_below_code_is_not_a_label() {
206        // Hashpipe options must precede any code, matching Quarto's parser.
207        let content = "```{r}\n1 + 1\n#| label: too-late\n```\n";
208        let warnings = check_quarto(content);
209        assert_eq!(warnings.len(), 1);
210    }
211
212    #[test]
213    fn no_auto_fix_offered() {
214        let warnings = check_quarto("```{r}\n1 + 1\n```\n");
215        assert!(warnings[0].fix.is_none());
216    }
217}