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