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