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::{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(&self, _ctx: &LintContext) -> Result<String, LintError> {
71        // MD078 has no auto-fix: a label is a human-chosen identifier.
72        Err(LintError::FixFailed("MD078 has no auto-fix".to_string()))
73    }
74
75    fn category(&self) -> RuleCategory {
76        RuleCategory::CodeBlock
77    }
78
79    fn should_skip(&self, ctx: &LintContext) -> bool {
80        ctx.flavor != MarkdownFlavor::Quarto || ctx.code_block_details.is_empty()
81    }
82
83    fn as_any(&self) -> &dyn std::any::Any {
84        self
85    }
86
87    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
88    where
89        Self: Sized,
90    {
91        Box::new(Self)
92    }
93}
94
95/// Slice the body of a fenced code block: everything after the opening fence
96/// line. The closing fence line, if present, will be encountered by the
97/// caller's scanner as a non-hashpipe line and stop further parsing.
98fn block_body(content: &str, block_start: usize) -> &str {
99    let rest = &content[block_start..];
100    match rest.find('\n') {
101        Some(idx) => &rest[idx + 1..],
102        None => "",
103    }
104}
105
106/// Compute the (line, start_column, end_column) span covering the chunk header
107/// on its line. 1-indexed for the LSP.
108fn info_string_span(ctx: &LintContext, block_start: usize, info_string: &str) -> (usize, usize, usize) {
109    let line_idx = ctx
110        .line_offsets
111        .binary_search(&block_start)
112        .unwrap_or_else(|i| i.saturating_sub(1));
113    let line_start = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
114    let line_end = ctx.line_offsets.get(line_idx + 1).copied().unwrap_or(ctx.content.len());
115    let line_text = &ctx.content[line_start..line_end];
116
117    let (start_col, end_col) = match line_text.find(info_string.trim()) {
118        Some(off) => {
119            let start = off + 1;
120            let end = start + info_string.trim().chars().count();
121            (start, end)
122        }
123        None => (1, line_text.trim_end_matches('\n').chars().count().max(1) + 1),
124    };
125
126    (line_idx + 1, start_col, end_col)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::lint_context::LintContext;
133
134    fn check_quarto(content: &str) -> Vec<LintWarning> {
135        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
136        MD078MissingChunkLabels.check(&ctx).unwrap()
137    }
138
139    fn check_standard(content: &str) -> Vec<LintWarning> {
140        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
141        MD078MissingChunkLabels.check(&ctx).unwrap()
142    }
143
144    #[test]
145    fn flags_executable_chunk_without_label() {
146        let warnings = check_quarto("```{r}\n1 + 1\n```\n");
147        assert_eq!(warnings.len(), 1);
148        assert_eq!(warnings[0].rule_name.as_deref(), Some("MD078"));
149    }
150
151    #[test]
152    fn accepts_inline_positional_label() {
153        let warnings = check_quarto("```{r setup}\n1 + 1\n```\n");
154        assert!(warnings.is_empty());
155    }
156
157    #[test]
158    fn accepts_inline_key_label() {
159        let warnings = check_quarto("```{r, label=setup}\n1 + 1\n```\n");
160        assert!(warnings.is_empty());
161    }
162
163    #[test]
164    fn accepts_hashpipe_label() {
165        let warnings = check_quarto("```{r}\n#| label: setup\n1 + 1\n```\n");
166        assert!(warnings.is_empty());
167    }
168
169    #[test]
170    fn ignores_display_blocks() {
171        let warnings = check_quarto("```r\n1 + 1\n```\n");
172        assert!(warnings.is_empty());
173    }
174
175    #[test]
176    fn no_warnings_under_standard_flavor() {
177        // Even a missing label in a Quarto-looking chunk must not fire under
178        // Standard, since braced info strings are non-standard CommonMark.
179        let warnings = check_standard("```{r}\n1 + 1\n```\n");
180        assert!(warnings.is_empty());
181    }
182
183    #[test]
184    fn flags_each_unlabeled_chunk_independently() {
185        let content = "```{r}\n1 + 1\n```\n\n```{python}\nprint(1)\n```\n";
186        let warnings = check_quarto(content);
187        assert_eq!(warnings.len(), 2);
188    }
189
190    #[test]
191    fn hashpipe_below_code_is_not_a_label() {
192        // Hashpipe options must precede any code, matching Quarto's parser.
193        let content = "```{r}\n1 + 1\n#| label: too-late\n```\n";
194        let warnings = check_quarto(content);
195        assert_eq!(warnings.len(), 1);
196    }
197
198    #[test]
199    fn no_auto_fix_offered() {
200        let warnings = check_quarto("```{r}\n1 + 1\n```\n");
201        assert!(warnings[0].fix.is_none());
202    }
203}