1use std::collections::{BTreeMap, BTreeSet};
2use std::error::Error;
3use std::fmt;
4
5use moine_core::{Arc, DistanceError, Lattice, LatticeError, Symbol};
6
7use crate::kana::{is_kana, normalize_kana_char};
8
9#[derive(Clone, Copy, Debug, Default)]
11pub struct RomajiVariantTable;
12
13impl RomajiVariantTable {
14 pub fn variants(&self, unit: &str) -> Option<&'static [&'static str]> {
16 variants_for(unit)
17 }
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct RomajiExpansionLimits {
27 pub max_input_chars: usize,
29 pub max_expanded_paths: usize,
31 pub max_total_symbols: usize,
33}
34
35impl Default for RomajiExpansionLimits {
36 fn default() -> Self {
37 Self {
38 max_input_chars: 1024,
39 max_expanded_paths: 65_536,
40 max_total_symbols: 1_048_576,
41 }
42 }
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum RomajiExpansionLimit {
48 InputChars,
50 ExpandedPaths,
52 TotalSymbols,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum JaLatticeError {
59 UnsupportedChar {
61 ch: char,
63 index: usize,
65 },
66 MissingVariant {
68 unit: String,
70 },
71 EmptyReadings,
73 Lattice(LatticeError),
75 Distance(DistanceError),
77 ArtifactPayload(String),
79 ExpansionLimitExceeded {
81 limit: RomajiExpansionLimit,
83 value: usize,
85 max: usize,
87 },
88}
89
90impl fmt::Display for JaLatticeError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 Self::UnsupportedChar { ch, index } => {
94 write!(f, "unsupported character {ch:?} at char index {index}")
95 }
96 Self::MissingVariant { unit } => {
97 write!(f, "missing romaji variant for kana unit {unit:?}")
98 }
99 Self::EmptyReadings => write!(f, "at least one reading is required"),
100 Self::Lattice(err) => write!(f, "{err}"),
101 Self::Distance(err) => write!(f, "{err}"),
102 Self::ArtifactPayload(err) => write!(f, "{err}"),
103 Self::ExpansionLimitExceeded { limit, value, max } => {
104 let limit_name = match limit {
105 RomajiExpansionLimit::InputChars => "input characters",
106 RomajiExpansionLimit::ExpandedPaths => "expanded romaji paths",
107 RomajiExpansionLimit::TotalSymbols => "expanded romaji symbols",
108 };
109 write!(
110 f,
111 "romaji expansion exceeded {limit_name} limit: {value} > {max}"
112 )
113 }
114 }
115 }
116}
117
118impl Error for JaLatticeError {
119 fn source(&self) -> Option<&(dyn Error + 'static)> {
120 match self {
121 Self::Lattice(err) => Some(err),
122 Self::Distance(err) => Some(err),
123 Self::UnsupportedChar { .. }
124 | Self::MissingVariant { .. }
125 | Self::EmptyReadings
126 | Self::ArtifactPayload(_)
127 | Self::ExpansionLimitExceeded { .. } => None,
128 }
129 }
130}
131
132impl From<LatticeError> for JaLatticeError {
133 fn from(value: LatticeError) -> Self {
134 Self::Lattice(value)
135 }
136}
137
138impl From<DistanceError> for JaLatticeError {
139 fn from(value: DistanceError) -> Self {
140 Self::Distance(value)
141 }
142}
143
144pub fn romaji_lattice(input: &str) -> Result<Lattice, JaLatticeError> {
149 RomajiVariantTable.build_lattice(input)
150}
151
152pub(crate) fn can_build_direct_romaji_path(input: &str) -> bool {
153 segment(input, RomajiSegmentMode::Surface)
154 .and_then(|units| validate_romaji_units(&units))
155 .is_ok()
156}
157
158pub(crate) fn can_build_romaji_reading(input: &str) -> bool {
159 segment(input, RomajiSegmentMode::Reading)
160 .and_then(|units| validate_romaji_units(&units))
161 .is_ok()
162}
163
164pub fn romaji_lattice_from_readings<I, S>(readings: I) -> Result<Lattice, JaLatticeError>
169where
170 I: IntoIterator<Item = S>,
171 S: AsRef<str>,
172{
173 let mut builder = RomajiLatticeBuilder::new();
174 for reading in readings {
175 let units = segment(reading.as_ref(), RomajiSegmentMode::Reading)?;
176 builder.add_units(&units)?;
177 }
178 builder.into_lattice()
179}
180
181pub(crate) fn romaji_lattice_from_supported_readings<I, S>(
182 readings: I,
183) -> Result<Option<Lattice>, JaLatticeError>
184where
185 I: IntoIterator<Item = S>,
186 S: AsRef<str>,
187{
188 let mut builder = RomajiLatticeBuilder::new();
189 for reading in readings {
190 match segment(reading.as_ref(), RomajiSegmentMode::Reading)
191 .and_then(|units| builder.add_units(&units))
192 {
193 Ok(()) => {}
194 Err(err) if is_unsupported_reading_error(&err) => continue,
195 Err(err) => return Err(err),
196 }
197 }
198 builder.into_optional_lattice()
199}
200
201fn is_unsupported_reading_error(err: &JaLatticeError) -> bool {
202 matches!(
203 err,
204 JaLatticeError::UnsupportedChar { .. } | JaLatticeError::MissingVariant { .. }
205 )
206}
207
208pub(crate) fn romaji_paths_from_segmented_readings<I, P, S>(
209 reading_paths: I,
210) -> Result<Vec<String>, JaLatticeError>
211where
212 I: IntoIterator<Item = P>,
213 P: IntoIterator<Item = (S, RomajiSegmentMode)>,
214 S: AsRef<str>,
215{
216 let mut paths = Vec::new();
217 let limits = RomajiExpansionLimits::default();
218 for reading_path in reading_paths {
219 let mut units = Vec::new();
220 let mut input_chars = 0usize;
221 for (segment_reading, mode) in reading_path {
222 let segment_reading = segment_reading.as_ref();
223 input_chars = checked_add_limit(
224 input_chars,
225 segment_reading.chars().count(),
226 RomajiExpansionLimit::InputChars,
227 limits.max_input_chars,
228 )?;
229 check_expansion_limit(
230 RomajiExpansionLimit::InputChars,
231 input_chars,
232 limits.max_input_chars,
233 )?;
234 units.extend(segment(segment_reading, mode)?);
235 }
236 let expanded = romaji_paths_from_units(&units, limits)?;
237 extend_expanded_paths(&mut paths, expanded, limits)?;
238 }
239 if paths.is_empty() {
240 return Err(JaLatticeError::EmptyReadings);
241 }
242 Ok(paths)
243}
244
245pub(crate) fn romaji_lattice_from_segmented_readings<I, P, S>(
246 reading_paths: I,
247) -> Result<Lattice, JaLatticeError>
248where
249 I: IntoIterator<Item = P>,
250 P: IntoIterator<Item = (S, RomajiSegmentMode)>,
251 S: AsRef<str>,
252{
253 let mut builder = RomajiLatticeBuilder::new();
254 for reading_path in reading_paths {
255 let mut units = Vec::new();
256 for (segment_reading, mode) in reading_path {
257 units.extend(segment(segment_reading.as_ref(), mode)?);
258 }
259 builder.add_units(&units)?;
260 }
261 builder.into_lattice()
262}
263
264pub(crate) fn romaji_lattice_from_supported_segmented_readings<I, P, S>(
265 reading_paths: I,
266) -> Result<Option<Lattice>, JaLatticeError>
267where
268 I: IntoIterator<Item = P>,
269 P: IntoIterator<Item = (S, RomajiSegmentMode)>,
270 S: AsRef<str>,
271{
272 let mut builder = RomajiLatticeBuilder::new();
273 for reading_path in reading_paths {
274 let mut units = Vec::new();
275 let mut supported = true;
276 for (segment_reading, mode) in reading_path {
277 match segment(segment_reading.as_ref(), mode) {
278 Ok(segment_units) => units.extend(segment_units),
279 Err(err) if is_unsupported_reading_error(&err) => {
280 supported = false;
281 break;
282 }
283 Err(err) => return Err(err),
284 }
285 }
286 if !supported {
287 continue;
288 }
289 match builder.add_units(&units) {
290 Ok(()) => {}
291 Err(err) if is_unsupported_reading_error(&err) => continue,
292 Err(err) => return Err(err),
293 }
294 }
295 builder.into_optional_lattice()
296}
297
298impl RomajiVariantTable {
299 pub fn build_lattice(&self, input: &str) -> Result<Lattice, JaLatticeError> {
301 let units = segment(input, RomajiSegmentMode::Surface)?;
302 let mut builder = RomajiLatticeBuilder::new();
303 builder.add_units(&units)?;
304 builder.into_lattice()
305 }
306}
307
308#[derive(Clone, Debug, Eq, PartialEq)]
309enum Unit {
310 Ascii(char),
311 NeutralLiteral(char),
312 Kana(String),
313}
314
315#[derive(Clone, Copy, Debug, Eq, PartialEq)]
316pub(crate) enum RomajiSegmentMode {
317 Surface,
318 Reading,
319}
320
321const END_NODE: usize = usize::MAX;
322
323#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
324enum LastVowel {
325 None,
326 A,
327 I,
328 U,
329 E,
330 O,
331}
332
333#[derive(Clone, Copy, Debug, Eq, PartialEq)]
334struct FrontierState {
335 node: usize,
336 last_vowel: LastVowel,
337}
338
339#[derive(Clone, Debug, Eq, PartialEq)]
340enum RomajiStep {
341 Variants(Vec<String>),
342 LongVowel,
343}
344
345#[derive(Clone, Debug)]
346struct RomajiTransition {
347 src: usize,
348 symbols: Vec<Symbol>,
349 last_vowel: LastVowel,
350}
351
352#[derive(Debug)]
353struct RomajiLatticeBuilder {
354 next_node: usize,
355 arcs: Vec<Arc>,
356 seen_arcs: BTreeSet<(usize, usize, Symbol)>,
357 has_empty_path: bool,
358 has_non_empty_path: bool,
359}
360
361impl RomajiLatticeBuilder {
362 fn new() -> Self {
363 Self {
364 next_node: 1,
365 arcs: Vec::new(),
366 seen_arcs: BTreeSet::new(),
367 has_empty_path: false,
368 has_non_empty_path: false,
369 }
370 }
371
372 fn add_units(&mut self, units: &[Unit]) -> Result<(), JaLatticeError> {
373 let steps = romaji_steps_from_units(units)?;
374 if steps.is_empty() {
375 self.has_empty_path = true;
376 return Ok(());
377 }
378
379 self.has_non_empty_path = true;
380 let mut frontier = vec![FrontierState {
381 node: 0,
382 last_vowel: LastVowel::None,
383 }];
384 for (idx, step) in steps.iter().enumerate() {
385 if idx + 1 == steps.len() {
386 self.add_final_step(&frontier, step);
387 } else {
388 frontier = self.add_intermediate_step(&frontier, step);
389 }
390 }
391
392 Ok(())
393 }
394
395 fn into_optional_lattice(self) -> Result<Option<Lattice>, JaLatticeError> {
396 if !self.has_empty_path && !self.has_non_empty_path {
397 return Ok(None);
398 }
399 self.into_lattice().map(Some)
400 }
401
402 fn into_lattice(self) -> Result<Lattice, JaLatticeError> {
403 if !self.has_empty_path && !self.has_non_empty_path {
404 return Err(JaLatticeError::EmptyReadings);
405 }
406 if self.has_empty_path && !self.has_non_empty_path {
407 return Lattice::from_edges(1, 0, 0, Vec::new()).map_err(JaLatticeError::from);
408 }
409 if self.has_empty_path && self.has_non_empty_path {
410 return Err(JaLatticeError::Lattice(
411 LatticeError::MixedEmptyAndNonEmptyPaths,
412 ));
413 }
414
415 let end = self.next_node;
416 let arcs = self
417 .arcs
418 .into_iter()
419 .map(|arc| {
420 let src = arc.src;
421 let symbol = arc.symbol;
422 let dst = arc.dst;
423 let dst = if dst == END_NODE { end } else { dst };
424 Arc::new(src, dst, symbol)
425 })
426 .collect::<Vec<_>>();
427 Lattice::from_edges(end + 1, 0, end, arcs).map_err(JaLatticeError::from)
428 }
429
430 fn add_intermediate_step(
431 &mut self,
432 frontier: &[FrontierState],
433 step: &RomajiStep,
434 ) -> Vec<FrontierState> {
435 let transitions = self.transitions(frontier, step);
436 let intermediate_count = transitions
437 .iter()
438 .map(|transition| transition.symbols.len().saturating_sub(1))
439 .sum::<usize>();
440 let target_vowels = transitions
441 .iter()
442 .map(|transition| transition.last_vowel)
443 .collect::<BTreeSet<_>>();
444 let target_base = self.next_node + intermediate_count;
445 let target_nodes = target_vowels
446 .iter()
447 .enumerate()
448 .map(|(idx, &last_vowel)| (last_vowel, target_base + idx))
449 .collect::<BTreeMap<_, _>>();
450
451 let mut intermediate = self.next_node;
452 for transition in transitions {
453 let dst = target_nodes[&transition.last_vowel];
454 self.add_symbol_path(transition.src, dst, &transition.symbols, &mut intermediate);
455 }
456
457 self.next_node = target_base + target_vowels.len();
458 target_vowels
459 .iter()
460 .map(|last_vowel| FrontierState {
461 node: target_nodes[last_vowel],
462 last_vowel: *last_vowel,
463 })
464 .collect()
465 }
466
467 fn add_final_step(&mut self, frontier: &[FrontierState], step: &RomajiStep) {
468 let transitions = self.transitions(frontier, step);
469 let mut intermediate = self.next_node;
470 for transition in transitions {
471 self.add_symbol_path(
472 transition.src,
473 END_NODE,
474 &transition.symbols,
475 &mut intermediate,
476 );
477 }
478 self.next_node = intermediate;
479 }
480
481 fn transitions(&self, frontier: &[FrontierState], step: &RomajiStep) -> Vec<RomajiTransition> {
482 let mut transitions = Vec::new();
483 for state in frontier {
484 match step {
485 RomajiStep::Variants(variants) => {
486 for variant in variants {
487 transitions.push(RomajiTransition::new(
488 state.node,
489 state.last_vowel,
490 variant,
491 ));
492 }
493 }
494 RomajiStep::LongVowel => {
495 for suffix in long_vowel_suffixes_for_last_vowel(state.last_vowel) {
496 transitions.push(RomajiTransition::new(
497 state.node,
498 state.last_vowel,
499 suffix,
500 ));
501 }
502 }
503 }
504 }
505 transitions
506 }
507
508 fn add_symbol_path(
509 &mut self,
510 src: usize,
511 dst: usize,
512 symbols: &[Symbol],
513 next_intermediate: &mut usize,
514 ) {
515 debug_assert!(!symbols.is_empty());
516
517 let mut current = src;
518 for (idx, &symbol) in symbols.iter().enumerate() {
519 let arc_dst = if idx + 1 == symbols.len() {
520 dst
521 } else {
522 let node = *next_intermediate;
523 *next_intermediate += 1;
524 node
525 };
526 if self.seen_arcs.insert((current, arc_dst, symbol)) {
527 self.arcs.push(Arc::new(current, arc_dst, symbol));
528 }
529 current = arc_dst;
530 }
531 }
532}
533
534impl RomajiTransition {
535 fn new(src: usize, last_vowel: LastVowel, variant: &str) -> Self {
536 let symbols = variant.chars().map(|ch| ch as Symbol).collect::<Vec<_>>();
537 let last_vowel = symbols.iter().fold(last_vowel, |current, &symbol| {
538 last_vowel_after(current, symbol)
539 });
540 Self {
541 src,
542 symbols,
543 last_vowel,
544 }
545 }
546}
547
548fn segment(input: &str, mode: RomajiSegmentMode) -> Result<Vec<Unit>, JaLatticeError> {
549 let chars = input.chars().collect::<Vec<_>>();
550 let mut units = Vec::new();
551 let mut i = 0;
552
553 while i < chars.len() {
554 let ch = chars[i];
555 if let Some(normalized) = normalize_whitespace_char(ch) {
556 units.push(Unit::Ascii(normalized));
557 i += 1;
558 continue;
559 }
560
561 if ch.is_ascii() {
562 units.push(Unit::Ascii(ch));
563 i += 1;
564 continue;
565 }
566
567 if mode == RomajiSegmentMode::Surface && is_neutral_literal(ch) {
568 units.push(Unit::NeutralLiteral(ch));
569 i += 1;
570 continue;
571 }
572
573 let normalized = normalize_kana_char(ch);
574 if !is_kana(normalized) {
575 return Err(JaLatticeError::UnsupportedChar { ch, index: i });
576 }
577
578 if i + 1 < chars.len() {
579 let next = normalize_kana_char(chars[i + 1]);
580 let pair = [normalized, next].iter().collect::<String>();
581 if variants_for(&pair).is_some() {
582 units.push(Unit::Kana(pair));
583 i += 2;
584 continue;
585 }
586 }
587
588 units.push(Unit::Kana(normalized.to_string()));
589 i += 1;
590 }
591
592 Ok(units)
593}
594
595fn normalize_whitespace_char(ch: char) -> Option<char> {
596 ch.is_whitespace().then_some(' ')
597}
598
599fn is_neutral_literal(ch: char) -> bool {
600 is_fullwidth_ascii_punctuation(ch)
601 || matches!(
602 ch,
603 '\u{00b7}'
604 | '\u{2010}'..='\u{2015}'
605 | '\u{2018}'..='\u{201f}'
606 | '\u{2026}'
607 | '\u{3001}'..='\u{3002}'
608 | '\u{3008}'..='\u{3011}'
609 | '\u{3014}'..='\u{301f}'
610 | '\u{3030}'
611 | '\u{30fb}'
612 )
613}
614
615fn is_fullwidth_ascii_punctuation(ch: char) -> bool {
616 matches!(
617 ch,
618 '\u{ff01}'..='\u{ff0f}'
619 | '\u{ff1a}'..='\u{ff20}'
620 | '\u{ff3b}'..='\u{ff40}'
621 | '\u{ff5b}'..='\u{ff65}'
622 )
623}
624
625pub fn romaji_paths(input: &str) -> Result<Vec<String>, JaLatticeError> {
631 romaji_paths_with_limits(input, RomajiExpansionLimits::default())
632}
633
634pub fn romaji_paths_with_limits(
636 input: &str,
637 limits: RomajiExpansionLimits,
638) -> Result<Vec<String>, JaLatticeError> {
639 check_expansion_limit(
640 RomajiExpansionLimit::InputChars,
641 input.chars().count(),
642 limits.max_input_chars,
643 )?;
644 let units = segment(input, RomajiSegmentMode::Surface)?;
645 romaji_paths_from_units(&units, limits)
646}
647
648fn validate_romaji_units(units: &[Unit]) -> Result<(), JaLatticeError> {
649 let mut i = 0;
650
651 while i < units.len() {
652 if matches!(&units[i], Unit::Kana(unit) if unit == "ー") {
653 i += 1;
654 continue;
655 }
656
657 if units
658 .get(i + 1)
659 .is_some_and(|next| can_combine_ascii_small_kana(&units[i], next))
660 {
661 i += 2;
662 continue;
663 }
664
665 if matches!(&units[i], Unit::Kana(unit) if unit == "っ") {
666 if let Some(next) = units.get(i + 1) {
667 validate_variants_for_unit(next)?;
668 }
669 }
670
671 validate_variants_for_unit(&units[i])?;
672 i += 1;
673 }
674
675 Ok(())
676}
677
678fn validate_variants_for_unit(unit: &Unit) -> Result<(), JaLatticeError> {
679 match unit {
680 Unit::Ascii(_) | Unit::NeutralLiteral(_) => Ok(()),
681 Unit::Kana(unit) => variants_for(unit)
682 .map(|_| ())
683 .ok_or_else(|| JaLatticeError::MissingVariant { unit: unit.clone() }),
684 }
685}
686
687fn romaji_steps_from_units(units: &[Unit]) -> Result<Vec<RomajiStep>, JaLatticeError> {
688 let mut steps = Vec::new();
689 let mut i = 0;
690
691 while i < units.len() {
692 if matches!(&units[i], Unit::Kana(unit) if unit == "ー") {
693 steps.push(RomajiStep::LongVowel);
694 i += 1;
695 continue;
696 }
697
698 let mut consumed_units = 1;
699 let variants = if let Some(variants) = units
700 .get(i + 1)
701 .and_then(|next| ascii_small_kana_variants(&units[i], next))
702 {
703 consumed_units = 2;
704 variants
705 } else if matches!(&units[i], Unit::Kana(unit) if unit == "っ") {
706 sokuon_variants(units.get(i + 1))?
707 } else {
708 variants_for_unit(&units[i])?
709 };
710
711 steps.push(RomajiStep::Variants(unique_variants(variants)));
712 i += consumed_units;
713 }
714
715 Ok(steps)
716}
717
718fn romaji_paths_from_units(
719 units: &[Unit],
720 limits: RomajiExpansionLimits,
721) -> Result<Vec<String>, JaLatticeError> {
722 let mut paths = vec![String::new()];
723
724 for step in romaji_steps_from_units(units)? {
725 paths = match step {
726 RomajiStep::LongVowel => append_contextual_long_vowel(paths, limits)?,
727 RomajiStep::Variants(variants) => append_path_variants(paths, &variants, limits)?,
728 };
729 }
730
731 Ok(paths)
732}
733
734fn append_path_variants(
735 paths: Vec<String>,
736 variants: &[String],
737 limits: RomajiExpansionLimits,
738) -> Result<Vec<String>, JaLatticeError> {
739 let next_path_count = checked_mul_limit(
740 paths.len(),
741 variants.len(),
742 RomajiExpansionLimit::ExpandedPaths,
743 limits.max_expanded_paths,
744 )?;
745 check_expansion_limit(
746 RomajiExpansionLimit::ExpandedPaths,
747 next_path_count,
748 limits.max_expanded_paths,
749 )?;
750
751 let mut next_paths = Vec::with_capacity(next_path_count);
752 let mut total_symbols = 0usize;
753 for prefix in &paths {
754 let prefix_symbols = prefix.chars().count();
755 for variant in variants {
756 let path_symbols = prefix_symbols.saturating_add(variant.chars().count());
757 total_symbols = checked_add_limit(
758 total_symbols,
759 path_symbols,
760 RomajiExpansionLimit::TotalSymbols,
761 limits.max_total_symbols,
762 )?;
763 check_expansion_limit(
764 RomajiExpansionLimit::TotalSymbols,
765 total_symbols,
766 limits.max_total_symbols,
767 )?;
768
769 let mut path = String::with_capacity(prefix.len() + variant.len());
770 path.push_str(prefix);
771 path.push_str(variant);
772 next_paths.push(path);
773 }
774 }
775 Ok(next_paths)
776}
777
778fn variants_for_unit(unit: &Unit) -> Result<Vec<String>, JaLatticeError> {
779 match unit {
780 Unit::Ascii(ch) | Unit::NeutralLiteral(ch) => Ok(vec![ch.to_string()]),
781 Unit::Kana(unit) => variants_for(unit)
782 .ok_or_else(|| JaLatticeError::MissingVariant { unit: unit.clone() })
783 .map(to_owned_variants),
784 }
785}
786
787fn sokuon_variants(next: Option<&Unit>) -> Result<Vec<String>, JaLatticeError> {
788 let mut variants = variants_for("っ")
789 .expect("small tsu must have explicit variants")
790 .iter()
791 .map(|variant| (*variant).to_string())
792 .collect::<Vec<_>>();
793
794 if let Some(next) = next {
795 for next_variant in variants_for_unit(next)? {
796 if let Some(prefix) = geminate_prefix(&next_variant) {
797 push_unique(&mut variants, prefix.to_string());
798 }
799 }
800 }
801
802 Ok(variants)
803}
804
805fn ascii_small_kana_variants(current: &Unit, next: &Unit) -> Option<Vec<String>> {
806 if !can_combine_ascii_small_kana(current, next) {
807 return None;
808 }
809
810 let Unit::Ascii(ch) = current else {
811 unreachable!("can_combine_ascii_small_kana requires an ASCII current unit");
812 };
813 let Unit::Kana(kana) = next else {
814 unreachable!("can_combine_ascii_small_kana requires a kana next unit");
815 };
816
817 let suffix = small_kana_ascii_suffix(kana)
818 .expect("can_combine_ascii_small_kana requires a supported small kana");
819
820 let mut variants = vec![format!("{ch}{suffix}")];
821 for small_kana_variant in variants_for_unit(next).ok()? {
822 variants.push(format!("{ch}{small_kana_variant}"));
823 }
824 Some(variants)
825}
826
827fn can_combine_ascii_small_kana(current: &Unit, next: &Unit) -> bool {
828 let Unit::Ascii(ch) = current else {
829 return false;
830 };
831 let Unit::Kana(kana) = next else {
832 return false;
833 };
834
835 is_ascii_consonant(*ch) && small_kana_ascii_suffix(kana).is_some()
836}
837
838fn small_kana_ascii_suffix(kana: &str) -> Option<&'static str> {
839 Some(match kana {
840 "ゃ" => "ya",
841 "ゅ" => "yu",
842 "ょ" => "yo",
843 _ => return None,
844 })
845}
846
847fn is_ascii_consonant(ch: char) -> bool {
848 ch.is_ascii_alphabetic() && !matches!(ch.to_ascii_lowercase(), 'a' | 'i' | 'u' | 'e' | 'o')
849}
850
851fn geminate_prefix(variant: &str) -> Option<char> {
852 let first = variant.chars().next()?;
853 if matches!(first, 'a' | 'i' | 'u' | 'e' | 'o' | 'n' | '-') {
854 None
855 } else {
856 Some(first)
857 }
858}
859
860fn append_contextual_long_vowel(
861 paths: Vec<String>,
862 limits: RomajiExpansionLimits,
863) -> Result<Vec<String>, JaLatticeError> {
864 let mut next_paths = Vec::new();
865 let mut total_symbols = 0usize;
866 for prefix in paths {
867 for suffix in long_vowel_suffixes(&prefix) {
868 check_expansion_limit(
869 RomajiExpansionLimit::ExpandedPaths,
870 next_paths.len().saturating_add(1),
871 limits.max_expanded_paths,
872 )?;
873 let path_symbols = prefix
874 .chars()
875 .count()
876 .saturating_add(suffix.chars().count());
877 total_symbols = checked_add_limit(
878 total_symbols,
879 path_symbols,
880 RomajiExpansionLimit::TotalSymbols,
881 limits.max_total_symbols,
882 )?;
883 check_expansion_limit(
884 RomajiExpansionLimit::TotalSymbols,
885 total_symbols,
886 limits.max_total_symbols,
887 )?;
888
889 let mut path = String::with_capacity(prefix.len() + suffix.len());
890 path.push_str(&prefix);
891 path.push_str(suffix);
892 next_paths.push(path);
893 }
894 }
895 Ok(next_paths)
896}
897
898fn long_vowel_suffixes(prefix: &str) -> &'static [&'static str] {
899 match last_vowel(prefix) {
900 Some('a') => &["a", "-"],
901 Some('i') => &["i", "-"],
902 Some('u') => &["u", "-"],
903 Some('e') => &["e", "i", "-"],
904 Some('o') => &["o", "u", "-"],
905 _ => &["-"],
906 }
907}
908
909fn long_vowel_suffixes_for_last_vowel(last_vowel: LastVowel) -> &'static [&'static str] {
910 match last_vowel {
911 LastVowel::A => &["a", "-"],
912 LastVowel::I => &["i", "-"],
913 LastVowel::U => &["u", "-"],
914 LastVowel::E => &["e", "i", "-"],
915 LastVowel::O => &["o", "u", "-"],
916 LastVowel::None => &["-"],
917 }
918}
919
920fn last_vowel(path: &str) -> Option<char> {
921 path.chars()
922 .rev()
923 .find(|ch| matches!(ch, 'a' | 'i' | 'u' | 'e' | 'o'))
924}
925
926fn to_owned_variants(variants: &'static [&'static str]) -> Vec<String> {
927 variants
928 .iter()
929 .map(|variant| (*variant).to_string())
930 .collect()
931}
932
933fn unique_variants(variants: Vec<String>) -> Vec<String> {
934 let mut unique = Vec::with_capacity(variants.len());
935 for variant in variants {
936 push_unique(&mut unique, variant);
937 }
938 unique
939}
940
941fn push_unique(values: &mut Vec<String>, value: String) {
942 if !values.iter().any(|existing| existing == &value) {
943 values.push(value);
944 }
945}
946
947fn last_vowel_after(current: LastVowel, symbol: Symbol) -> LastVowel {
948 match char::from_u32(symbol) {
949 Some('a') => LastVowel::A,
950 Some('i') => LastVowel::I,
951 Some('u') => LastVowel::U,
952 Some('e') => LastVowel::E,
953 Some('o') => LastVowel::O,
954 _ => current,
955 }
956}
957
958fn extend_expanded_paths(
959 paths: &mut Vec<String>,
960 expanded: Vec<String>,
961 limits: RomajiExpansionLimits,
962) -> Result<(), JaLatticeError> {
963 let path_count = checked_add_limit(
964 paths.len(),
965 expanded.len(),
966 RomajiExpansionLimit::ExpandedPaths,
967 limits.max_expanded_paths,
968 )?;
969 check_expansion_limit(
970 RomajiExpansionLimit::ExpandedPaths,
971 path_count,
972 limits.max_expanded_paths,
973 )?;
974
975 let current_symbols = total_path_symbols(paths);
976 let expanded_symbols = total_path_symbols(&expanded);
977 let total_symbols = checked_add_limit(
978 current_symbols,
979 expanded_symbols,
980 RomajiExpansionLimit::TotalSymbols,
981 limits.max_total_symbols,
982 )?;
983 check_expansion_limit(
984 RomajiExpansionLimit::TotalSymbols,
985 total_symbols,
986 limits.max_total_symbols,
987 )?;
988
989 paths.extend(expanded);
990 Ok(())
991}
992
993fn total_path_symbols(paths: &[String]) -> usize {
994 paths.iter().map(|path| path.chars().count()).sum()
995}
996
997fn check_expansion_limit(
998 limit: RomajiExpansionLimit,
999 value: usize,
1000 max: usize,
1001) -> Result<(), JaLatticeError> {
1002 if value > max {
1003 Err(JaLatticeError::ExpansionLimitExceeded { limit, value, max })
1004 } else {
1005 Ok(())
1006 }
1007}
1008
1009fn checked_add_limit(
1010 left: usize,
1011 right: usize,
1012 limit: RomajiExpansionLimit,
1013 max: usize,
1014) -> Result<usize, JaLatticeError> {
1015 left.checked_add(right)
1016 .ok_or(JaLatticeError::ExpansionLimitExceeded {
1017 limit,
1018 value: usize::MAX,
1019 max,
1020 })
1021}
1022
1023fn checked_mul_limit(
1024 left: usize,
1025 right: usize,
1026 limit: RomajiExpansionLimit,
1027 max: usize,
1028) -> Result<usize, JaLatticeError> {
1029 left.checked_mul(right)
1030 .ok_or(JaLatticeError::ExpansionLimitExceeded {
1031 limit,
1032 value: usize::MAX,
1033 max,
1034 })
1035}
1036
1037const ROMAJI_VARIANTS: &[(&str, &[&str])] = &[
1038 ("あ", &["a"]),
1039 ("い", &["i"]),
1040 ("う", &["u"]),
1041 ("え", &["e"]),
1042 ("お", &["o"]),
1043 ("か", &["ka"]),
1044 ("き", &["ki"]),
1045 ("く", &["ku"]),
1046 ("け", &["ke"]),
1047 ("こ", &["ko"]),
1048 ("さ", &["sa"]),
1049 ("し", &["si", "shi", "ci"]),
1050 ("す", &["su"]),
1051 ("せ", &["se"]),
1052 ("そ", &["so"]),
1053 ("た", &["ta"]),
1054 ("ち", &["ti", "chi"]),
1055 ("つ", &["tu", "tsu"]),
1056 ("て", &["te"]),
1057 ("と", &["to"]),
1058 ("な", &["na"]),
1059 ("に", &["ni"]),
1060 ("ぬ", &["nu"]),
1061 ("ね", &["ne"]),
1062 ("の", &["no"]),
1063 ("は", &["ha"]),
1064 ("ひ", &["hi"]),
1065 ("ふ", &["hu", "fu"]),
1066 ("へ", &["he"]),
1067 ("ほ", &["ho"]),
1068 ("ま", &["ma"]),
1069 ("み", &["mi"]),
1070 ("む", &["mu"]),
1071 ("め", &["me"]),
1072 ("も", &["mo"]),
1073 ("や", &["ya"]),
1074 ("ゆ", &["yu"]),
1075 ("よ", &["yo"]),
1076 ("ら", &["ra"]),
1077 ("り", &["ri"]),
1078 ("る", &["ru"]),
1079 ("れ", &["re"]),
1080 ("ろ", &["ro"]),
1081 ("わ", &["wa"]),
1082 ("を", &["wo", "o"]),
1083 ("ん", &["n", "nn", "m"]),
1084 ("が", &["ga"]),
1085 ("ぎ", &["gi"]),
1086 ("ぐ", &["gu"]),
1087 ("げ", &["ge"]),
1088 ("ご", &["go"]),
1089 ("ざ", &["za"]),
1090 ("じ", &["zi", "ji"]),
1091 ("ず", &["zu"]),
1092 ("ぜ", &["ze"]),
1093 ("ぞ", &["zo"]),
1094 ("だ", &["da"]),
1095 ("ぢ", &["di", "ji"]),
1096 ("づ", &["du", "zu"]),
1097 ("で", &["de"]),
1098 ("ど", &["do"]),
1099 ("ば", &["ba"]),
1100 ("び", &["bi"]),
1101 ("ぶ", &["bu"]),
1102 ("べ", &["be"]),
1103 ("ぼ", &["bo"]),
1104 ("ぱ", &["pa"]),
1105 ("ぴ", &["pi"]),
1106 ("ぷ", &["pu"]),
1107 ("ぺ", &["pe"]),
1108 ("ぽ", &["po"]),
1109 ("ゔ", &["vu"]),
1110 ("ぁ", &["xa", "la"]),
1111 ("ぃ", &["xi", "li"]),
1112 ("ぅ", &["xu", "lu"]),
1113 ("ぇ", &["xe", "le"]),
1114 ("ぉ", &["xo", "lo"]),
1115 ("ゃ", &["xya", "lya"]),
1116 ("ゅ", &["xyu", "lyu"]),
1117 ("ょ", &["xyo", "lyo"]),
1118 ("っ", &["xtu", "ltu", "ttu"]),
1119 ("ー", &["-"]),
1120 ("きゃ", &["kya"]),
1121 ("きゅ", &["kyu"]),
1122 ("きょ", &["kyo"]),
1123 ("しゃ", &["sya", "sha", "cya"]),
1124 ("しゅ", &["syu", "shu", "cyu"]),
1125 ("しょ", &["syo", "sho", "cyo"]),
1126 ("ちゃ", &["tya", "cha", "cya"]),
1127 ("ちゅ", &["tyu", "chu", "cyu"]),
1128 ("ちょ", &["tyo", "cho", "cyo"]),
1129 ("にゃ", &["nya"]),
1130 ("にゅ", &["nyu"]),
1131 ("にょ", &["nyo"]),
1132 ("ひゃ", &["hya"]),
1133 ("ひゅ", &["hyu"]),
1134 ("ひょ", &["hyo"]),
1135 ("みゃ", &["mya"]),
1136 ("みゅ", &["myu"]),
1137 ("みょ", &["myo"]),
1138 ("りゃ", &["rya"]),
1139 ("りゅ", &["ryu"]),
1140 ("りょ", &["ryo"]),
1141 ("ぎゃ", &["gya"]),
1142 ("ぎゅ", &["gyu"]),
1143 ("ぎょ", &["gyo"]),
1144 ("じゃ", &["zya", "ja", "jya"]),
1145 ("じゅ", &["zyu", "ju", "jyu"]),
1146 ("じょ", &["zyo", "jo", "jyo"]),
1147 ("びゃ", &["bya"]),
1148 ("びゅ", &["byu"]),
1149 ("びょ", &["byo"]),
1150 ("ぴゃ", &["pya"]),
1151 ("ぴゅ", &["pyu"]),
1152 ("ぴょ", &["pyo"]),
1153];
1154
1155fn variants_for(unit: &str) -> Option<&'static [&'static str]> {
1156 ROMAJI_VARIANTS
1157 .iter()
1158 .find_map(|&(key, variants)| (key == unit).then_some(variants))
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use std::collections::BTreeSet;
1164
1165 use moine_core::{distance, distance_with_trace, Lattice, Symbol};
1166
1167 use super::*;
1168
1169 fn symbols_to_string(symbols: &[Symbol]) -> String {
1170 symbols
1171 .iter()
1172 .map(|&symbol| char::from_u32(symbol).expect("test symbol should be a char"))
1173 .collect()
1174 }
1175
1176 fn enumerate_lattice_paths(lattice: &Lattice) -> BTreeSet<String> {
1177 fn visit(
1178 lattice: &Lattice,
1179 node: usize,
1180 current: &mut String,
1181 paths: &mut BTreeSet<String>,
1182 ) {
1183 if node == lattice.end() {
1184 paths.insert(current.clone());
1185 return;
1186 }
1187
1188 for arc in lattice.outgoing_arcs(node) {
1189 let ch =
1190 char::from_u32(arc.symbol).expect("romaji lattice symbols should be chars");
1191 current.push(ch);
1192 visit(lattice, arc.dst, current, paths);
1193 current.pop();
1194 }
1195 }
1196
1197 let mut paths = BTreeSet::new();
1198 visit(lattice, lattice.start(), &mut String::new(), &mut paths);
1199 paths
1200 }
1201
1202 fn assert_lattice_matches_explicit_paths(input: &str) {
1203 let lattice = romaji_lattice(input).expect("test input should build a direct lattice");
1204 let expected = romaji_paths_with_limits(input, RomajiExpansionLimits::default())
1205 .expect("test input should expand within the default limits")
1206 .into_iter()
1207 .collect::<BTreeSet<_>>();
1208 assert_eq!(
1209 enumerate_lattice_paths(&lattice),
1210 expected,
1211 "input {input:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn ascii_is_identity_path() {
1217 let lattice = romaji_lattice("chadougu").expect("ascii should build");
1218 let trace = distance_with_trace(&lattice, &Lattice::from_paths(["chadougu"]));
1219
1220 assert_eq!(trace.distance, 0);
1221 assert_eq!(symbols_to_string(&trace.left_symbols()), "chadougu");
1222 }
1223
1224 #[test]
1225 fn direct_lattice_matches_explicit_paths_for_whisky_inputs() {
1226 let units = ["シェ", "リ", "ン", "チャ", "ッ", "ー", "P", "X"];
1227 for left in units {
1228 assert_lattice_matches_explicit_paths(left);
1229 for right in units {
1230 assert_lattice_matches_explicit_paths(&format!("{left}{right}"));
1231 }
1232 }
1233
1234 for input in [
1235 "シェリー",
1236 "チャー",
1237 "スコッチ",
1238 "ウイスキー",
1239 "ピーテッド",
1240 "PXシェリー",
1241 ] {
1242 assert_lattice_matches_explicit_paths(input);
1243 }
1244 }
1245
1246 #[test]
1247 fn katakana_and_hiragana_share_romaji_lattice() {
1248 let hira = romaji_lattice("ちゃ").expect("hiragana should build");
1249 let kata = romaji_lattice("チャ").expect("katakana should build");
1250
1251 assert_eq!(distance(&hira, &kata), 0);
1252 assert_eq!(distance(&hira, &Lattice::from_paths(["cha"])), 0);
1253 assert_eq!(distance(&hira, &Lattice::from_paths(["tya"])), 0);
1254 assert_eq!(distance(&hira, &Lattice::from_paths(["cya"])), 0);
1255 }
1256
1257 #[test]
1258 fn variants_make_si_shi_ci_equivalent_for_shi() {
1259 let lattice = romaji_lattice("し").expect("shi should build");
1260
1261 assert_eq!(distance(&lattice, &Lattice::from_paths(["si"])), 0);
1262 assert_eq!(distance(&lattice, &Lattice::from_paths(["shi"])), 0);
1263 assert_eq!(distance(&lattice, &Lattice::from_paths(["ci"])), 0);
1264 }
1265
1266 #[test]
1267 fn kana_and_ascii_can_mix() {
1268 let lattice = romaji_lattice("いんさt").expect("mixed input should build");
1269 let trace = distance_with_trace(&lattice, &Lattice::from_paths(["insat"]));
1270
1271 assert_eq!(trace.distance, 0);
1272 assert_eq!(symbols_to_string(&trace.left_symbols()), "insat");
1273 }
1274
1275 #[test]
1276 fn unicode_whitespace_normalizes_to_ascii_space() {
1277 for whitespace in [' ', '\u{00a0}', '\u{2003}', '\u{2009}', '\u{3000}'] {
1278 let left = format!("ピーテッド{whitespace}ウイスキー");
1279 let right = format!("ぴーてっど{whitespace}ういすきー");
1280 let left_lattice = romaji_lattice(&left).expect("unicode whitespace should build");
1281 let right_lattice = romaji_lattice(&right).expect("unicode whitespace should build");
1282
1283 assert_eq!(distance(&left_lattice, &right_lattice), 0);
1284 }
1285 }
1286
1287 #[test]
1288 fn surface_neutral_literals_are_kept_as_literal_symbols() {
1289 let left = romaji_lattice("はい,「です」。").expect("punctuated kana should build");
1290 let right = Lattice::from_paths(["hai,「desu」。"]);
1291
1292 assert_eq!(distance(&left, &right), 0);
1293 }
1294
1295 #[test]
1296 fn middle_dot_readings_stay_out_of_strict_romaji_reading_validation() {
1297 assert!(can_build_direct_romaji_path("ジョニー・ウォーカー"));
1301 assert!(!can_build_romaji_reading("ジョニー・ウォーカー"));
1302 }
1303
1304 #[test]
1305 fn ascii_consonant_can_combine_with_small_kana() {
1306 let lattice = romaji_lattice("kょう").expect("mixed small kana input should build");
1307
1308 assert_eq!(distance(&lattice, &Lattice::from_paths(["kyou"])), 0);
1309 assert_eq!(distance(&lattice, &Lattice::from_paths(["kxyou"])), 0);
1310 }
1311
1312 #[test]
1313 fn sokuon_adds_next_consonant_prefix() {
1314 let lattice = romaji_lattice("まっちゃ").expect("sokuon input should build");
1315
1316 assert_eq!(distance(&lattice, &Lattice::from_paths(["maccha"])), 0);
1317 assert_eq!(distance(&lattice, &Lattice::from_paths(["mattya"])), 0);
1318 assert_eq!(distance(&lattice, &Lattice::from_paths(["maxtucha"])), 0);
1319 }
1320
1321 #[test]
1322 fn long_vowel_mark_adds_contextual_vowels() {
1323 let lattice = romaji_lattice("チャドーグ").expect("long vowel input should build");
1324
1325 assert_eq!(distance(&lattice, &Lattice::from_paths(["chadougu"])), 0);
1326 assert_eq!(distance(&lattice, &Lattice::from_paths(["chadoogu"])), 0);
1327 assert_eq!(distance(&lattice, &Lattice::from_paths(["chado-gu"])), 0);
1328 }
1329
1330 #[test]
1331 fn repeated_variant_lattice_does_not_enumerate_all_paths() {
1332 let input = "キー".repeat(32);
1333 let lattice = romaji_lattice(&input).expect("repeated variant input should build");
1334
1335 assert!(lattice.node_count() < 512);
1336 assert!(lattice.arcs().len() < 1024);
1337 assert!(matches!(
1338 romaji_paths(&input),
1339 Err(JaLatticeError::ExpansionLimitExceeded { .. })
1340 ));
1341 }
1342
1343 #[test]
1344 fn explicit_romaji_path_expansion_reports_limits() {
1345 let input_limit = RomajiExpansionLimits {
1346 max_input_chars: 1,
1347 ..RomajiExpansionLimits::default()
1348 };
1349 assert!(matches!(
1350 romaji_paths_with_limits("ちゃ", input_limit),
1351 Err(JaLatticeError::ExpansionLimitExceeded {
1352 limit: RomajiExpansionLimit::InputChars,
1353 ..
1354 })
1355 ));
1356
1357 let path_limit = RomajiExpansionLimits {
1358 max_expanded_paths: 2,
1359 ..RomajiExpansionLimits::default()
1360 };
1361 assert!(matches!(
1362 romaji_paths_with_limits("ちゃ", path_limit),
1363 Err(JaLatticeError::ExpansionLimitExceeded {
1364 limit: RomajiExpansionLimit::ExpandedPaths,
1365 ..
1366 })
1367 ));
1368
1369 assert_eq!(
1370 romaji_paths_with_limits("キー", path_limit).unwrap(),
1371 vec!["kii", "ki-"]
1372 );
1373 }
1374
1375 #[test]
1376 #[ignore]
1377 fn romaji_lattice_expansion_shape_smoke_benchmark() {
1378 use std::time::Instant;
1379
1380 for input in ["キー".repeat(32), "ちゃ".repeat(24)] {
1381 let start = Instant::now();
1382 let lattice = romaji_lattice(&input).expect("benchmark input should build");
1383 let elapsed = start.elapsed();
1384 eprintln!(
1385 "input_chars={} nodes={} arcs={} build={elapsed:?}",
1386 input.chars().count(),
1387 lattice.node_count(),
1388 lattice.arcs().len(),
1389 );
1390
1391 assert!(lattice.node_count() < 1024);
1392 assert!(lattice.arcs().len() < 2048);
1393 }
1394 }
1395
1396 #[test]
1397 fn support_check_does_not_expand_all_romaji_paths() {
1398 let input = "キー".repeat(32);
1399
1400 assert!(can_build_direct_romaji_path(&input));
1401 assert!(can_build_romaji_reading(&input));
1402 }
1403
1404 #[test]
1405 fn unsupported_kanji_errors_until_dictionary_support_exists() {
1406 let result = romaji_lattice("印刷");
1407
1408 assert!(matches!(
1409 result,
1410 Err(JaLatticeError::UnsupportedChar {
1411 ch: '印', index: 0
1412 })
1413 ));
1414 }
1415
1416 #[test]
1417 fn default_table_contains_issue_two_variants() {
1418 let table = RomajiVariantTable;
1419
1420 assert_eq!(table.variants("ん"), Some(&["n", "nn", "m"][..]));
1421 assert_eq!(table.variants("つ"), Some(&["tu", "tsu"][..]));
1422 assert_eq!(table.variants("ちゃ"), Some(&["tya", "cha", "cya"][..]));
1423 }
1424}