1mod md089_config;
23#[cfg(test)]
24mod tests;
25
26use std::collections::HashSet;
27use std::sync::LazyLock;
28
29use regex::Regex;
30
31use crate::filtered_lines::FilteredLinesExt;
32use crate::lint_context::LintContext;
33use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
34use crate::utils::obsidian_tag::TAG_PATTERN;
35use crate::utils::range_utils::byte_to_char_count;
36use crate::utils::unicode::is_cjk_letter;
37use md089_config::MD089Config;
38
39#[derive(Debug, Clone)]
41pub struct MD089CjkSpacing {
42 symbols_after_cjk: HashSet<char>,
44 symbols_before_cjk: HashSet<char>,
46}
47
48impl Default for MD089CjkSpacing {
49 fn default() -> Self {
50 Self::from_config_struct(MD089Config::default())
51 }
52}
53
54impl MD089CjkSpacing {
55 fn from_config_struct(config: MD089Config) -> Self {
56 let set = |symbols: String| symbols.chars().filter(|c| !c.is_whitespace()).collect();
57 Self {
58 symbols_after_cjk: set(config.symbols_after_cjk),
59 symbols_before_cjk: set(config.symbols_before_cjk),
60 }
61 }
62
63 fn latin_edges(&self, units: &[Unit]) -> (Vec<bool>, Vec<bool>) {
70 let n = units.len();
71 let mut right = vec![false; n];
72 for i in 0..n {
73 right[i] = match units[i].kind {
74 Kind::Latin | Kind::Opaque => true,
75 Kind::Symbol(c) => i > 0 && right[i - 1] && self.symbols_before_cjk.contains(&c),
76 Kind::Delimiter { .. } => i > 0 && right[i - 1],
77 Kind::Cjk | Kind::Other | Kind::Wall => false,
78 };
79 }
80 let mut left = vec![false; n];
81 for i in (0..n).rev() {
82 left[i] = match units[i].kind {
83 Kind::Latin | Kind::Opaque => true,
84 Kind::Symbol(c) => i + 1 < n && left[i + 1] && self.symbols_after_cjk.contains(&c),
85 Kind::Delimiter { .. } => i + 1 < n && left[i + 1],
86 Kind::Cjk | Kind::Other | Kind::Wall => false,
87 };
88 }
89 (right, left)
90 }
91
92 fn missing_spaces(&self, units: &[Unit]) -> Vec<Gap> {
94 let (latin_right, latin_left) = self.latin_edges(units);
95 let is_delimiter = |j: &usize| matches!(units[*j].kind, Kind::Delimiter { .. });
96 let mut gaps = Vec::new();
97 for (k, unit) in units.iter().enumerate() {
98 if unit.kind != Kind::Cjk {
99 continue;
100 }
101 if let Some(j) = (k + 1..units.len()).find(|j| !is_delimiter(j))
103 && latin_left[j]
104 {
105 gaps.push(Gap {
106 insert_at: first_opener(&units[k + 1..j]).unwrap_or(units[j].start),
107 left: (unit.start, unit.end),
108 right: attached_run(units, j, &latin_right, &latin_left, true),
109 });
110 }
111 if let Some(j) = (0..k).rev().find(|j| !is_delimiter(j))
113 && latin_right[j]
114 {
115 gaps.push(Gap {
116 insert_at: first_opener(&units[j + 1..k]).unwrap_or(unit.start),
117 left: attached_run(units, j, &latin_right, &latin_left, false),
118 right: (unit.start, unit.end),
119 });
120 }
121 }
122 gaps.sort_by_key(|gap| gap.insert_at);
123 gaps
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129enum Kind {
130 Cjk,
132 Latin,
134 Symbol(char),
137 Other,
141 Delimiter { opener: bool },
143 Opaque,
146 Wall,
149}
150
151#[derive(Debug, Clone, Copy)]
153struct Unit {
154 kind: Kind,
155 start: usize,
156 end: usize,
157}
158
159struct Gap {
161 insert_at: usize,
162 left: (usize, usize),
163 right: (usize, usize),
164}
165
166fn is_attached_mark(c: char) -> bool {
172 if c.is_ascii() {
173 return false;
174 }
175 static ATTACHED_MARK: LazyLock<Regex> =
176 LazyLock::new(|| Regex::new(r"^[\p{Mn}\p{Me}]$").expect("attached mark class is a valid regex"));
177 let mut buf = [0u8; 4];
178 ATTACHED_MARK.is_match(c.encode_utf8(&mut buf))
179}
180
181fn classify(c: char) -> Kind {
182 if is_cjk_letter(c) {
183 Kind::Cjk
184 } else if c.is_ascii_alphanumeric() {
185 Kind::Latin
186 } else if c.is_whitespace() || c.is_alphanumeric() {
187 Kind::Other
188 } else {
189 Kind::Symbol(c)
190 }
191}
192
193fn first_opener(between: &[Unit]) -> Option<usize> {
196 between
197 .iter()
198 .find(|unit| unit.kind == Kind::Delimiter { opener: true })
199 .map(|unit| unit.start)
200}
201
202fn attached_run(units: &[Unit], j: usize, latin_right: &[bool], latin_left: &[bool], forward: bool) -> (usize, usize) {
209 let (mut start, mut end) = (units[j].start, units[j].end);
210 if forward {
211 let mut m = j;
212 while m + 1 < units.len() && (latin_right[m + 1] || latin_left[m + 1]) {
213 m += 1;
214 end = units[m].end;
215 }
216 } else {
217 let mut m = j;
218 while m > 0 && (latin_right[m - 1] || latin_left[m - 1]) {
219 m -= 1;
220 start = units[m].start;
221 }
222 }
223 (start, end)
224}
225
226fn link_wraps_only_an_image(content: &str, link: (usize, usize), images: &[(usize, usize)]) -> bool {
231 images.iter().any(|&(start, end)| {
232 link.0 < start
233 && end < link.1
234 && content
235 .get(link.0 + 1..start)
236 .is_some_and(|before| before.trim().is_empty())
237 && content
238 .get(end..link.1)
239 .is_some_and(|after| after.trim_start().starts_with(']'))
240 })
241}
242
243fn footnote_marker_end(content: &str, start: usize) -> Option<usize> {
247 let rest = content.get(start..)?;
248 if !rest.starts_with("[^") {
249 return None;
250 }
251 rest.find(']').map(|offset| start + offset + 1)
252}
253
254fn footnote_label_range(line: &str) -> Option<(usize, usize)> {
258 let start = line.find("[^")?;
259 if !line[..start].chars().all(|c| c.is_whitespace() || c == '>') {
260 return None;
261 }
262 let close = start + line[start..].find(']')?;
263 line[close + 1..].starts_with(':').then_some((start, close + 2))
264}
265
266fn container_marker_content(rest: &str) -> Option<&str> {
272 let after_marker = if let Some(tail) = rest.strip_prefix(['-', '+', '*']) {
273 tail
274 } else if let Some(tail) = rest.strip_prefix("[^") {
275 let close = tail.find(']')?;
276 tail[close + 1..].strip_prefix(':')?
277 } else {
278 let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
279 if !(1..=9).contains(&digits) {
280 return None;
281 }
282 rest[digits..].strip_prefix([')', '.'])?
283 };
284 let content = after_marker.trim_start();
285 (content.len() < after_marker.len()).then_some(content)
286}
287
288fn completes_list_marker(prefix: &str) -> bool {
296 let mut rest = prefix.trim_start();
297 loop {
298 if let Some(tail) = rest.strip_prefix('>') {
299 rest = tail.trim_start();
300 } else if let Some(content) = container_marker_content(rest) {
301 rest = content;
302 } else {
303 break;
304 }
305 }
306 if matches!(rest, "-" | "+" | "*") {
307 return true;
308 }
309 let Some(digits) = rest.strip_suffix([')', '.']) else {
310 return false;
311 };
312 (1..=9).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
313}
314
315fn hashtag_ranges(line: &str, line_start: usize) -> Vec<(usize, usize)> {
324 let mut ranges = Vec::new();
325 let mut chars = line.char_indices().peekable();
326 while let Some((i, c)) = chars.next() {
327 if c != '#' {
328 continue;
329 }
330 if line[..i].chars().next_back().is_some_and(char::is_alphanumeric) {
331 continue;
332 }
333 if !TAG_PATTERN.is_match(&line[i..]) {
334 continue;
335 }
336 let end = line[i..]
337 .find(char::is_whitespace)
338 .map_or(line.len(), |offset| i + offset);
339 ranges.push((line_start + i, line_start + end));
340 while chars.peek().is_some_and(|&(j, _)| j < end) {
341 chars.next();
342 }
343 }
344 ranges
345}
346
347fn collect_specials(ctx: &LintContext) -> Vec<Unit> {
351 let mut specials = Vec::new();
352 let mut push = |start: usize, end: usize, kind: Kind| {
353 if start < end {
354 specials.push(Unit { kind, start, end });
355 }
356 };
357 for span in ctx.code_spans().iter() {
358 push(span.byte_offset, span.byte_end, Kind::Opaque);
359 }
360 for span in ctx.math_spans().iter() {
361 push(span.byte_offset, span.byte_end, Kind::Opaque);
362 }
363 let images: Vec<(usize, usize)> = ctx
364 .images()
365 .iter()
366 .map(|image| (image.byte_offset, image.byte_end))
367 .collect();
368 for link in ctx.links() {
369 let kind = if link_wraps_only_an_image(ctx.content, (link.byte_offset, link.byte_end), &images) {
373 Kind::Wall
374 } else {
375 Kind::Opaque
376 };
377 push(link.byte_offset, link.byte_end, kind);
378 }
379 for url in ctx.bare_urls().iter() {
380 push(url.byte_offset, url.byte_end, Kind::Opaque);
381 }
382 for &(start, end) in &images {
383 push(start, end, Kind::Wall);
384 }
385 for tag in ctx.html_tags().iter() {
386 push(tag.byte_offset, tag.byte_end, Kind::Wall);
387 }
388 for comment in ctx.html_comment_ranges() {
389 push(comment.start, comment.end, Kind::Wall);
390 }
391 for def in ctx.reference_definitions() {
394 push(def.byte_offset, def.byte_end, Kind::Wall);
395 }
396 for footnote in ctx.footnote_references() {
400 if let Some(end) = footnote_marker_end(ctx.content, footnote.byte_offset) {
401 push(footnote.byte_offset, end, Kind::Wall);
402 }
403 }
404 for line in ctx.lines.iter().filter(|line| line.in_footnote_definition) {
407 if let Some((start, end)) = footnote_label_range(line.content(ctx.content)) {
408 push(line.byte_offset + start, line.byte_offset + end, Kind::Wall);
409 }
410 }
411 for span in ctx.emphasis_spans().iter() {
412 let width = if span.is_strong { 2 } else { 1 };
413 push(
414 span.byte_offset,
415 span.byte_offset + width,
416 Kind::Delimiter { opener: true },
417 );
418 push(
419 span.byte_end.saturating_sub(width),
420 span.byte_end,
421 Kind::Delimiter { opener: false },
422 );
423 }
424 specials.sort_by_key(|unit| (unit.start, unit.end));
427 let mut tags = Vec::new();
428 let mut cursor = 0;
429 let mut offset = 0;
430 for line in ctx.content.split_inclusive('\n') {
431 for (start, end) in hashtag_ranges(line.trim_end_matches(['\n', '\r']), offset) {
432 while cursor < specials.len() && specials[cursor].end <= start {
433 cursor += 1;
434 }
435 if let Some((start, end)) = tag_outside_specials(&specials[cursor..], start, end) {
436 tags.push(Unit {
437 kind: Kind::Wall,
438 start,
439 end,
440 });
441 }
442 }
443 offset += line.len();
444 }
445 specials.extend(tags);
446 specials.sort_by_key(|unit| (unit.start, unit.end));
447 specials
448}
449
450fn tag_outside_specials(specials: &[Unit], start: usize, end: usize) -> Option<(usize, usize)> {
456 for special in specials {
457 if special.start > start {
458 return Some((start, end.min(special.start)));
459 }
460 if special.end > start {
461 return None;
462 }
463 }
464 Some((start, end))
465}
466
467fn line_units(content: &str, line_start: usize, specials: &[Unit]) -> Vec<Unit> {
472 let line_end = line_start + content.len();
473 let mut units: Vec<Unit> = Vec::new();
474 let mut next_special = 0;
475 let mut pos = line_start;
476 while pos < line_end {
477 while next_special < specials.len() && specials[next_special].end <= pos {
478 next_special += 1;
479 }
480 if let Some(special) = specials.get(next_special).filter(|special| special.start <= pos) {
481 let end = special.end.min(line_end);
482 units.push(Unit {
483 kind: special.kind,
484 start: pos,
485 end,
486 });
487 pos = end;
488 next_special += 1;
489 continue;
490 }
491 let c = content[pos - line_start..]
492 .chars()
493 .next()
494 .expect("pos is on a char boundary inside the line");
495 let end = pos + c.len_utf8();
496 if is_attached_mark(c)
500 && let Some(last) = units.last_mut()
501 && last.end == pos
502 {
503 last.end = end;
504 pos = end;
505 continue;
506 }
507 let kind = classify(c);
508 match units.last_mut() {
509 Some(last)
510 if last.end == pos && last.kind == kind && matches!(kind, Kind::Cjk | Kind::Latin | Kind::Other) =>
511 {
512 last.end = end;
513 }
514 _ => units.push(Unit { kind, start: pos, end }),
515 }
516 pos = end;
517 }
518 units
519}
520
521fn excerpt(content: &str, (start, end): (usize, usize)) -> String {
523 const MAX_CHARS: usize = 16;
524 let text = &content[start..end];
525 match text.char_indices().nth(MAX_CHARS) {
526 Some((cut, _)) => format!("{}...", &text[..cut]),
527 None => text.to_string(),
528 }
529}
530
531impl Rule for MD089CjkSpacing {
532 fn name(&self) -> &'static str {
533 "MD089"
534 }
535
536 fn description(&self) -> &'static str {
537 "CJK letters and Latin letters or digits should be separated by a space"
538 }
539
540 fn check(&self, ctx: &LintContext) -> LintResult {
541 if self.should_skip(ctx) {
542 return Ok(Vec::new());
543 }
544 let specials = collect_specials(ctx);
545 let mut cursor = 0;
546 let mut warnings = Vec::new();
547 for line in ctx
548 .filtered_lines()
549 .skip_front_matter()
550 .skip_code_blocks()
551 .skip_html_blocks()
552 .skip_html_comments()
553 .skip_math_blocks()
554 .skip_esm_blocks()
555 .skip_jsx_expressions()
556 .skip_mdx_comments()
557 .skip_obsidian_comments()
558 {
559 if line.line_info.is_kramdown_block_ial || line.line_info.in_kramdown_extension_block {
563 continue;
564 }
565 let line_start = line.line_info.byte_offset;
566 while cursor < specials.len() && specials[cursor].end <= line_start {
567 cursor += 1;
568 }
569 let units = line_units(line.content, line_start, &specials[cursor..]);
570 for gap in self.missing_spaces(&units) {
571 if completes_list_marker(&line.content[..gap.insert_at - line_start]) {
577 continue;
578 }
579 if ctx.is_in_inline_code_attr(gap.insert_at) || ctx.is_in_bracketed_span(gap.insert_at) {
584 continue;
585 }
586 let column = byte_to_char_count(line.content, gap.insert_at - line_start);
587 warnings.push(LintWarning {
588 rule_name: Some(self.name().to_string()),
589 line: line.line_num,
590 column,
591 end_line: line.line_num,
592 end_column: column + 1,
593 severity: Severity::Warning,
594 message: format!(
595 "Missing space between \"{}\" and \"{}\"",
596 excerpt(ctx.content, gap.left),
597 excerpt(ctx.content, gap.right)
598 ),
599 fix: Some(Fix::new(gap.insert_at..gap.insert_at, " ".to_string())),
600 });
601 }
602 }
603 Ok(warnings)
604 }
605
606 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
607 if self.should_skip(ctx) {
608 return Ok(ctx.content.to_string());
609 }
610 let warnings = self.check(ctx)?;
611 if warnings.is_empty() {
612 return Ok(ctx.content.to_string());
613 }
614 let warnings =
615 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
616 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
617 }
618
619 fn should_skip(&self, ctx: &LintContext) -> bool {
620 !ctx.content.chars().any(is_cjk_letter)
621 }
622
623 fn category(&self) -> RuleCategory {
624 RuleCategory::Whitespace
625 }
626
627 fn fix_capability(&self) -> FixCapability {
628 FixCapability::FullyFixable
629 }
630
631 fn as_any(&self) -> &dyn std::any::Any {
632 self
633 }
634
635 crate::impl_rule_config_methods!(MD089Config);
636}