Skip to main content

moine_ja/
romaji.rs

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/// Kana-to-romaji variant table used by the Japanese adapter.
10#[derive(Clone, Copy, Debug, Default)]
11pub struct RomajiVariantTable;
12
13impl RomajiVariantTable {
14    /// Returns the accepted romaji variants for a kana unit.
15    pub fn variants(&self, unit: &str) -> Option<&'static [&'static str]> {
16        variants_for(unit)
17    }
18}
19
20/// Limits used when explicitly expanding romaji candidates into strings.
21///
22/// Lattice-building APIs keep variants in a DAG and do not use these limits.
23/// These limits only guard APIs that must materialize every accepted romaji
24/// candidate as a `String`.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct RomajiExpansionLimits {
27    /// Maximum input characters accepted by explicit expansion APIs.
28    pub max_input_chars: usize,
29    /// Maximum complete romaji paths returned by explicit expansion APIs.
30    pub max_expanded_paths: usize,
31    /// Maximum total Unicode scalar values across all expanded romaji paths.
32    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/// Explicit romaji expansion limit that was exceeded.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum RomajiExpansionLimit {
48    /// The input character count exceeded `max_input_chars`.
49    InputChars,
50    /// The complete candidate path count exceeded `max_expanded_paths`.
51    ExpandedPaths,
52    /// The total output symbol count exceeded `max_total_symbols`.
53    TotalSymbols,
54}
55
56/// Errors returned while building Japanese romaji lattices.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum JaLatticeError {
59    /// The input contains a character that is neither ASCII nor supported kana.
60    UnsupportedChar {
61        /// Unsupported character.
62        ch: char,
63        /// Character offset in the input.
64        index: usize,
65    },
66    /// No romaji mapping exists for a segmented kana unit.
67    MissingVariant {
68        /// Kana unit with no mapping.
69        unit: String,
70    },
71    /// No readings were provided when constructing a lattice from readings.
72    EmptyReadings,
73    /// The underlying lattice shape was invalid.
74    Lattice(LatticeError),
75    /// Distance computation exceeded the configured matrix-size limit.
76    Distance(DistanceError),
77    /// A dictionary artifact payload failed while expanding readings.
78    ArtifactPayload(String),
79    /// Explicit romaji path expansion exceeded a configured limit.
80    ExpansionLimitExceeded {
81        /// Limit that was exceeded.
82        limit: RomajiExpansionLimit,
83        /// Observed value that crossed the limit.
84        value: usize,
85        /// Configured maximum value.
86        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
144/// Builds a compact romaji lattice from kana or ASCII romaji input.
145///
146/// The lattice is built directly from romaji variant fragments instead of
147/// materializing every accepted romaji string first.
148pub 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
164/// Builds a compact romaji lattice from one or more kana readings.
165///
166/// The lattice is built directly from romaji variant fragments instead of
167/// materializing every accepted romaji string first.
168pub 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    /// Builds a compact romaji lattice using this variant table.
300    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
625/// Expands kana or ASCII romaji input into explicit romaji paths.
626///
627/// This materializes every accepted romaji candidate and therefore uses
628/// [`RomajiExpansionLimits::default`] to reject inputs that would expand too
629/// broadly. Use [`romaji_paths_with_limits`] to choose a different budget.
630pub fn romaji_paths(input: &str) -> Result<Vec<String>, JaLatticeError> {
631    romaji_paths_with_limits(input, RomajiExpansionLimits::default())
632}
633
634/// Expands kana or ASCII romaji input into explicit romaji paths with limits.
635pub 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        // This is a converter policy, not a claim about dictionary validity:
1298        // Sudachi can contain readings with separators, but dictionary-reading
1299        // separators need a separate normalize/preserve/drop decision.
1300        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}