rumdl_lib/rules/
md079_chunk_label_spaces.rs1use crate::config::MarkdownFlavor;
16use crate::lint_context::LintContext;
17use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
18use crate::utils::quarto_chunks::{
19 ChunkLabelSource, is_executable_chunk, parse_hashpipe_labels, parse_inline_chunk_header,
20};
21
22#[derive(Debug, Clone, Default)]
23pub struct MD079ChunkLabelSpaces;
24
25impl Rule for MD079ChunkLabelSpaces {
26 fn name(&self) -> &'static str {
27 "MD079"
28 }
29
30 fn description(&self) -> &'static str {
31 "Quarto chunk labels must not contain whitespace"
32 }
33
34 fn check(&self, ctx: &LintContext) -> LintResult {
35 if ctx.flavor != MarkdownFlavor::Quarto {
36 return Ok(Vec::new());
37 }
38
39 let mut warnings = Vec::new();
40 for detail in &ctx.code_block_details {
41 if !detail.is_fenced || !is_executable_chunk(&detail.info_string) {
42 continue;
43 }
44
45 if let Some(header) = parse_inline_chunk_header(&detail.info_string) {
47 let positional: Vec<_> = header
50 .labels
51 .iter()
52 .filter(|l| l.source == ChunkLabelSource::InlinePositional)
53 .collect();
54 if positional.len() >= 2 {
55 let combined = positional
56 .iter()
57 .map(|l| l.value.as_str())
58 .collect::<Vec<_>>()
59 .join(" ");
60 warnings.push(make_warning(
61 self.name(),
62 ctx,
63 detail.start,
64 &detail.info_string,
65 &combined,
66 ));
67 } else if let Some(label) = positional.first()
68 && label.value.chars().any(char::is_whitespace)
69 {
70 warnings.push(make_warning(
73 self.name(),
74 ctx,
75 detail.start,
76 &detail.info_string,
77 &label.value,
78 ));
79 }
80
81 for label in header.labels.iter().filter(|l| l.source == ChunkLabelSource::InlineKey) {
83 if label.value.chars().any(char::is_whitespace) {
84 warnings.push(make_warning(
85 self.name(),
86 ctx,
87 detail.start,
88 &detail.info_string,
89 &label.value,
90 ));
91 }
92 }
93 }
94
95 let body = block_body(ctx.content, detail.start);
97 for label in parse_hashpipe_labels(body) {
98 if label.value.chars().any(char::is_whitespace) {
99 warnings.push(make_warning(
100 self.name(),
101 ctx,
102 detail.start,
103 &detail.info_string,
104 &label.value,
105 ));
106 }
107 }
108 }
109 Ok(warnings)
110 }
111
112 fn fix_capability(&self) -> FixCapability {
113 FixCapability::Unfixable
115 }
116
117 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
118 Err(LintError::FixFailed("MD079 has no auto-fix".to_string()))
120 }
121
122 fn category(&self) -> RuleCategory {
123 RuleCategory::CodeBlock
124 }
125
126 fn should_skip(&self, ctx: &LintContext) -> bool {
127 ctx.flavor != MarkdownFlavor::Quarto || ctx.code_block_details.is_empty()
128 }
129
130 fn as_any(&self) -> &dyn std::any::Any {
131 self
132 }
133
134 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
135 where
136 Self: Sized,
137 {
138 Box::new(Self)
139 }
140}
141
142fn block_body(content: &str, block_start: usize) -> &str {
143 let rest = &content[block_start..];
144 match rest.find('\n') {
145 Some(idx) => &rest[idx + 1..],
146 None => "",
147 }
148}
149
150fn make_warning(
151 rule_name: &str,
152 ctx: &LintContext,
153 block_start: usize,
154 info_string: &str,
155 label_value: &str,
156) -> LintWarning {
157 let line_idx = ctx
158 .line_offsets
159 .binary_search(&block_start)
160 .unwrap_or_else(|i| i.saturating_sub(1));
161 let line_start = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
162 let line_end = ctx.line_offsets.get(line_idx + 1).copied().unwrap_or(ctx.content.len());
163 let line_text = &ctx.content[line_start..line_end];
164
165 let trimmed = info_string.trim();
166 let (start_col, end_col) = match line_text.find(trimmed) {
167 Some(off) => {
168 let start = off + 1;
169 let end = start + trimmed.chars().count();
170 (start, end)
171 }
172 None => (1, line_text.trim_end_matches('\n').chars().count().max(1) + 1),
173 };
174
175 LintWarning {
176 rule_name: Some(rule_name.to_string()),
177 line: line_idx + 1,
178 column: start_col,
179 end_line: line_idx + 1,
180 end_column: end_col,
181 severity: Severity::Warning,
182 message: format!("Chunk label `{label_value}` contains whitespace; use a hyphen or underscore instead"),
183 fix: None,
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::lint_context::LintContext;
191
192 fn check_quarto(content: &str) -> Vec<LintWarning> {
193 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
194 MD079ChunkLabelSpaces.check(&ctx).unwrap()
195 }
196
197 fn check_standard(content: &str) -> Vec<LintWarning> {
198 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
199 MD079ChunkLabelSpaces.check(&ctx).unwrap()
200 }
201
202 #[test]
203 fn declares_unfixable() {
204 assert_eq!(MD079ChunkLabelSpaces.fix_capability(), FixCapability::Unfixable);
207 }
208
209 #[test]
210 fn flags_implicit_positional_spaces() {
211 let warnings = check_quarto("```{r several words}\n1 + 1\n```\n");
212 assert_eq!(warnings.len(), 1);
213 assert!(warnings[0].message.contains("several words"));
214 }
215
216 #[test]
217 fn flags_quoted_label_with_spaces() {
218 let warnings = check_quarto("```{r, label=\"my label\"}\n1 + 1\n```\n");
219 assert_eq!(warnings.len(), 1);
220 assert!(warnings[0].message.contains("my label"));
221 }
222
223 #[test]
224 fn flags_hashpipe_label_with_spaces() {
225 let warnings = check_quarto("```{r}\n#| label: my label\n1 + 1\n```\n");
226 assert_eq!(warnings.len(), 1);
227 assert!(warnings[0].message.contains("my label"));
228 }
229
230 #[test]
231 fn accepts_single_positional_label() {
232 let warnings = check_quarto("```{r setup}\n1 + 1\n```\n");
233 assert!(warnings.is_empty());
234 }
235
236 #[test]
237 fn accepts_hyphenated_or_underscored_labels() {
238 assert!(check_quarto("```{r my-label}\n1\n```\n").is_empty());
239 assert!(check_quarto("```{r, label=my_label}\n1\n```\n").is_empty());
240 assert!(check_quarto("```{r}\n#| label: my-label\n1\n```\n").is_empty());
241 }
242
243 #[test]
244 fn ignores_display_blocks() {
245 let warnings = check_quarto("```r several words\n1 + 1\n```\n");
248 assert!(warnings.is_empty());
249 }
250
251 #[test]
252 fn no_warnings_under_standard_flavor() {
253 let warnings = check_standard("```{r several words}\n1 + 1\n```\n");
254 assert!(warnings.is_empty());
255 }
256
257 #[test]
258 fn does_not_flag_options_after_label() {
259 let warnings = check_quarto("```{r setup, echo=FALSE}\n1 + 1\n```\n");
261 assert!(warnings.is_empty());
262 }
263
264 #[test]
265 fn no_auto_fix_offered() {
266 let warnings = check_quarto("```{r several words}\n1 + 1\n```\n");
267 assert!(warnings[0].fix.is_none());
268 }
269
270 #[test]
271 fn flags_quoted_positional_with_spaces() {
272 let warnings = check_quarto("```{r \"my label\"}\n1 + 1\n```\n");
275 assert_eq!(warnings.len(), 1);
276 assert!(warnings[0].message.contains("my label"));
277 }
278}