const SIMPLE: [(u8, &str); 17] = [
(b',', ","),
(b'.', "。"),
(b'?', "?"),
(b'!', "!"),
(b';', ";"),
(b':', ":"),
(b'(', "("),
(b')', ")"),
(b'[', "【"),
(b']', "】"),
(b'\\', "、"),
(b'`', "·"),
(b'$', "¥"),
(b'^', "……"),
(b'_', "——"),
(b'{', "「"),
(b'}', "」"),
];
const DOUBLE_QUOTE: (&str, &str) = ("“", "”");
const SINGLE_QUOTE: (&str, &str) = ("‘", "’");
const CORNER_DOUBLE_QUOTE: (&str, &str) = ("『", "』");
const CORNER_SINGLE_QUOTE: (&str, &str) = ("「", "」");
const HOLLOW_BRACKETS: [(u8, &str); 2] = [(b'[', "〖"), (b']', "〗")];
const ENUMERATION_COMMA: [(u8, &str); 2] = [(b'\\', "、"), (b'/', "、")];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuoteMarks {
#[default]
Curly,
Corner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BracketMarks {
#[default]
Solid,
Hollow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PunctuationMarks {
pub quotes: QuoteMarks,
pub brackets: BracketMarks,
pub enumeration_comma_backslash: bool,
pub enumeration_comma_slash: bool,
}
impl Default for PunctuationMarks {
fn default() -> Self {
Self {
quotes: QuoteMarks::Curly,
brackets: BracketMarks::Solid,
enumeration_comma_backslash: true,
enumeration_comma_slash: false,
}
}
}
const NESTED_OPENING_INPUT: u8 = b'<';
const NESTED_CLOSING_INPUT: u8 = b'>';
const NESTED_OPENING: &str = "《";
const NESTED_OPENING_INNER: &str = "〈";
const NESTED_CLOSING: &str = "》";
const NESTED_CLOSING_INNER: &str = "〉";
#[derive(Debug, Clone)]
pub struct PunctuationPolicy {
paired_enabled: bool,
marks: PunctuationMarks,
next_double_quote_is_opening: bool,
next_single_quote_is_opening: bool,
book_title_nesting: u32,
last_alternating_quote: Option<u8>,
}
impl Default for PunctuationPolicy {
fn default() -> Self {
Self {
paired_enabled: true,
marks: PunctuationMarks::default(),
next_double_quote_is_opening: true,
next_single_quote_is_opening: true,
book_title_nesting: 0,
last_alternating_quote: None,
}
}
}
impl PunctuationPolicy {
pub fn translate(&mut self, key: u8) -> Option<&'static str> {
if self.marks.brackets == BracketMarks::Hollow {
if let Some(&(_, output)) = HOLLOW_BRACKETS.iter().find(|(input, _)| *input == key) {
self.last_alternating_quote = None;
return Some(output);
}
}
if let Some(&(input, output)) = ENUMERATION_COMMA.iter().find(|(input, _)| *input == key) {
let enabled = match input {
b'\\' => self.marks.enumeration_comma_backslash,
_ => self.marks.enumeration_comma_slash,
};
self.last_alternating_quote = None;
return enabled.then_some(output);
}
if let Some(&(_, output)) = SIMPLE.iter().find(|(input, _)| *input == key) {
self.last_alternating_quote = None;
return Some(output);
}
let corner = self.marks.quotes == QuoteMarks::Corner;
if !matches!(key, b'"' | b'\'') {
self.last_alternating_quote = None;
}
match key {
b'"' => {
self.last_alternating_quote = Some(key);
Some(alternate(
&mut self.next_double_quote_is_opening,
if corner {
CORNER_DOUBLE_QUOTE
} else {
DOUBLE_QUOTE
},
))
}
b'\'' => {
self.last_alternating_quote = Some(key);
Some(alternate(
&mut self.next_single_quote_is_opening,
if corner {
CORNER_SINGLE_QUOTE
} else {
SINGLE_QUOTE
},
))
}
NESTED_OPENING_INPUT => {
self.book_title_nesting += 1;
Some(if self.book_title_nesting == 1 {
NESTED_OPENING
} else {
NESTED_OPENING_INNER
})
}
NESTED_CLOSING_INPUT => {
if self.book_title_nesting == 0 {
return Some(NESTED_CLOSING);
}
self.book_title_nesting -= 1;
Some(if self.book_title_nesting == 0 {
NESTED_CLOSING
} else {
NESTED_CLOSING_INNER
})
}
_ => None,
}
}
pub fn clear_quote_undo(&mut self) {
self.last_alternating_quote = None;
}
pub fn undo_last_quote(&mut self) {
let Some(key) = self.last_alternating_quote.take() else {
return;
};
match key {
b'"' => self.next_double_quote_is_opening = !self.next_double_quote_is_opening,
b'\'' => self.next_single_quote_is_opening = !self.next_single_quote_is_opening,
_ => {}
}
}
pub fn set_paired_enabled(&mut self, enabled: bool) {
self.paired_enabled = enabled;
}
pub fn set_marks(&mut self, marks: PunctuationMarks) {
self.marks = marks;
}
pub fn balance_after_auto_close(&mut self, opening: u8) {
if self.paired_enabled && opening == NESTED_OPENING_INPUT && self.book_title_nesting > 0 {
self.book_title_nesting -= 1;
}
}
}
fn alternate(
next_is_opening: &mut bool,
(opening, closing): (&'static str, &'static str),
) -> &'static str {
let output = if *next_is_opening { opening } else { closing };
*next_is_opening = !*next_is_opening;
output
}
#[cfg(test)]
mod tests {
use super::*;
fn translate_all(policy: &mut PunctuationPolicy, keys: &str) -> Vec<Option<&'static str>> {
keys.bytes().map(|key| policy.translate(key)).collect()
}
#[test]
fn matches_the_shared_contract() {
let spec: serde_json::Value = serde_json::from_str(include_str!(
"../../../shared/contracts/punctuation/policy.json"
))
.expect("policy.json");
assert_eq!(spec["contractVersion"], 5);
let simple = spec["simple"].as_array().expect("simple");
assert_eq!(simple.len(), SIMPLE.len());
for ((input, output), row) in SIMPLE.iter().zip(simple) {
assert_eq!(
row["input"].as_str().map(str::as_bytes),
Some(&[*input][..])
);
assert_eq!(row["output"], *output);
}
let alternating = spec["alternating"].as_array().expect("alternating");
assert_eq!(alternating.len(), 2);
for (row, (input, (opening, closing))) in alternating
.iter()
.zip([("\"", DOUBLE_QUOTE), ("'", SINGLE_QUOTE)])
{
assert_eq!(row["input"], input);
assert_eq!(row["opening"], opening);
assert_eq!(row["closing"], closing);
}
let nested = &spec["nested"];
assert_eq!(
nested["openingInput"].as_str().map(str::as_bytes),
Some(&[NESTED_OPENING_INPUT][..])
);
assert_eq!(
nested["closingInput"].as_str().map(str::as_bytes),
Some(&[NESTED_CLOSING_INPUT][..])
);
assert_eq!(nested["opening"], NESTED_OPENING);
assert_eq!(nested["nestedOpening"], NESTED_OPENING_INNER);
assert_eq!(nested["closing"], NESTED_CLOSING);
assert_eq!(nested["nestedClosing"], NESTED_CLOSING_INNER);
let styles = &spec["styles"];
let corner = styles["cornerQuotes"].as_array().expect("cornerQuotes");
assert_eq!(corner.len(), 2);
for (row, (input, (opening, closing))) in corner
.iter()
.zip([("\"", CORNER_DOUBLE_QUOTE), ("'", CORNER_SINGLE_QUOTE)])
{
assert_eq!(row["input"], input);
assert_eq!(row["opening"], opening);
assert_eq!(row["closing"], closing);
}
let hollow = styles["hollowBrackets"].as_array().expect("hollowBrackets");
assert_eq!(hollow.len(), HOLLOW_BRACKETS.len());
for ((input, output), row) in HOLLOW_BRACKETS.iter().zip(hollow) {
assert_eq!(
row["input"].as_str().map(str::as_bytes),
Some(&[*input][..])
);
assert_eq!(row["output"], *output);
}
let enumeration = spec["enumerationComma"]
.as_array()
.expect("enumerationComma");
assert_eq!(enumeration.len(), ENUMERATION_COMMA.len());
for ((input, output), row) in ENUMERATION_COMMA.iter().zip(enumeration) {
assert_eq!(
row["input"].as_str().map(str::as_bytes),
Some(&[*input][..])
);
assert_eq!(row["output"], *output);
}
let header = include_str!("../../../shared/contracts/punctuation/policy.h");
let literal = |input: u8| match input {
b'\\' => "'\\\\'".to_owned(),
b'\'' => "'\\''".to_owned(),
_ => format!("'{}'", input as char),
};
assert!(header.contains(&format!("std::array<Mapping, {}> simple", SIMPLE.len())));
for (input, output) in SIMPLE {
let row = format!("{{{}, \"{output}\"}}", literal(input));
assert!(header.contains(&row), "policy.h lacks {row}");
}
for (input, (opening, closing)) in [
(b'"', DOUBLE_QUOTE),
(b'\'', SINGLE_QUOTE),
(b'"', CORNER_DOUBLE_QUOTE),
(b'\'', CORNER_SINGLE_QUOTE),
] {
let row = format!("{{{}, \"{opening}\", \"{closing}\"}}", literal(input));
assert!(header.contains(&row), "policy.h lacks {row}");
}
assert!(header.contains("std::array<AlternatingMapping, 2> alternating"));
assert!(header.contains("std::array<AlternatingMapping, 2> corner_quotes"));
assert!(header.contains(&format!(
"std::array<Mapping, {}> hollow_brackets",
HOLLOW_BRACKETS.len()
)));
for (input, output) in HOLLOW_BRACKETS {
let row = format!("{{{}, \"{output}\"}}", literal(input));
assert!(header.contains(&row), "policy.h lacks {row}");
}
assert!(header.contains(&format!(
"std::array<Mapping, {}> enumeration_comma",
ENUMERATION_COMMA.len()
)));
for (input, output) in ENUMERATION_COMMA {
let row = format!("{{{}, \"{output}\"}}", literal(input));
assert!(header.contains(&row), "policy.h lacks {row}");
}
for (name, value) in [
("nested_opening_input", literal(NESTED_OPENING_INPUT)),
("nested_closing_input", literal(NESTED_CLOSING_INPUT)),
("nested_opening[]", format!("\"{NESTED_OPENING}\"")),
(
"nested_opening_inner[]",
format!("\"{NESTED_OPENING_INNER}\""),
),
("nested_closing[]", format!("\"{NESTED_CLOSING}\"")),
(
"nested_closing_inner[]",
format!("\"{NESTED_CLOSING_INNER}\""),
),
] {
let line = format!("{name} = {value};");
assert!(header.contains(&line), "policy.h lacks {line}");
}
}
#[test]
fn contract_table() {
assert_eq!(SIMPLE.len(), 17);
assert_eq!(ENUMERATION_COMMA.len(), 2);
let mut policy = PunctuationPolicy::default();
assert_eq!(policy.translate(b'$'), Some("¥"));
assert_eq!(policy.translate(b'_'), Some("——"));
assert_eq!(policy.translate(b'"'), Some("“"));
assert_eq!(policy.translate(b'"'), Some("”"));
assert_eq!(policy.translate(b'<'), Some("《"));
assert_eq!(policy.translate(b'<'), Some("〈"));
for key in *b"a@-1 /" {
assert_eq!(policy.translate(key), None);
}
}
#[test]
fn enumeration_comma_keys_follow_their_switches() {
let mut policy = PunctuationPolicy::default();
assert_eq!(policy.translate(b'\\'), Some("、"));
assert_eq!(policy.translate(b'/'), None);
policy.set_marks(PunctuationMarks {
enumeration_comma_slash: true,
..PunctuationMarks::default()
});
assert_eq!(policy.translate(b'/'), Some("、"));
policy.set_marks(PunctuationMarks {
enumeration_comma_backslash: false,
enumeration_comma_slash: true,
..PunctuationMarks::default()
});
assert_eq!(policy.translate(b'\\'), None);
assert_eq!(policy.translate(b'/'), Some("、"));
policy.set_marks(PunctuationMarks {
enumeration_comma_backslash: false,
enumeration_comma_slash: false,
..PunctuationMarks::default()
});
assert_eq!(policy.translate(b'\\'), None);
assert_eq!(policy.translate(b'/'), None);
assert_eq!(policy.translate(b','), Some(","));
}
#[test]
fn idle_map_in_recorded_order() {
let mut policy = PunctuationPolicy::default();
let expected = [
"。", "“", "”", "‘", "’", "(", ")", "【", "】", "·", "¥", "……", "——", "《", "〈",
"〉", "》", "、",
];
let actual = translate_all(&mut policy, ".\"\"''()[]`$^_<<>>\\");
assert_eq!(actual, expected.map(Some));
let rest = translate_all(&mut policy, ",?!;:");
assert_eq!(rest, [",", "?", "!", ";", ":"].map(Some));
}
#[test]
fn quotes_toggle_independently() {
let mut policy = PunctuationPolicy::default();
assert_eq!(
translate_all(&mut policy, "\"'\"'"),
["“", "‘", "”", "’"].map(Some)
);
}
#[test]
fn deleting_the_last_quote_restores_its_direction() {
let mut policy = PunctuationPolicy::default();
assert_eq!(policy.translate(b'"'), Some("“"));
policy.undo_last_quote();
assert_eq!(policy.translate(b'"'), Some("“"));
assert_eq!(policy.translate(b':'), Some(":"));
policy.undo_last_quote();
assert_eq!(policy.translate(b'"'), Some("”"));
}
#[test]
fn pairing_off_still_alternates_and_nests() {
let mut policy = PunctuationPolicy::default();
policy.set_paired_enabled(false);
assert_eq!(
translate_all(&mut policy, "\"\"''<<>>"),
["“", "”", "‘", "’", "《", "〈", "〉", "》"].map(Some)
);
policy.translate(b'"');
policy.set_paired_enabled(true);
assert_eq!(policy.translate(b'"'), Some("”"));
}
#[test]
fn unmatched_closing_never_goes_negative() {
let mut policy = PunctuationPolicy::default();
assert_eq!(
translate_all(&mut policy, ">><"),
["》", "》", "《"].map(Some)
);
assert_eq!(policy.book_title_nesting, 1);
}
#[test]
fn auto_close_balances_only_with_pairing() {
let mut policy = PunctuationPolicy::default();
assert_eq!(policy.translate(b'<'), Some("《"));
policy.balance_after_auto_close(b'<');
assert_eq!(policy.book_title_nesting, 0);
assert_eq!(policy.translate(b'<'), Some("《"));
policy.balance_after_auto_close(b'(');
assert_eq!(policy.book_title_nesting, 1);
policy.set_paired_enabled(false);
policy.balance_after_auto_close(b'<');
assert_eq!(policy.book_title_nesting, 1);
assert_eq!(policy.translate(b'<'), Some("〈"));
policy.set_paired_enabled(true);
let mut empty = PunctuationPolicy::default();
empty.balance_after_auto_close(b'<');
assert_eq!(empty.book_title_nesting, 0);
}
fn styled(quotes: QuoteMarks, brackets: BracketMarks) -> PunctuationPolicy {
let mut policy = PunctuationPolicy::default();
policy.set_marks(PunctuationMarks {
quotes,
brackets,
..PunctuationMarks::default()
});
policy
}
#[test]
fn corner_quotes_alternate_per_key() {
let mut policy = styled(QuoteMarks::Corner, BracketMarks::Solid);
assert_eq!(
translate_all(&mut policy, "'\"'\""),
["「", "『", "」", "』"].map(Some)
);
assert_eq!(translate_all(&mut policy, "[]"), ["【", "】"].map(Some));
}
#[test]
fn hollow_brackets_replace_only_square_keys() {
let mut hollow = styled(QuoteMarks::Curly, BracketMarks::Hollow);
let mut solid = PunctuationPolicy::default();
assert_eq!(translate_all(&mut hollow, "[]"), ["〖", "〗"].map(Some));
for (key, _) in SIMPLE.iter().filter(|(key, _)| !matches!(key, b'[' | b']')) {
assert_eq!(hollow.translate(*key), solid.translate(*key));
}
assert_eq!(
translate_all(&mut hollow, "\"'<>"),
["“", "‘", "《", "》"].map(Some)
);
}
#[test]
fn shifted_brackets_give_corner_quotes() {
let mut policy = PunctuationPolicy::default();
assert_eq!(
translate_all(&mut policy, "{}{{}}"),
["「", "」", "「", "「", "」", "」"].map(Some)
);
assert_eq!(
translate_all(&mut policy, "\"'}{"),
["“", "‘", "」", "「"].map(Some)
);
let mut corner = styled(QuoteMarks::Corner, BracketMarks::Hollow);
assert_eq!(
translate_all(&mut corner, "{'}'"),
["「", "「", "」", "」"].map(Some)
);
policy.set_paired_enabled(false);
assert_eq!(translate_all(&mut policy, "}{"), ["」", "「"].map(Some));
assert_eq!(policy.translate(b'"'), Some("”"));
assert_eq!(policy.translate(b'{'), Some("「"));
policy.undo_last_quote();
assert_eq!(policy.translate(b'"'), Some("“"));
}
#[test]
fn default_marks_match_the_contract() {
assert_eq!(
PunctuationMarks::default(),
PunctuationMarks {
quotes: QuoteMarks::Curly,
brackets: BracketMarks::Solid,
enumeration_comma_backslash: true,
enumeration_comma_slash: false,
}
);
let mut styled = styled(QuoteMarks::Curly, BracketMarks::Solid);
let mut default = PunctuationPolicy::default();
let keys = ",.?!;:()[]\\`$^_\"\"''<<>>";
assert_eq!(
translate_all(&mut styled, keys),
translate_all(&mut default, keys)
);
}
#[test]
fn marks_do_not_reset_alternation() {
let mut policy = PunctuationPolicy::default();
assert_eq!(policy.translate(b'\''), Some("‘"));
policy.set_marks(PunctuationMarks {
quotes: QuoteMarks::Corner,
brackets: BracketMarks::Solid,
..PunctuationMarks::default()
});
assert_eq!(policy.translate(b'\''), Some("」"));
assert_eq!(policy.translate(b'\''), Some("「"));
}
#[test]
fn pairing_off_still_alternates_corner_quotes() {
let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
policy.set_paired_enabled(false);
assert_eq!(
translate_all(&mut policy, "''\"\"[]"),
["「", "」", "『", "』", "〖", "〗"].map(Some)
);
}
#[test]
fn balance_after_auto_close_ignores_styles() {
let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
assert_eq!(policy.translate(b'<'), Some("《"));
policy.balance_after_auto_close(b'[');
policy.balance_after_auto_close(b'\'');
assert_eq!(policy.book_title_nesting, 1);
policy.balance_after_auto_close(b'<');
assert_eq!(policy.book_title_nesting, 0);
assert_eq!(policy.translate(b'\''), Some("「"));
}
}