Skip to main content

hangul_cd/
word.rs

1use std::fmt::Debug;
2
3use thiserror::Error;
4
5use crate::{block::*, jamo::*};
6
7/// A composer for a single Hangul word, made up of multiple syllable blocks.
8#[derive(Error, Debug, PartialEq, Eq)]
9pub enum WordError {
10    /// Occurs when there is an error related to syllable blocks.
11    #[error("Block error: {0}")]
12    BlockError(#[from] BlockError),
13
14    /// Occurs when there is an error related to Jamo letters.
15    #[error("Jamo error: {0}")]
16    JamoError(#[from] JamoError),
17
18    /// Tried to start a new block while pushing Jamo, but it was not possible.
19    /// The reason is provided in the `BlockPushResult`.
20    #[error("Could not start new block with character '{0}'; reason: {1:?}")]
21    CouldNotStartNewBlock(char, BlockPushResult),
22
23    /// Tried popping from an empty word (no Jamo to pop).
24    #[error("Tried popping from empty word")]
25    NothingToPop,
26
27    /// Tried to complete the current block, but it only contains one Jamo.
28    #[error("Cannot complete current block; currently contains only one Jamo: {0:?}")]
29    CannotCompleteCurrentBlock(Jamo),
30}
31
32/// A composer for a single Hangul word, made up of multiple syllable blocks.
33/// The `HangulWordComposer` maintains a list of completed `HangulBlock`s and a
34/// `BlockComposer` for the current syllable block being composed.
35///
36/// **API:**
37/// ```rust
38/// use hangul_cd::word::{HangulWordComposer, WordPushResult};
39/// use hangul_cd::jamo::{Jamo, JamoConsonantSingular, JamoVowelSingular};
40///
41/// let mut composer = HangulWordComposer::new();
42///
43/// // Push characters to form Hangul syllables
44/// assert_eq!(composer.push_char('ㅇ').unwrap(), WordPushResult::Continue);
45/// assert_eq!(composer.push_char('ㅏ').unwrap(), WordPushResult::Continue);
46/// assert_eq!(composer.push_char('ㄴ').unwrap(), WordPushResult::Continue);
47/// assert_eq!(composer.push_char('ㄴ').unwrap(), WordPushResult::Continue);
48/// assert_eq!(composer.push_char('ㅕ').unwrap(), WordPushResult::Continue);
49/// assert_eq!(composer.push_char('ㅇ').unwrap(), WordPushResult::Continue);
50///
51/// // Get the composed string
52/// let result = composer.as_string().unwrap();
53/// assert_eq!(result, "안녕".to_string());
54///
55/// // Popping characters removes jamo in reverse order
56/// assert_eq!(
57///     composer.pop().unwrap(),
58///     Some(Jamo::Consonant(JamoConsonantSingular::Ieung))
59/// );
60/// assert_eq!(
61///     composer.pop().unwrap(),
62///     Some(Jamo::Vowel(JamoVowelSingular::Yeo))
63/// );
64/// assert_eq!(
65///     composer.pop().unwrap(),
66///     Some(Jamo::Consonant(JamoConsonantSingular::Nieun))
67/// );
68/// assert_eq!(composer.as_string().unwrap(), "안".to_string());
69/// ```
70#[derive(Debug)]
71pub struct HangulWordComposer {
72    prev_blocks: Vec<HangulBlock>,
73    cur_block: BlockComposer,
74}
75
76impl Default for HangulWordComposer {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82/// The result of attempting to push a character into the `HangulWordComposer`.
83#[derive(Debug, PartialEq, Eq)]
84pub enum WordPushResult {
85    /// The character was successfully pushed and composition can continue.
86    Continue,
87
88    /// The character could not be pushed because it would result in an invalid
89    /// Hangul syllable.
90    InvalidHangul,
91
92    /// The character was not pushed because it is not a Hangul character.
93    NonHangul,
94}
95
96impl HangulWordComposer {
97    /// Creates a new, empty `HangulWordComposer`.
98    pub fn new() -> Self {
99        HangulWordComposer {
100            prev_blocks: Vec::new(),
101            cur_block: BlockComposer::new(),
102        }
103    }
104
105    /// Pushes a character into the `HangulWordComposer` if valid and returns a
106    /// result indicating the outcome.
107    ///
108    /// If pushing would make a valid Hangul syllable, the new character is
109    /// appended and `WordPushResult::Continue` is returned.
110    ///
111    /// If pushing the character would result in an invalid Hangul syllable,
112    /// but the character is Hangul and can start a new syllable block, the current
113    /// block is completed, a new block is started with the pushed character,
114    /// and `WordPushResult::Continue` is returned.
115    ///
116    /// If the character is Hangul but cannot form a valid syllable in either
117    /// the current or a new block, `WordPushResult::InvalidHangul` is returned.
118    ///
119    /// If the character is not Hangul, `WordPushResult::NonHangul` is returned.
120    pub fn push_char(&mut self, c: char) -> Result<WordPushResult, WordError> {
121        match Character::from_char(c)? {
122            Character::Hangul(jamo) => self.push(&jamo),
123            Character::NonHangul(_) => Ok(WordPushResult::NonHangul),
124        }
125    }
126
127    /// Pushes a Jamo letter into the `HangulWordComposer`. Acts the same as
128    /// `push_char`, but takes a `Jamo` instead of a `char`.
129    /// Pushing appends to the current syllable block if that would make a
130    /// valid Hangul syllable; otherwise, it completes the current block and
131    /// creates a new block with the pushed character.
132    pub fn push(&mut self, letter: &Jamo) -> Result<WordPushResult, WordError> {
133        match self.cur_block.push(letter) {
134            BlockPushResult::Success => Ok(WordPushResult::Continue),
135            BlockPushResult::InvalidHangul => Ok(WordPushResult::InvalidHangul),
136            BlockPushResult::NonHangul => Ok(WordPushResult::NonHangul),
137            BlockPushResult::StartNewBlockNoPop => match self.start_new_block(letter.clone()) {
138                Ok(_) => Ok(WordPushResult::Continue),
139                Err(e) => Err(e),
140            },
141            BlockPushResult::PopAndStartNewBlock => {
142                match self.pop_and_start_new_block(letter.clone()) {
143                    Ok(_) => Ok(WordPushResult::Continue),
144                    Err(e) => Err(e),
145                }
146            }
147        }
148    }
149
150    /// Pops the last Jamo letter from the `HangulWordComposer`.
151    /// If the current syllable block has letters, it will remove the last
152    /// letter from it. If the current block is empty, it will set the last
153    /// completed block as the currently-active block and remove one Jamo
154    /// from it if possible.
155    ///
156    /// Returns `Ok(Some(Jamo))` if a letter was successfully removed,
157    /// `Ok(None)` if there are no letters to remove, or `Err(String)` if an
158    /// error occurred during the operation.
159    pub fn pop(&mut self) -> Result<Option<Jamo>, WordError> {
160        match self.cur_block.pop() {
161            BlockPopStatus::PoppedAndNonEmpty(l) => Ok(Some(l)),
162            BlockPopStatus::PoppedAndEmpty(l) => {
163                self.prev_block_to_cur()?;
164                Ok(Some(l))
165            }
166            BlockPopStatus::None => {
167                self.prev_block_to_cur()?;
168                Ok(None)
169            }
170        }
171    }
172
173    fn prev_block_to_cur(&mut self) -> Result<(), WordError> {
174        if let Some(last_block) = self.prev_blocks.pop() {
175            self.cur_block = BlockComposer::from_composed_block(&last_block)?;
176            Ok(())
177        } else {
178            Ok(())
179        }
180    }
181
182    fn pop_and_start_new_block(&mut self, letter: Jamo) -> Result<(), WordError> {
183        match self.cur_block.pop_end_consonant() {
184            Some(l) => {
185                self.complete_current_block()?;
186                self.cur_block.push(&l);
187                match self.cur_block.push(&letter) {
188                    BlockPushResult::Success => Ok(()),
189                    other => Err(WordError::CouldNotStartNewBlock(
190                        letter.char_compatibility(),
191                        other,
192                    )),
193                }
194            }
195            None => Err(WordError::NothingToPop),
196        }
197    }
198
199    fn start_new_block(&mut self, letter: Jamo) -> Result<(), WordError> {
200        self.complete_current_block()?;
201        match self.cur_block.push(&letter) {
202            BlockPushResult::Success => Ok(()),
203            other => Err(WordError::CouldNotStartNewBlock(
204                letter.char_compatibility(),
205                other,
206            )),
207        }
208    }
209
210    /// Returns the composed string for the current Hangul word.
211    /// This includes all completed syllable blocks and the current block,
212    /// even if it is incomplete.
213    pub fn as_string(&self) -> Result<String, WordError> {
214        let mut result = hangul_blocks_vec_to_string(&self.prev_blocks)?;
215        let cur_as_char = self.cur_block.block_as_string()?;
216        if let Some(c) = cur_as_char {
217            result.push(c);
218        }
219        Ok(result)
220    }
221
222    fn complete_current_block(&mut self) -> Result<(), WordError> {
223        match self.cur_block.try_as_complete_block()? {
224            BlockCompletionStatus::Complete(block) => {
225                self.prev_blocks.push(block);
226                self.cur_block = BlockComposer::new();
227                Ok(())
228            }
229            BlockCompletionStatus::Incomplete(c) => Err(WordError::CannotCompleteCurrentBlock(c)),
230            BlockCompletionStatus::Empty => {
231                // Nothing to complete
232                Ok(())
233            }
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn start_new_block_valid() {
244        let mut composer = HangulWordComposer::new();
245
246        assert_eq!(composer.push_char('ㄱ'), Ok(WordPushResult::Continue));
247        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
248        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue),);
249        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue),);
250        assert_eq!(
251            composer.prev_blocks,
252            vec![HangulBlock {
253                initial: Jamo::Consonant(JamoConsonantSingular::Giyeok),
254                vowel: Jamo::Vowel(JamoVowelSingular::A),
255                final_optional: Some(Jamo::Consonant(JamoConsonantSingular::Nieun)),
256            }]
257        );
258        assert_eq!(composer.push_char('ㅛ'), Ok(WordPushResult::Continue));
259        assert_eq!(composer.push_char('ㅉ'), Ok(WordPushResult::Continue),);
260        assert_eq!(
261            composer.prev_blocks,
262            vec![
263                HangulBlock {
264                    initial: Jamo::Consonant(JamoConsonantSingular::Giyeok),
265                    vowel: Jamo::Vowel(JamoVowelSingular::A),
266                    final_optional: Some(Jamo::Consonant(JamoConsonantSingular::Nieun)),
267                },
268                HangulBlock {
269                    initial: Jamo::Consonant(JamoConsonantSingular::Ieung),
270                    vowel: Jamo::Vowel(JamoVowelSingular::Yo),
271                    final_optional: None,
272                }
273            ]
274        );
275    }
276
277    #[test]
278    fn start_new_block_invalid() {
279        let mut composer = HangulWordComposer::new();
280
281        assert_eq!(
282            composer.start_new_block(Jamo::Vowel(JamoVowelSingular::A)),
283            Err(WordError::CouldNotStartNewBlock(
284                'ㅏ',
285                BlockPushResult::InvalidHangul
286            ))
287        );
288        let _ = composer.push_char('ㄱ');
289        assert_eq!(
290            composer.start_new_block(Jamo::CompositeVowel(JamoVowelComposite::Wae)),
291            Err(WordError::CannotCompleteCurrentBlock(Jamo::Consonant(
292                JamoConsonantSingular::Giyeok
293            )))
294        );
295    }
296
297    #[test]
298    fn push_char_valid() {
299        let mut composer = HangulWordComposer::new();
300
301        assert_eq!(composer.push_char('ㄱ'), Ok(WordPushResult::Continue));
302        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
303        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue),);
304    }
305
306    #[test]
307    fn push_char_invalid_hangul() {
308        let mut composer = HangulWordComposer::new();
309
310        assert_eq!(composer.push_char('ㄱ'), Ok(WordPushResult::Continue));
311        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
312        assert_eq!(composer.push_char('ㄹ'), Ok(WordPushResult::Continue));
313        assert_eq!(composer.push_char('ㄽ'), Ok(WordPushResult::InvalidHangul));
314    }
315
316    #[test]
317    fn push_char_next_block() {
318        let mut composer = HangulWordComposer::new();
319
320        assert_eq!(composer.push_char('ㄱ'), Ok(WordPushResult::Continue));
321        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
322        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
323        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
324    }
325
326    #[test]
327    fn push_char_non_hangul() {
328        let mut composer = HangulWordComposer::new();
329
330        assert_eq!(composer.push_char('ㄱ'), Ok(WordPushResult::Continue));
331        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
332        assert_eq!(composer.push_char('A'), Ok(WordPushResult::NonHangul));
333    }
334
335    #[test]
336    fn test_single_word_안녕하세요_as_string() {
337        let mut composer = HangulWordComposer::new();
338
339        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
340        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
341        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
342        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
343        assert_eq!(composer.push_char('ㅕ'), Ok(WordPushResult::Continue));
344        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
345        assert_eq!(composer.push_char('ㅎ'), Ok(WordPushResult::Continue));
346        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
347        assert_eq!(composer.push_char('ㅅ'), Ok(WordPushResult::Continue));
348        assert_eq!(composer.push_char('ㅔ'), Ok(WordPushResult::Continue));
349        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
350        assert_eq!(composer.push_char('ㅛ'), Ok(WordPushResult::Continue));
351
352        let result_string = composer.as_string().unwrap();
353        assert_eq!(result_string, "안녕하세요".to_string());
354    }
355
356    #[test]
357    fn test_single_word_앖어요_as_string() {
358        let mut composer = HangulWordComposer::new();
359
360        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
361        assert_eq!(composer.push_char('ㅓ'), Ok(WordPushResult::Continue));
362        assert_eq!(composer.push_char('ㅂ'), Ok(WordPushResult::Continue));
363        assert_eq!(composer.push_char('ㅅ'), Ok(WordPushResult::Continue));
364        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
365        assert_eq!(composer.push_char('ㅓ'), Ok(WordPushResult::Continue));
366        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
367        assert_eq!(composer.push_char('ㅛ'), Ok(WordPushResult::Continue));
368
369        let result_string = composer.as_string().unwrap();
370        assert_eq!(result_string, "없어요".to_string());
371    }
372
373    #[test]
374    fn test_incomplete_block_as_string() {
375        let mut composer = HangulWordComposer::new();
376
377        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
378
379        let result_string = composer.as_string().unwrap();
380        assert_eq!(result_string, "ᄋ".to_string());
381    }
382
383    #[test]
384    fn test_deletions() {
385        let mut composer = HangulWordComposer::new();
386        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
387        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
388        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
389        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
390        assert_eq!(composer.push_char('ㅕ'), Ok(WordPushResult::Continue));
391        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㅕ');
392        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㄴ');
393        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㄴ');
394        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㅏ');
395        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㅇ');
396        assert_eq!(composer.pop(), Ok(None));
397    }
398
399    #[test]
400    fn test_deletion_then_write_again() {
401        let mut composer = HangulWordComposer::new();
402        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
403        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
404        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
405
406        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㄴ');
407        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㅏ');
408        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㅇ');
409
410        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
411        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
412        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
413
414        let result_string = composer.as_string().unwrap();
415        assert_eq!(result_string, "안".to_string());
416    }
417
418    #[test]
419    fn deletion_removes_empty_block() {
420        let mut composer = HangulWordComposer::new();
421        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
422        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
423        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
424        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
425
426        assert_eq!(composer.pop().unwrap().unwrap().char_compatibility(), 'ㄴ');
427        // if current block is still empty, as_string should fail
428        assert_eq!(composer.as_string().unwrap(), "안".to_string());
429    }
430
431    #[test]
432    fn test_complete_current_block() {
433        let mut composer = HangulWordComposer::new();
434        assert_eq!(composer.push_char('ㅇ'), Ok(WordPushResult::Continue));
435        assert_eq!(composer.push_char('ㅏ'), Ok(WordPushResult::Continue));
436        assert_eq!(composer.push_char('ㄴ'), Ok(WordPushResult::Continue));
437
438        assert!(composer.complete_current_block().is_ok());
439
440        assert_eq!(composer.prev_blocks.len(), 1);
441        assert_eq!(composer.cur_block, BlockComposer::new());
442
443        let result_string = composer.as_string().unwrap();
444        assert_eq!(result_string, "안".to_string());
445    }
446}