1const SIMPLE: [(u8, &str); 17] = [
5 (b',', ","),
6 (b'.', "。"),
7 (b'?', "?"),
8 (b'!', "!"),
9 (b';', ";"),
10 (b':', ":"),
11 (b'(', "("),
12 (b')', ")"),
13 (b'[', "【"),
14 (b']', "】"),
15 (b'\\', "、"),
16 (b'`', "·"),
17 (b'$', "¥"),
18 (b'^', "……"),
19 (b'_', "——"),
20 (b'{', "「"),
21 (b'}', "」"),
22];
23
24const DOUBLE_QUOTE: (&str, &str) = ("“", "”");
25const SINGLE_QUOTE: (&str, &str) = ("‘", "’");
26
27const CORNER_DOUBLE_QUOTE: (&str, &str) = ("『", "』");
29const CORNER_SINGLE_QUOTE: (&str, &str) = ("「", "」");
30
31const HOLLOW_BRACKETS: [(u8, &str); 2] = [(b'[', "〖"), (b']', "〗")];
33
34const ENUMERATION_COMMA: [(u8, &str); 2] = [(b'\\', "、"), (b'/', "、")];
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum QuoteMarks {
40 #[default]
41 Curly,
42 Corner,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum BracketMarks {
48 #[default]
49 Solid,
50 Hollow,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct PunctuationMarks {
56 pub quotes: QuoteMarks,
57 pub brackets: BracketMarks,
58 pub enumeration_comma_backslash: bool,
60 pub enumeration_comma_slash: bool,
62}
63
64impl Default for PunctuationMarks {
65 fn default() -> Self {
66 Self {
67 quotes: QuoteMarks::Curly,
68 brackets: BracketMarks::Solid,
69 enumeration_comma_backslash: true,
70 enumeration_comma_slash: false,
71 }
72 }
73}
74
75const NESTED_OPENING_INPUT: u8 = b'<';
76const NESTED_CLOSING_INPUT: u8 = b'>';
77const NESTED_OPENING: &str = "《";
78const NESTED_OPENING_INNER: &str = "〈";
79const NESTED_CLOSING: &str = "》";
80const NESTED_CLOSING_INNER: &str = "〉";
81
82#[derive(Debug, Clone)]
83pub struct PunctuationPolicy {
84 paired_enabled: bool,
85 marks: PunctuationMarks,
86 next_double_quote_is_opening: bool,
87 next_single_quote_is_opening: bool,
88 book_title_nesting: u32,
89 last_alternating_quote: Option<u8>,
91}
92
93impl Default for PunctuationPolicy {
94 fn default() -> Self {
95 Self {
96 paired_enabled: true,
97 marks: PunctuationMarks::default(),
98 next_double_quote_is_opening: true,
99 next_single_quote_is_opening: true,
100 book_title_nesting: 0,
101 last_alternating_quote: None,
102 }
103 }
104}
105
106impl PunctuationPolicy {
107 pub fn translate(&mut self, key: u8) -> Option<&'static str> {
109 if self.marks.brackets == BracketMarks::Hollow {
110 if let Some(&(_, output)) = HOLLOW_BRACKETS.iter().find(|(input, _)| *input == key) {
111 self.last_alternating_quote = None;
112 return Some(output);
113 }
114 }
115 if let Some(&(input, output)) = ENUMERATION_COMMA.iter().find(|(input, _)| *input == key) {
117 let enabled = match input {
118 b'\\' => self.marks.enumeration_comma_backslash,
119 _ => self.marks.enumeration_comma_slash,
120 };
121 self.last_alternating_quote = None;
122 return enabled.then_some(output);
123 }
124 if let Some(&(_, output)) = SIMPLE.iter().find(|(input, _)| *input == key) {
125 self.last_alternating_quote = None;
126 return Some(output);
127 }
128 let corner = self.marks.quotes == QuoteMarks::Corner;
129 if !matches!(key, b'"' | b'\'') {
130 self.last_alternating_quote = None;
131 }
132 match key {
135 b'"' => {
136 self.last_alternating_quote = Some(key);
137 Some(alternate(
138 &mut self.next_double_quote_is_opening,
139 if corner {
140 CORNER_DOUBLE_QUOTE
141 } else {
142 DOUBLE_QUOTE
143 },
144 ))
145 }
146 b'\'' => {
147 self.last_alternating_quote = Some(key);
148 Some(alternate(
149 &mut self.next_single_quote_is_opening,
150 if corner {
151 CORNER_SINGLE_QUOTE
152 } else {
153 SINGLE_QUOTE
154 },
155 ))
156 }
157 NESTED_OPENING_INPUT => {
158 self.book_title_nesting += 1;
159 Some(if self.book_title_nesting == 1 {
160 NESTED_OPENING
161 } else {
162 NESTED_OPENING_INNER
163 })
164 }
165 NESTED_CLOSING_INPUT => {
167 if self.book_title_nesting == 0 {
168 return Some(NESTED_CLOSING);
169 }
170 self.book_title_nesting -= 1;
171 Some(if self.book_title_nesting == 0 {
172 NESTED_CLOSING
173 } else {
174 NESTED_CLOSING_INNER
175 })
176 }
177 _ => None,
178 }
179 }
180
181 pub fn clear_quote_undo(&mut self) {
183 self.last_alternating_quote = None;
184 }
185
186 pub fn undo_last_quote(&mut self) {
188 let Some(key) = self.last_alternating_quote.take() else {
189 return;
190 };
191 match key {
192 b'"' => self.next_double_quote_is_opening = !self.next_double_quote_is_opening,
193 b'\'' => self.next_single_quote_is_opening = !self.next_single_quote_is_opening,
194 _ => {}
195 }
196 }
197
198 pub fn set_paired_enabled(&mut self, enabled: bool) {
199 self.paired_enabled = enabled;
200 }
201
202 pub fn set_marks(&mut self, marks: PunctuationMarks) {
203 self.marks = marks;
204 }
205
206 pub fn balance_after_auto_close(&mut self, opening: u8) {
208 if self.paired_enabled && opening == NESTED_OPENING_INPUT && self.book_title_nesting > 0 {
209 self.book_title_nesting -= 1;
210 }
211 }
212}
213
214fn alternate(
215 next_is_opening: &mut bool,
216 (opening, closing): (&'static str, &'static str),
217) -> &'static str {
218 let output = if *next_is_opening { opening } else { closing };
219 *next_is_opening = !*next_is_opening;
220 output
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn translate_all(policy: &mut PunctuationPolicy, keys: &str) -> Vec<Option<&'static str>> {
228 keys.bytes().map(|key| policy.translate(key)).collect()
229 }
230
231 #[test]
233 fn matches_the_shared_contract() {
234 let spec: serde_json::Value = serde_json::from_str(include_str!(
235 "../../../shared/contracts/punctuation/policy.json"
236 ))
237 .expect("policy.json");
238 assert_eq!(spec["contractVersion"], 5);
239 let simple = spec["simple"].as_array().expect("simple");
240 assert_eq!(simple.len(), SIMPLE.len());
241 for ((input, output), row) in SIMPLE.iter().zip(simple) {
242 assert_eq!(
243 row["input"].as_str().map(str::as_bytes),
244 Some(&[*input][..])
245 );
246 assert_eq!(row["output"], *output);
247 }
248 let alternating = spec["alternating"].as_array().expect("alternating");
249 assert_eq!(alternating.len(), 2);
250 for (row, (input, (opening, closing))) in alternating
251 .iter()
252 .zip([("\"", DOUBLE_QUOTE), ("'", SINGLE_QUOTE)])
253 {
254 assert_eq!(row["input"], input);
255 assert_eq!(row["opening"], opening);
256 assert_eq!(row["closing"], closing);
257 }
258 let nested = &spec["nested"];
259 assert_eq!(
260 nested["openingInput"].as_str().map(str::as_bytes),
261 Some(&[NESTED_OPENING_INPUT][..])
262 );
263 assert_eq!(
264 nested["closingInput"].as_str().map(str::as_bytes),
265 Some(&[NESTED_CLOSING_INPUT][..])
266 );
267 assert_eq!(nested["opening"], NESTED_OPENING);
268 assert_eq!(nested["nestedOpening"], NESTED_OPENING_INNER);
269 assert_eq!(nested["closing"], NESTED_CLOSING);
270 assert_eq!(nested["nestedClosing"], NESTED_CLOSING_INNER);
271
272 let styles = &spec["styles"];
273 let corner = styles["cornerQuotes"].as_array().expect("cornerQuotes");
274 assert_eq!(corner.len(), 2);
275 for (row, (input, (opening, closing))) in corner
276 .iter()
277 .zip([("\"", CORNER_DOUBLE_QUOTE), ("'", CORNER_SINGLE_QUOTE)])
278 {
279 assert_eq!(row["input"], input);
280 assert_eq!(row["opening"], opening);
281 assert_eq!(row["closing"], closing);
282 }
283 let hollow = styles["hollowBrackets"].as_array().expect("hollowBrackets");
284 assert_eq!(hollow.len(), HOLLOW_BRACKETS.len());
285 for ((input, output), row) in HOLLOW_BRACKETS.iter().zip(hollow) {
286 assert_eq!(
287 row["input"].as_str().map(str::as_bytes),
288 Some(&[*input][..])
289 );
290 assert_eq!(row["output"], *output);
291 }
292 let enumeration = spec["enumerationComma"]
293 .as_array()
294 .expect("enumerationComma");
295 assert_eq!(enumeration.len(), ENUMERATION_COMMA.len());
296 for ((input, output), row) in ENUMERATION_COMMA.iter().zip(enumeration) {
297 assert_eq!(
298 row["input"].as_str().map(str::as_bytes),
299 Some(&[*input][..])
300 );
301 assert_eq!(row["output"], *output);
302 }
303
304 let header = include_str!("../../../shared/contracts/punctuation/policy.h");
305 let literal = |input: u8| match input {
306 b'\\' => "'\\\\'".to_owned(),
307 b'\'' => "'\\''".to_owned(),
308 _ => format!("'{}'", input as char),
309 };
310 assert!(header.contains(&format!("std::array<Mapping, {}> simple", SIMPLE.len())));
311 for (input, output) in SIMPLE {
312 let row = format!("{{{}, \"{output}\"}}", literal(input));
313 assert!(header.contains(&row), "policy.h lacks {row}");
314 }
315 for (input, (opening, closing)) in [
316 (b'"', DOUBLE_QUOTE),
317 (b'\'', SINGLE_QUOTE),
318 (b'"', CORNER_DOUBLE_QUOTE),
319 (b'\'', CORNER_SINGLE_QUOTE),
320 ] {
321 let row = format!("{{{}, \"{opening}\", \"{closing}\"}}", literal(input));
322 assert!(header.contains(&row), "policy.h lacks {row}");
323 }
324 assert!(header.contains("std::array<AlternatingMapping, 2> alternating"));
325 assert!(header.contains("std::array<AlternatingMapping, 2> corner_quotes"));
326 assert!(header.contains(&format!(
327 "std::array<Mapping, {}> hollow_brackets",
328 HOLLOW_BRACKETS.len()
329 )));
330 for (input, output) in HOLLOW_BRACKETS {
331 let row = format!("{{{}, \"{output}\"}}", literal(input));
332 assert!(header.contains(&row), "policy.h lacks {row}");
333 }
334 assert!(header.contains(&format!(
335 "std::array<Mapping, {}> enumeration_comma",
336 ENUMERATION_COMMA.len()
337 )));
338 for (input, output) in ENUMERATION_COMMA {
339 let row = format!("{{{}, \"{output}\"}}", literal(input));
340 assert!(header.contains(&row), "policy.h lacks {row}");
341 }
342 for (name, value) in [
343 ("nested_opening_input", literal(NESTED_OPENING_INPUT)),
344 ("nested_closing_input", literal(NESTED_CLOSING_INPUT)),
345 ("nested_opening[]", format!("\"{NESTED_OPENING}\"")),
346 (
347 "nested_opening_inner[]",
348 format!("\"{NESTED_OPENING_INNER}\""),
349 ),
350 ("nested_closing[]", format!("\"{NESTED_CLOSING}\"")),
351 (
352 "nested_closing_inner[]",
353 format!("\"{NESTED_CLOSING_INNER}\""),
354 ),
355 ] {
356 let line = format!("{name} = {value};");
357 assert!(header.contains(&line), "policy.h lacks {line}");
358 }
359 }
360
361 #[test]
363 fn contract_table() {
364 assert_eq!(SIMPLE.len(), 17);
365 assert_eq!(ENUMERATION_COMMA.len(), 2);
366 let mut policy = PunctuationPolicy::default();
367 assert_eq!(policy.translate(b'$'), Some("¥"));
368 assert_eq!(policy.translate(b'_'), Some("——"));
369 assert_eq!(policy.translate(b'"'), Some("“"));
370 assert_eq!(policy.translate(b'"'), Some("”"));
371 assert_eq!(policy.translate(b'<'), Some("《"));
372 assert_eq!(policy.translate(b'<'), Some("〈"));
373 for key in *b"a@-1 /" {
374 assert_eq!(policy.translate(key), None);
375 }
376 }
377
378 #[test]
380 fn enumeration_comma_keys_follow_their_switches() {
381 let mut policy = PunctuationPolicy::default();
382 assert_eq!(policy.translate(b'\\'), Some("、"));
383 assert_eq!(policy.translate(b'/'), None);
384
385 policy.set_marks(PunctuationMarks {
386 enumeration_comma_slash: true,
387 ..PunctuationMarks::default()
388 });
389 assert_eq!(policy.translate(b'/'), Some("、"));
390
391 policy.set_marks(PunctuationMarks {
392 enumeration_comma_backslash: false,
393 enumeration_comma_slash: true,
394 ..PunctuationMarks::default()
395 });
396 assert_eq!(policy.translate(b'\\'), None);
397 assert_eq!(policy.translate(b'/'), Some("、"));
398
399 policy.set_marks(PunctuationMarks {
401 enumeration_comma_backslash: false,
402 enumeration_comma_slash: false,
403 ..PunctuationMarks::default()
404 });
405 assert_eq!(policy.translate(b'\\'), None);
406 assert_eq!(policy.translate(b'/'), None);
407 assert_eq!(policy.translate(b','), Some(","));
408 }
409
410 #[test]
412 fn idle_map_in_recorded_order() {
413 let mut policy = PunctuationPolicy::default();
414 let expected = [
415 "。", "“", "”", "‘", "’", "(", ")", "【", "】", "·", "¥", "……", "——", "《", "〈",
416 "〉", "》", "、",
417 ];
418 let actual = translate_all(&mut policy, ".\"\"''()[]`$^_<<>>\\");
419 assert_eq!(actual, expected.map(Some));
420 let rest = translate_all(&mut policy, ",?!;:");
421 assert_eq!(rest, [",", "?", "!", ";", ":"].map(Some));
422 }
423
424 #[test]
425 fn quotes_toggle_independently() {
426 let mut policy = PunctuationPolicy::default();
427 assert_eq!(
428 translate_all(&mut policy, "\"'\"'"),
429 ["“", "‘", "”", "’"].map(Some)
430 );
431 }
432
433 #[test]
434 fn deleting_the_last_quote_restores_its_direction() {
435 let mut policy = PunctuationPolicy::default();
436 assert_eq!(policy.translate(b'"'), Some("“"));
437 policy.undo_last_quote();
438 assert_eq!(policy.translate(b'"'), Some("“"));
439
440 assert_eq!(policy.translate(b':'), Some(":"));
442 policy.undo_last_quote();
443 assert_eq!(policy.translate(b'"'), Some("”"));
444 }
445
446 #[test]
448 fn pairing_off_still_alternates_and_nests() {
449 let mut policy = PunctuationPolicy::default();
450 policy.set_paired_enabled(false);
451 assert_eq!(
452 translate_all(&mut policy, "\"\"''<<>>"),
453 ["“", "”", "‘", "’", "《", "〈", "〉", "》"].map(Some)
454 );
455 policy.translate(b'"');
456 policy.set_paired_enabled(true);
457 assert_eq!(policy.translate(b'"'), Some("”"));
458 }
459
460 #[test]
461 fn unmatched_closing_never_goes_negative() {
462 let mut policy = PunctuationPolicy::default();
463 assert_eq!(
464 translate_all(&mut policy, ">><"),
465 ["》", "》", "《"].map(Some)
466 );
467 assert_eq!(policy.book_title_nesting, 1);
468 }
469
470 #[test]
471 fn auto_close_balances_only_with_pairing() {
472 let mut policy = PunctuationPolicy::default();
473 assert_eq!(policy.translate(b'<'), Some("《"));
474 policy.balance_after_auto_close(b'<');
475 assert_eq!(policy.book_title_nesting, 0);
476 assert_eq!(policy.translate(b'<'), Some("《"));
477
478 policy.balance_after_auto_close(b'(');
479 assert_eq!(policy.book_title_nesting, 1);
480
481 policy.set_paired_enabled(false);
482 policy.balance_after_auto_close(b'<');
483 assert_eq!(policy.book_title_nesting, 1);
484 assert_eq!(policy.translate(b'<'), Some("〈"));
485
486 policy.set_paired_enabled(true);
487 let mut empty = PunctuationPolicy::default();
488 empty.balance_after_auto_close(b'<');
489 assert_eq!(empty.book_title_nesting, 0);
490 }
491
492 fn styled(quotes: QuoteMarks, brackets: BracketMarks) -> PunctuationPolicy {
493 let mut policy = PunctuationPolicy::default();
494 policy.set_marks(PunctuationMarks {
495 quotes,
496 brackets,
497 ..PunctuationMarks::default()
498 });
499 policy
500 }
501
502 #[test]
503 fn corner_quotes_alternate_per_key() {
504 let mut policy = styled(QuoteMarks::Corner, BracketMarks::Solid);
505 assert_eq!(
506 translate_all(&mut policy, "'\"'\""),
507 ["「", "『", "」", "』"].map(Some)
508 );
509 assert_eq!(translate_all(&mut policy, "[]"), ["【", "】"].map(Some));
510 }
511
512 #[test]
513 fn hollow_brackets_replace_only_square_keys() {
514 let mut hollow = styled(QuoteMarks::Curly, BracketMarks::Hollow);
515 let mut solid = PunctuationPolicy::default();
516 assert_eq!(translate_all(&mut hollow, "[]"), ["〖", "〗"].map(Some));
517 for (key, _) in SIMPLE.iter().filter(|(key, _)| !matches!(key, b'[' | b']')) {
518 assert_eq!(hollow.translate(*key), solid.translate(*key));
519 }
520 assert_eq!(
521 translate_all(&mut hollow, "\"'<>"),
522 ["“", "‘", "《", "》"].map(Some)
523 );
524 }
525
526 #[test]
528 fn shifted_brackets_give_corner_quotes() {
529 let mut policy = PunctuationPolicy::default();
530 assert_eq!(
531 translate_all(&mut policy, "{}{{}}"),
532 ["「", "」", "「", "「", "」", "」"].map(Some)
533 );
534 assert_eq!(
536 translate_all(&mut policy, "\"'}{"),
537 ["“", "‘", "」", "「"].map(Some)
538 );
539 let mut corner = styled(QuoteMarks::Corner, BracketMarks::Hollow);
541 assert_eq!(
542 translate_all(&mut corner, "{'}'"),
543 ["「", "「", "」", "」"].map(Some)
544 );
545 policy.set_paired_enabled(false);
547 assert_eq!(translate_all(&mut policy, "}{"), ["」", "「"].map(Some));
548 assert_eq!(policy.translate(b'"'), Some("”"));
550 assert_eq!(policy.translate(b'{'), Some("「"));
551 policy.undo_last_quote();
552 assert_eq!(policy.translate(b'"'), Some("“"));
553 }
554
555 #[test]
557 fn default_marks_match_the_contract() {
558 assert_eq!(
559 PunctuationMarks::default(),
560 PunctuationMarks {
561 quotes: QuoteMarks::Curly,
562 brackets: BracketMarks::Solid,
563 enumeration_comma_backslash: true,
564 enumeration_comma_slash: false,
565 }
566 );
567 let mut styled = styled(QuoteMarks::Curly, BracketMarks::Solid);
568 let mut default = PunctuationPolicy::default();
569 let keys = ",.?!;:()[]\\`$^_\"\"''<<>>";
570 assert_eq!(
571 translate_all(&mut styled, keys),
572 translate_all(&mut default, keys)
573 );
574 }
575
576 #[test]
577 fn marks_do_not_reset_alternation() {
578 let mut policy = PunctuationPolicy::default();
579 assert_eq!(policy.translate(b'\''), Some("‘"));
580 policy.set_marks(PunctuationMarks {
581 quotes: QuoteMarks::Corner,
582 brackets: BracketMarks::Solid,
583 ..PunctuationMarks::default()
584 });
585 assert_eq!(policy.translate(b'\''), Some("」"));
586 assert_eq!(policy.translate(b'\''), Some("「"));
587 }
588
589 #[test]
590 fn pairing_off_still_alternates_corner_quotes() {
591 let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
592 policy.set_paired_enabled(false);
593 assert_eq!(
594 translate_all(&mut policy, "''\"\"[]"),
595 ["「", "」", "『", "』", "〖", "〗"].map(Some)
596 );
597 }
598
599 #[test]
600 fn balance_after_auto_close_ignores_styles() {
601 let mut policy = styled(QuoteMarks::Corner, BracketMarks::Hollow);
602 assert_eq!(policy.translate(b'<'), Some("《"));
603 policy.balance_after_auto_close(b'[');
604 policy.balance_after_auto_close(b'\'');
605 assert_eq!(policy.book_title_nesting, 1);
606 policy.balance_after_auto_close(b'<');
607 assert_eq!(policy.book_title_nesting, 0);
608 assert_eq!(policy.translate(b'\''), Some("「"));
609 }
610}