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};
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 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 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 FixCapability::Unfixable
74 }
75
76 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
77 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
101fn 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
112fn 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 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 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 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 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}