1mod md088_config;
8
9use crate::filtered_lines::FilteredLinesExt;
10use crate::lint_context::LintContext;
11use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::range_utils::byte_to_char_count;
13use crate::utils::skip_context::is_in_math_context;
14use crate::utils::unicode;
15use md088_config::MD088Config;
16
17#[derive(Debug, Clone)]
19pub struct MD088QuotesDashes {
20 config: MD088Config,
21}
22
23impl Default for MD088QuotesDashes {
24 fn default() -> Self {
25 Self::from_config_struct(MD088Config::default())
26 }
27}
28
29impl MD088QuotesDashes {
30 fn from_config_struct(config: MD088Config) -> Self {
31 Self { config }
32 }
33
34 #[inline]
35 fn is_allowed(&self, c: char) -> bool {
36 self.config.allow.contains(&c)
37 }
38
39 #[inline]
40 fn replacement_for(&self, c: char) -> Option<&'static str> {
41 if self.is_allowed(c) {
42 return None;
43 }
44
45 match c {
51 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' | '\u{2032}' if self.config.normalize_quotes => Some("'"),
52 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' | '\u{2033}' if self.config.normalize_quotes => {
53 Some("\"")
54 }
55 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
56 if self.config.normalize_dashes =>
57 {
58 Some("-")
59 }
60 _ => None,
61 }
62 }
63
64 #[inline]
65 fn has_target_char(&self, ctx: &LintContext) -> bool {
66 ctx.content.chars().any(|c| self.replacement_for(c).is_some())
67 }
68}
69
70impl Rule for MD088QuotesDashes {
71 fn name(&self) -> &'static str {
72 "MD088"
73 }
74
75 fn description(&self) -> &'static str {
76 "Quotes and dashes should be replaced with ASCII equivalents"
77 }
78
79 fn category(&self) -> RuleCategory {
80 RuleCategory::Whitespace
81 }
82
83 fn fix_capability(&self) -> FixCapability {
84 FixCapability::FullyFixable
85 }
86
87 fn should_skip(&self, ctx: &LintContext) -> bool {
88 ctx.content.is_empty() || !self.has_target_char(ctx)
89 }
90
91 fn check(&self, ctx: &LintContext) -> LintResult {
92 let mut warnings = Vec::new();
93
94 for line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
95 for (byte_idx, c) in line.content.char_indices() {
96 let Some(replacement) = self.replacement_for(c) else {
97 continue;
98 };
99
100 let absolute_byte = line.line_info.byte_offset + byte_idx;
101 if ctx.is_byte_offset_in_code_span(absolute_byte) {
102 continue;
103 }
104
105 if is_in_math_context(ctx, absolute_byte) {
109 continue;
110 }
111
112 let column = byte_to_char_count(line.content, byte_idx);
113 let fix = if ctx.is_in_link(absolute_byte) || ctx.is_in_bare_url(absolute_byte) {
114 None
115 } else {
116 Some(Fix::new(
117 ctx.line_column_byte_range_with_length(line.line_num, column, 1),
118 replacement.to_string(),
119 ))
120 };
121
122 warnings.push(LintWarning {
123 rule_name: Some(self.name().to_string()),
124 line: line.line_num,
125 column,
126 end_line: line.line_num,
127 end_column: column + 1,
128 severity: Severity::Warning,
129 message: format!(
130 "Unicode character {} ({}) should be replaced with {}",
131 c,
132 unicode::format_codepoint(c),
133 replacement
134 ),
135 fix,
136 });
137 }
138 }
139
140 Ok(warnings)
141 }
142
143 fn fixes_from_diagnostics(&self) -> bool {
144 true
145 }
146
147 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
148 if self.should_skip(ctx) {
149 return Ok(ctx.content.to_string());
150 }
151
152 let warnings = self.check(ctx)?;
153 if warnings.is_empty() {
154 return Ok(ctx.content.to_string());
155 }
156
157 let warnings =
158 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
159 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
160 .map_err(crate::rule::LintError::InvalidInput)
161 }
162
163 fn as_any(&self) -> &dyn std::any::Any {
164 self
165 }
166
167 crate::impl_rule_config_methods!(MD088Config);
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::config::{Config, MarkdownFlavor};
174
175 #[test]
176 fn test_detects_and_fixes_single_and_double_quotes_look_alikes() {
177 let rule = MD088QuotesDashes::default();
178 let ctx = LintContext::new("It\u{2019}s \u{201C}fine\u{201D}.", MarkdownFlavor::Standard, None);
179
180 let warnings = rule.check(&ctx).unwrap();
181 assert_eq!(warnings.len(), 3);
182 assert!(warnings.iter().all(|w| w.fix.is_some()));
183
184 let fixed = rule.fix(&ctx).unwrap();
185 assert_eq!(fixed, "It's \"fine\".");
186 }
187
188 #[test]
189 fn test_detects_and_fixes_dash_look_alikes() {
190 let rule = MD088QuotesDashes::from_config_struct(MD088Config {
191 normalize_dashes: true,
192 ..Default::default()
193 });
194 let ctx = LintContext::new(
195 "Dash variants: \u{2010}\u{2011}\u{2012}\u{2013}\u{2014}\u{2015}",
196 MarkdownFlavor::Standard,
197 None,
198 );
199
200 let warnings = rule.check(&ctx).unwrap();
201 assert_eq!(warnings.len(), 6);
202 assert!(warnings.iter().all(|w| w.fix.is_some()));
203
204 let fixed = rule.fix(&ctx).unwrap();
205 assert_eq!(fixed, "Dash variants: ------");
206 }
207
208 #[test]
209 fn test_detects_and_fixes_prime_marks() {
210 let rule = MD088QuotesDashes::default();
211 let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}", MarkdownFlavor::Standard, None);
212
213 let warnings = rule.check(&ctx).unwrap();
214 assert_eq!(warnings.len(), 2);
215 assert!(warnings.iter().all(|w| w.fix.is_some()));
216
217 let fixed = rule.fix(&ctx).unwrap();
218 assert_eq!(fixed, "Sizes: 6' and 8\"");
219 }
220
221 #[test]
222 fn test_modifier_letters_are_spelling_not_typography() {
223 let rule = MD088QuotesDashes::default();
227 let content = "Qaraden\u{02BC}iz and Hawai\u{02BB}i.";
228 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
229
230 assert!(
231 rule.check(&ctx).unwrap().is_empty(),
232 "a modifier letter is part of a word's spelling"
233 );
234 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not respell the words");
235
236 let ctx = LintContext::new("Qaraden\u{02BC}iz \u{2019}s.", MarkdownFlavor::Standard, None);
238 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
239 assert_eq!(rule.fix(&ctx).unwrap(), "Qaraden\u{02BC}iz 's.");
240 }
241
242 #[test]
243 fn test_skips_math() {
244 let rule = MD088QuotesDashes::default();
247 let content = "Prose \u{2019}quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n";
248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
249
250 let warnings = rule.check(&ctx).unwrap();
251 assert_eq!(
252 warnings.len(),
253 1,
254 "only the prose quote is a finding, got {warnings:#?}"
255 );
256 assert_eq!(warnings[0].line, 1);
257 assert_eq!(
258 rule.fix(&ctx).unwrap(),
259 "Prose 'quote.\n\n$$\nx\u{2032} = y\u{2033}\n$$\n\nInline $a\u{2033}$ too.\n"
260 );
261
262 let ctx = LintContext::new("Sizes: 6\u{2032} and 8\u{2033}.", MarkdownFlavor::Standard, None);
265 assert_eq!(rule.check(&ctx).unwrap().len(), 2);
266 assert_eq!(rule.fix(&ctx).unwrap(), "Sizes: 6' and 8\".");
267 }
268
269 #[test]
270 fn test_skips_inline_code_spans() {
271 let rule = MD088QuotesDashes::default();
272 let content = "Prose \u{2019}quote and `code \u{2019}quote`";
273 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
274
275 let warnings = rule.check(&ctx).unwrap();
276 assert_eq!(warnings.len(), 1);
277 assert_eq!(warnings[0].column, 7);
278 assert_eq!(rule.fix(&ctx).unwrap(), "Prose 'quote and `code \u{2019}quote`");
279 }
280
281 #[test]
282 fn test_skips_fenced_and_indented_code_blocks() {
283 let rule = MD088QuotesDashes::default();
284 let content = r#"
285This is a ‘quote in prose’.
286
287```
288This is a ‘quote in fenced code block’
289```
290
291 This is a ‘quote in indented code block’
292"#;
293 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
294
295 let warnings = rule.check(&ctx).unwrap();
296 assert_eq!(warnings.len(), 2);
297 assert_eq!(
298 rule.fix(&ctx).unwrap(),
299 r#"
300This is a 'quote in prose'.
301
302```
303This is a ‘quote in fenced code block’
304```
305
306 This is a ‘quote in indented code block’
307"#
308 );
309 }
310
311 #[test]
312 fn test_no_findings_for_plain_ascii_quotes() {
313 let rule = MD088QuotesDashes::default();
314 let content = "He said, \"it's fine\".";
315 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
316
317 let warnings = rule.check(&ctx).unwrap();
318 assert!(warnings.is_empty());
319 assert_eq!(rule.fix(&ctx).unwrap(), content);
320 }
321
322 #[test]
323 fn test_allow_list_keeps_configured_codepoints() {
324 let config: Config = toml::from_str(
325 r#"
326 [MD088]
327 allow = ["U+2019", "U+2014"]
328 "#,
329 )
330 .unwrap();
331
332 let rule = MD088QuotesDashes::from_config(&config);
333 let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
334
335 let content = "It\u{2019}s \u{2014} and \u{201C}quoted\u{201D}";
336 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
337 let fixed = rule.fix(&ctx).unwrap();
338
339 assert_eq!(fixed, "It\u{2019}s \u{2014} and \"quoted\"");
340 }
341
342 #[test]
343 fn test_reports_but_does_not_fix_bare_urls() {
344 let config: Config = toml::from_str(
345 r#"
346 [MD088]
347 normalize-quotes = true
348 normalize-dashes = true
349 "#,
350 )
351 .unwrap();
352
353 let rule = MD088QuotesDashes::from_config(&config);
354 let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
355
356 let content = "Visit https://this\u{2010}site.com and \u{201C}enjoy\u{201D}!.";
357 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
358
359 let warnings = rule.check(&ctx).unwrap();
360 assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
361
362 let url_warning = warnings.iter().find(|w| w.column > 10).unwrap();
363 assert!(url_warning.fix.is_none());
364
365 let fixed = rule.fix(&ctx).unwrap();
366 assert_eq!(fixed, "Visit https://this\u{2010}site.com and \"enjoy\"!.");
367 }
368
369 #[test]
370 fn test_reports_but_does_not_fix_findings_in_link_destinations() {
371 let rule = MD088QuotesDashes::from_config_struct(MD088Config {
372 normalize_quotes: true,
373 normalize_dashes: true,
374 ..Default::default()
375 });
376 let rule = rule.as_any().downcast_ref::<MD088QuotesDashes>().unwrap();
377
378 let content = "[link](https://example\u{2010}\u{2018}.com) and prose \u{2010}.";
379 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
380
381 let warnings = rule.check(&ctx).unwrap();
382 assert_eq!(warnings.len(), 3, "Expected 3 warnings, got {warnings:#?}");
383
384 let fixed = rule.fix(&ctx).unwrap();
385 assert_eq!(fixed, "[link](https://example\u{2010}\u{2018}.com) and prose -.");
386 }
387}