mod md089_config;
#[cfg(test)]
mod tests;
use std::collections::HashSet;
use std::sync::LazyLock;
use regex::Regex;
use crate::filtered_lines::FilteredLinesExt;
use crate::lint_context::LintContext;
use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::obsidian_tag::TAG_PATTERN;
use crate::utils::range_utils::byte_to_char_count;
use crate::utils::unicode::is_cjk_letter;
use md089_config::MD089Config;
#[derive(Debug, Clone)]
pub struct MD089CjkSpacing {
symbols_after_cjk: HashSet<char>,
symbols_before_cjk: HashSet<char>,
}
impl Default for MD089CjkSpacing {
fn default() -> Self {
Self::from_config_struct(MD089Config::default())
}
}
impl MD089CjkSpacing {
fn from_config_struct(config: MD089Config) -> Self {
let set = |symbols: String| symbols.chars().filter(|c| !c.is_whitespace()).collect();
Self {
symbols_after_cjk: set(config.symbols_after_cjk),
symbols_before_cjk: set(config.symbols_before_cjk),
}
}
fn latin_edges(&self, units: &[Unit]) -> (Vec<bool>, Vec<bool>) {
let n = units.len();
let mut right = vec![false; n];
for i in 0..n {
right[i] = match units[i].kind {
Kind::Latin | Kind::Opaque => true,
Kind::Symbol(c) => i > 0 && right[i - 1] && self.symbols_before_cjk.contains(&c),
Kind::Delimiter { .. } => i > 0 && right[i - 1],
Kind::Cjk | Kind::Other | Kind::Wall => false,
};
}
let mut left = vec![false; n];
for i in (0..n).rev() {
left[i] = match units[i].kind {
Kind::Latin | Kind::Opaque => true,
Kind::Symbol(c) => i + 1 < n && left[i + 1] && self.symbols_after_cjk.contains(&c),
Kind::Delimiter { .. } => i + 1 < n && left[i + 1],
Kind::Cjk | Kind::Other | Kind::Wall => false,
};
}
(right, left)
}
fn missing_spaces(&self, units: &[Unit]) -> Vec<Gap> {
let (latin_right, latin_left) = self.latin_edges(units);
let is_delimiter = |j: &usize| matches!(units[*j].kind, Kind::Delimiter { .. });
let mut gaps = Vec::new();
for (k, unit) in units.iter().enumerate() {
if unit.kind != Kind::Cjk {
continue;
}
if let Some(j) = (k + 1..units.len()).find(|j| !is_delimiter(j))
&& latin_left[j]
{
gaps.push(Gap {
insert_at: first_opener(&units[k + 1..j]).unwrap_or(units[j].start),
left: (unit.start, unit.end),
right: attached_run(units, j, &latin_right, &latin_left, true),
});
}
if let Some(j) = (0..k).rev().find(|j| !is_delimiter(j))
&& latin_right[j]
{
gaps.push(Gap {
insert_at: first_opener(&units[j + 1..k]).unwrap_or(unit.start),
left: attached_run(units, j, &latin_right, &latin_left, false),
right: (unit.start, unit.end),
});
}
}
gaps.sort_by_key(|gap| gap.insert_at);
gaps
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Cjk,
Latin,
Symbol(char),
Other,
Delimiter { opener: bool },
Opaque,
Wall,
}
#[derive(Debug, Clone, Copy)]
struct Unit {
kind: Kind,
start: usize,
end: usize,
}
struct Gap {
insert_at: usize,
left: (usize, usize),
right: (usize, usize),
}
fn is_attached_mark(c: char) -> bool {
if c.is_ascii() {
return false;
}
static ATTACHED_MARK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Mn}\p{Me}]$").expect("attached mark class is a valid regex"));
let mut buf = [0u8; 4];
ATTACHED_MARK.is_match(c.encode_utf8(&mut buf))
}
fn classify(c: char) -> Kind {
if is_cjk_letter(c) {
Kind::Cjk
} else if c.is_ascii_alphanumeric() {
Kind::Latin
} else if c.is_whitespace() || c.is_alphanumeric() {
Kind::Other
} else {
Kind::Symbol(c)
}
}
fn first_opener(between: &[Unit]) -> Option<usize> {
between
.iter()
.find(|unit| unit.kind == Kind::Delimiter { opener: true })
.map(|unit| unit.start)
}
fn attached_run(units: &[Unit], j: usize, latin_right: &[bool], latin_left: &[bool], forward: bool) -> (usize, usize) {
let (mut start, mut end) = (units[j].start, units[j].end);
if forward {
let mut m = j;
while m + 1 < units.len() && (latin_right[m + 1] || latin_left[m + 1]) {
m += 1;
end = units[m].end;
}
} else {
let mut m = j;
while m > 0 && (latin_right[m - 1] || latin_left[m - 1]) {
m -= 1;
start = units[m].start;
}
}
(start, end)
}
fn link_wraps_only_an_image(content: &str, link: (usize, usize), images: &[(usize, usize)]) -> bool {
images.iter().any(|&(start, end)| {
link.0 < start
&& end < link.1
&& content
.get(link.0 + 1..start)
.is_some_and(|before| before.trim().is_empty())
&& content
.get(end..link.1)
.is_some_and(|after| after.trim_start().starts_with(']'))
})
}
fn footnote_marker_end(content: &str, start: usize) -> Option<usize> {
let rest = content.get(start..)?;
if !rest.starts_with("[^") {
return None;
}
rest.find(']').map(|offset| start + offset + 1)
}
fn footnote_label_range(line: &str) -> Option<(usize, usize)> {
let start = line.find("[^")?;
if !line[..start].chars().all(|c| c.is_whitespace() || c == '>') {
return None;
}
let close = start + line[start..].find(']')?;
line[close + 1..].starts_with(':').then_some((start, close + 2))
}
fn container_marker_content(rest: &str) -> Option<&str> {
let after_marker = if let Some(tail) = rest.strip_prefix(['-', '+', '*']) {
tail
} else if let Some(tail) = rest.strip_prefix("[^") {
let close = tail.find(']')?;
tail[close + 1..].strip_prefix(':')?
} else {
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if !(1..=9).contains(&digits) {
return None;
}
rest[digits..].strip_prefix([')', '.'])?
};
let content = after_marker.trim_start();
(content.len() < after_marker.len()).then_some(content)
}
fn completes_list_marker(prefix: &str) -> bool {
let mut rest = prefix.trim_start();
loop {
if let Some(tail) = rest.strip_prefix('>') {
rest = tail.trim_start();
} else if let Some(content) = container_marker_content(rest) {
rest = content;
} else {
break;
}
}
if matches!(rest, "-" | "+" | "*") {
return true;
}
let Some(digits) = rest.strip_suffix([')', '.']) else {
return false;
};
(1..=9).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
}
fn hashtag_ranges(line: &str, line_start: usize) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c != '#' {
continue;
}
if line[..i].chars().next_back().is_some_and(char::is_alphanumeric) {
continue;
}
if !TAG_PATTERN.is_match(&line[i..]) {
continue;
}
let end = line[i..]
.find(char::is_whitespace)
.map_or(line.len(), |offset| i + offset);
ranges.push((line_start + i, line_start + end));
while chars.peek().is_some_and(|&(j, _)| j < end) {
chars.next();
}
}
ranges
}
fn collect_specials(ctx: &LintContext) -> Vec<Unit> {
let mut specials = Vec::new();
let mut push = |start: usize, end: usize, kind: Kind| {
if start < end {
specials.push(Unit { kind, start, end });
}
};
for span in ctx.code_spans().iter() {
push(span.byte_offset, span.byte_end, Kind::Opaque);
}
for span in ctx.math_spans().iter() {
push(span.byte_offset, span.byte_end, Kind::Opaque);
}
let images: Vec<(usize, usize)> = ctx
.images()
.iter()
.map(|image| (image.byte_offset, image.byte_end))
.collect();
for link in ctx.links() {
let kind = if link_wraps_only_an_image(ctx.content, (link.byte_offset, link.byte_end), &images) {
Kind::Wall
} else {
Kind::Opaque
};
push(link.byte_offset, link.byte_end, kind);
}
for url in ctx.bare_urls().iter() {
push(url.byte_offset, url.byte_end, Kind::Opaque);
}
for &(start, end) in &images {
push(start, end, Kind::Wall);
}
for tag in ctx.html_tags().iter() {
push(tag.byte_offset, tag.byte_end, Kind::Wall);
}
for comment in ctx.html_comment_ranges() {
push(comment.start, comment.end, Kind::Wall);
}
for def in ctx.reference_definitions() {
push(def.byte_offset, def.byte_end, Kind::Wall);
}
for footnote in ctx.footnote_references() {
if let Some(end) = footnote_marker_end(ctx.content, footnote.byte_offset) {
push(footnote.byte_offset, end, Kind::Wall);
}
}
for line in ctx.lines.iter().filter(|line| line.in_footnote_definition) {
if let Some((start, end)) = footnote_label_range(line.content(ctx.content)) {
push(line.byte_offset + start, line.byte_offset + end, Kind::Wall);
}
}
for span in ctx.emphasis_spans().iter() {
let width = if span.is_strong { 2 } else { 1 };
push(
span.byte_offset,
span.byte_offset + width,
Kind::Delimiter { opener: true },
);
push(
span.byte_end.saturating_sub(width),
span.byte_end,
Kind::Delimiter { opener: false },
);
}
specials.sort_by_key(|unit| (unit.start, unit.end));
let mut tags = Vec::new();
let mut cursor = 0;
let mut offset = 0;
for line in ctx.content.split_inclusive('\n') {
for (start, end) in hashtag_ranges(line.trim_end_matches(['\n', '\r']), offset) {
while cursor < specials.len() && specials[cursor].end <= start {
cursor += 1;
}
if let Some((start, end)) = tag_outside_specials(&specials[cursor..], start, end) {
tags.push(Unit {
kind: Kind::Wall,
start,
end,
});
}
}
offset += line.len();
}
specials.extend(tags);
specials.sort_by_key(|unit| (unit.start, unit.end));
specials
}
fn tag_outside_specials(specials: &[Unit], start: usize, end: usize) -> Option<(usize, usize)> {
for special in specials {
if special.start > start {
return Some((start, end.min(special.start)));
}
if special.end > start {
return None;
}
}
Some((start, end))
}
fn line_units(content: &str, line_start: usize, specials: &[Unit]) -> Vec<Unit> {
let line_end = line_start + content.len();
let mut units: Vec<Unit> = Vec::new();
let mut next_special = 0;
let mut pos = line_start;
while pos < line_end {
while next_special < specials.len() && specials[next_special].end <= pos {
next_special += 1;
}
if let Some(special) = specials.get(next_special).filter(|special| special.start <= pos) {
let end = special.end.min(line_end);
units.push(Unit {
kind: special.kind,
start: pos,
end,
});
pos = end;
next_special += 1;
continue;
}
let c = content[pos - line_start..]
.chars()
.next()
.expect("pos is on a char boundary inside the line");
let end = pos + c.len_utf8();
if is_attached_mark(c)
&& let Some(last) = units.last_mut()
&& last.end == pos
{
last.end = end;
pos = end;
continue;
}
let kind = classify(c);
match units.last_mut() {
Some(last)
if last.end == pos && last.kind == kind && matches!(kind, Kind::Cjk | Kind::Latin | Kind::Other) =>
{
last.end = end;
}
_ => units.push(Unit { kind, start: pos, end }),
}
pos = end;
}
units
}
fn excerpt(content: &str, (start, end): (usize, usize)) -> String {
const MAX_CHARS: usize = 16;
let text = &content[start..end];
match text.char_indices().nth(MAX_CHARS) {
Some((cut, _)) => format!("{}...", &text[..cut]),
None => text.to_string(),
}
}
impl Rule for MD089CjkSpacing {
fn name(&self) -> &'static str {
"MD089"
}
fn description(&self) -> &'static str {
"CJK letters and Latin letters or digits should be separated by a space"
}
fn check(&self, ctx: &LintContext) -> LintResult {
if self.should_skip(ctx) {
return Ok(Vec::new());
}
let specials = collect_specials(ctx);
let mut cursor = 0;
let mut warnings = Vec::new();
for line in ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_html_blocks()
.skip_html_comments()
.skip_math_blocks()
.skip_esm_blocks()
.skip_jsx_expressions()
.skip_mdx_comments()
.skip_obsidian_comments()
{
if line.line_info.is_kramdown_block_ial || line.line_info.in_kramdown_extension_block {
continue;
}
let line_start = line.line_info.byte_offset;
while cursor < specials.len() && specials[cursor].end <= line_start {
cursor += 1;
}
let units = line_units(line.content, line_start, &specials[cursor..]);
for gap in self.missing_spaces(&units) {
if completes_list_marker(&line.content[..gap.insert_at - line_start]) {
continue;
}
if ctx.is_in_inline_code_attr(gap.insert_at) || ctx.is_in_bracketed_span(gap.insert_at) {
continue;
}
let column = byte_to_char_count(line.content, gap.insert_at - line_start);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: line.line_num,
column,
end_line: line.line_num,
end_column: column + 1,
severity: Severity::Warning,
message: format!(
"Missing space between \"{}\" and \"{}\"",
excerpt(ctx.content, gap.left),
excerpt(ctx.content, gap.right)
),
fix: Some(Fix::new(gap.insert_at..gap.insert_at, " ".to_string())),
});
}
}
Ok(warnings)
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
}
fn should_skip(&self, ctx: &LintContext) -> bool {
!ctx.content.chars().any(is_cjk_letter)
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn fix_capability(&self) -> FixCapability {
FixCapability::FullyFixable
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD089Config);
}