rumdl_lib/rules/
md078_missing_chunk_labels.rs1use 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 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 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 FixCapability::Unfixable
73 }
74
75 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
76 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
100fn 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
111fn 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 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 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 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}