use std::str::CharIndices;
pub(crate) use crate::formatting::TextEdit;
use unicode_general_category::{GeneralCategory, get_general_category};
use crate::config::{SpacingConfig, SpacingRule};
#[derive(Debug, Clone, Copy, PartialEq)]
enum CharType {
Cjk,
Latin,
Digit,
Space,
Other,
}
pub(crate) fn spacing_edits(config: &SpacingConfig, text: &str) -> Vec<TextEdit> {
let characters = text_characters(text);
let mut edits = Vec::new();
for pair in characters.windows(2) {
let [previous, current] = pair else {
unreachable!();
};
match (previous.kind, current.kind) {
pair if is_spacing_pair(pair.0, pair.1)
&& spacing_rule(config, pair.0, pair.1) == SpacingRule::Require =>
{
edits.push(TextEdit {
range: current.start..current.start,
replacement: " ".to_string(),
});
}
_ => {}
}
}
let mut index = 0;
while index < characters.len() {
if characters[index].character != ' ' {
index += 1;
continue;
}
let start = index;
while index < characters.len() && characters[index].character == ' ' {
index += 1;
}
let end = index;
if start == 0 || end == characters.len() {
continue;
}
let left = characters[start - 1];
let right = characters[end];
if is_spacing_pair(left.kind, right.kind)
&& spacing_rule(config, left.kind, right.kind) == SpacingRule::Prohibit
{
edits.push(TextEdit {
range: left.end..right.start,
replacement: String::new(),
});
}
}
edits
}
#[derive(Debug, Clone, Copy)]
struct TextCharacter {
start: usize,
end: usize,
character: char,
kind: CharType,
}
fn text_characters(text: &str) -> Vec<TextCharacter> {
let mut characters = Vec::new();
let mut indices: CharIndices<'_> = text.char_indices();
while let Some((start, character)) = indices.next() {
let end = indices
.clone()
.next()
.map_or(text.len(), |(index, _)| index);
characters.push(TextCharacter {
start,
end,
character,
kind: char_type(character),
});
}
characters
}
fn is_spacing_pair(left: CharType, right: CharType) -> bool {
matches!(
(left, right),
(CharType::Cjk, CharType::Digit)
| (CharType::Digit, CharType::Cjk)
| (CharType::Cjk, CharType::Latin)
| (CharType::Latin, CharType::Cjk)
)
}
fn spacing_rule(config: &SpacingConfig, left: CharType, right: CharType) -> SpacingRule {
match (left, right) {
(CharType::Cjk, CharType::Digit) | (CharType::Digit, CharType::Cjk) => config.digits,
(CharType::Cjk, CharType::Latin) | (CharType::Latin, CharType::Cjk) => config.alphabets,
_ => SpacingRule::Ignore,
}
}
fn char_type(c: char) -> CharType {
match c {
' ' | '\r' | '\n' => return CharType::Space,
_ if c.is_whitespace() => return CharType::Other,
_ => {}
}
match c {
'\u{4E00}'..='\u{9FFF}'
| '\u{3400}'..='\u{4DBF}'
| '\u{20000}'..='\u{2A6DF}'
| '\u{2A700}'..='\u{2B73F}'
| '\u{2B740}'..='\u{2B81F}'
| '\u{2B820}'..='\u{2CEAF}'
| '\u{2CEB0}'..='\u{2EBEF}'
| '\u{30000}'..='\u{3134F}'
| '\u{31350}'..='\u{323AF}'
| '\u{2EBF0}'..='\u{2EE5D}'
| '\u{2E80}'..='\u{2EFF}'
| '\u{3000}'..='\u{303F}'
| '\u{3040}'..='\u{309F}'
| '\u{30A0}'..='\u{30FF}'
| '\u{3100}'..='\u{312F}'
| '\u{AC00}'..='\u{D7AF}' => match get_general_category(c) {
GeneralCategory::ClosePunctuation
| GeneralCategory::ConnectorPunctuation
| GeneralCategory::DashPunctuation
| GeneralCategory::FinalPunctuation
| GeneralCategory::InitialPunctuation
| GeneralCategory::OpenPunctuation
| GeneralCategory::OtherPunctuation => CharType::Other,
_ => CharType::Cjk,
},
'A'..='Z'
| 'a'..='z'
| '\u{00C0}'..='\u{00FF}'
| '\u{0100}'..='\u{017F}'
| '\u{0180}'..='\u{024F}'
| '\u{1E00}'..='\u{1EFF}'
| '\u{0250}'..='\u{02AF}'
| '\u{02B0}'..='\u{02FF}'
| '\u{0300}'..='\u{036F}'
| '\u{1AB0}'..='\u{1AFF}'
| '\u{1DC0}'..='\u{1DFF}'
| '\u{2C60}'..='\u{2C7F}'
| '\u{A720}'..='\u{A7FF}'
| '\u{AB30}'..='\u{AB6F}'
| '\u{10780}'..='\u{107BF}'
| '\u{1DF00}'..='\u{1DFFF}' => CharType::Latin,
'0'..='9' => CharType::Digit,
_ => CharType::Other,
}
}
#[cfg(test)]
mod tests {
use std::ops::Range;
use super::*;
fn make_config(alphabets: SpacingRule, digits: SpacingRule) -> SpacingConfig {
SpacingConfig { alphabets, digits }
}
fn edits(config: &SpacingConfig, text: &str) -> Vec<(Range<usize>, String)> {
spacing_edits(config, text)
.into_iter()
.map(|edit| (edit.range, edit.replacement))
.collect()
}
#[test]
fn require_inserts_at_utf8_byte_boundaries_in_both_directions() {
let config = make_config(SpacingRule::Require, SpacingRule::Require);
assert_eq!(
edits(&config, "漢A1漢"),
vec![(3..3, " ".to_string()), (5..5, " ".to_string())]
);
}
#[test]
fn require_does_not_duplicate_existing_ascii_space() {
let config = make_config(SpacingRule::Require, SpacingRule::Require);
assert!(spacing_edits(&config, "漢 A").is_empty());
}
#[test]
fn prohibit_deletes_a_complete_ascii_space_run() {
let config = make_config(SpacingRule::Prohibit, SpacingRule::Prohibit);
assert_eq!(edits(&config, "漢 A 1"), vec![(3..6, String::new())]);
}
#[test]
fn prohibit_does_not_cross_non_ascii_space_or_line_endings() {
let config = make_config(SpacingRule::Prohibit, SpacingRule::Prohibit);
for text in ["漢\tA", "漢\nA", "漢\r\nA", "漢\u{00a0}A", "漢\u{3000}A"] {
assert!(spacing_edits(&config, text).is_empty(), "changed {text:?}");
}
}
#[test]
fn alphabet_and_digit_rules_are_independent() {
let config = make_config(SpacingRule::Require, SpacingRule::Ignore);
assert_eq!(
edits(&config, "漢A漢1"),
vec![(3..3, " ".to_string()), (4..4, " ".to_string())]
);
let digit_config = make_config(SpacingRule::Ignore, SpacingRule::Prohibit);
assert_eq!(
edits(&digit_config, "漢 A 漢 1"),
vec![(9..11, String::new())]
);
}
#[test]
fn ignore_leaves_both_kinds_unchanged() {
let config = make_config(SpacingRule::Ignore, SpacingRule::Ignore);
assert!(spacing_edits(&config, "漢A 漢 1").is_empty());
}
#[test]
fn character_types_keep_punctuation_out_of_spacing_pairs() {
assert_eq!(char_type('中'), CharType::Cjk);
assert_eq!(char_type('漢'), CharType::Cjk);
assert_eq!(char_type('a'), CharType::Latin);
assert_eq!(char_type('1'), CharType::Digit);
assert_eq!(char_type(' '), CharType::Space);
assert_eq!(char_type('。'), CharType::Other);
assert_eq!(char_type('\u{3000}'), CharType::Other);
}
}