hangul_cd/string.rs
1use thiserror::Error;
2
3use crate::{
4 jamo::{Jamo, JamoPosition},
5 word::*,
6};
7
8/// An error type for `StringComposer` operations.
9#[derive(Error, Debug, PartialEq, Eq)]
10pub enum StringError {
11 /// Occurs when there is an error related to word composition.
12 #[error("Word error: {0}")]
13 WordError(#[from] WordError),
14}
15
16/// A composer struct that manages the composition of strings of text
17/// consisting of multiple words, including both Hangul words and non-Hangul
18/// text.
19///
20/// The `StringComposer` maintains both a string of completed text and a
21/// `HangulWordComposer` for the current word being composed. If the currently
22/// active word is a Hangul word that has not yet been completed, pushing or
23/// popping characters will interact with the `HangulWordComposer` and directly
24/// update syllable blocks. Otherwise, Unicode characters will be directly
25/// added to or removed from the completed string.
26///
27/// **API:**
28/// ```rust
29/// use hangul_cd::string::StringComposer;
30///
31/// let mut composer = StringComposer::new();
32///
33/// // Push characters to form Hangul syllables
34/// composer.push_char('ㅎ').unwrap();
35/// composer.push_char('ㅏ').unwrap();
36///
37/// // Get the composed string
38/// let result = composer.as_string().unwrap();
39/// assert_eq!(result, "하".to_string());
40///
41/// // Push non-Hangul characters
42/// composer.push_char(' ').unwrap();
43/// composer.push_char('!').unwrap();
44/// assert_eq!(composer.as_string().unwrap(), "하 !".to_string());
45///
46/// // Popping non-Hangul characters removes them from the completed string
47/// composer.pop().unwrap(); // removes '!'
48/// composer.pop().unwrap(); // removes ' '
49/// assert_eq!(composer.as_string().unwrap(), "하".to_string());
50///
51/// // Popping Hangul characters after they've been completed removes entire syllables
52/// composer.pop().unwrap(); // removes '하'
53/// assert_eq!(composer.as_string().unwrap(), "".to_string());
54///
55/// // Popping characters while a Hangul word is active removes jamo
56/// composer.push_char('ㅂ').unwrap();
57/// composer.push_char('ㅏ').unwrap();
58/// composer.push_char('ㅂ').unwrap();
59/// composer.pop().unwrap(); // removes 'ㅂ'
60/// assert_eq!(composer.as_string().unwrap(), "바".to_string());
61/// ```
62#[derive(Debug)]
63pub struct StringComposer {
64 completed: String,
65 current: HangulWordComposer,
66}
67
68impl Default for StringComposer {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl StringComposer {
75 /// Creates a new, empty `StringComposer`.
76 pub fn new() -> Self {
77 Self {
78 completed: String::new(),
79 current: HangulWordComposer::new(),
80 }
81 }
82
83 /// Pushes a character to the `StringComposer`.
84 ///
85 /// If the character is part of a Hangul word, it will be composed into syllables.
86 /// Otherwise, it will be added directly to the completed string.
87 pub fn push_char(&mut self, c: char) -> Result<(), StringError> {
88 match self.current.push_char(c)? {
89 WordPushResult::Continue => Ok(()),
90 _ => self.handle_invalid_input(c),
91 }
92 }
93
94 /// Returns the composed string, combining completed text and the current word.
95 pub fn as_string(&self) -> Result<String, StringError> {
96 let mut result = self.completed.clone();
97 let current_string = self.current.as_string()?;
98 result.push_str(¤t_string);
99 Ok(result)
100 }
101
102 /// Pops the last character from the `StringComposer` and returns it wrapped
103 /// within a `Result` and `Option`.
104 ///
105 /// If the current word is a Hangul word with uncompleted syllables, it will
106 /// remove the last jamo from the current syllable block. Otherwise, it will
107 /// remove the last character from the completed string.
108 pub fn pop(&mut self) -> Result<Option<char>, StringError> {
109 match self.current.pop()? {
110 Some(c) => Ok(c.char_modern(match c {
111 Jamo::Consonant(_) | Jamo::CompositeConsonant(_) => JamoPosition::Initial,
112 Jamo::Vowel(_) | Jamo::CompositeVowel(_) => JamoPosition::Vowel,
113 })),
114 None => match self.completed.pop() {
115 Some(c) => Ok(Some(c)),
116 None => Ok(None),
117 },
118 }
119 }
120
121 fn handle_invalid_input(&mut self, c: char) -> Result<(), StringError> {
122 let current_string = self.current.as_string()?;
123 self.completed.push_str(¤t_string);
124 self.completed.push(c);
125 self.current = HangulWordComposer::new();
126 Ok(())
127 }
128}
129
130#[cfg(test)]
131mod test {
132 use super::*;
133
134 #[test]
135 fn test_no_new_words() {
136 let input = "ㅎㅏㄴㄱㅡㄹ";
137 let mut composer = StringComposer::new();
138 for c in input.chars() {
139 composer.push_char(c).unwrap();
140 }
141 let result = composer.as_string().unwrap();
142 assert_eq!(result, "한글".to_string());
143 }
144
145 #[test]
146 fn test_new_hangul_word() {
147 let input = "ㅎㅏㄴㄱㅡㄹ ㅇㅏㄴㄴㅕㅇㅎㅏㅅㅔㅇㅛ";
148 let mut composer = StringComposer::new();
149 for c in input.chars() {
150 composer.push_char(c).unwrap();
151 }
152 let result = composer.as_string().unwrap();
153 assert_eq!(result, "한글 안녕하세요".to_string());
154 }
155
156 #[test]
157 fn test_new_non_hangul_word() {
158 let input = "ㅎㅏㄴㄱㅡㄹ beans";
159 let mut composer = StringComposer::new();
160 for c in input.chars() {
161 composer.push_char(c).unwrap();
162 }
163 let result = composer.as_string().unwrap();
164 assert_eq!(result, "한글 beans".to_string());
165 }
166
167 #[test]
168 fn test_multiple_words() {
169 let input = "ㅎㅏㄴㄱㅡㄹ 123 \n ㅇㅏㄴㄴㅕㅇ!";
170 let mut composer = StringComposer::new();
171 for c in input.chars() {
172 composer.push_char(c).unwrap();
173 }
174 let result = composer.as_string().unwrap();
175 assert_eq!(result, "한글 123 \n 안녕!".to_string());
176 }
177
178 #[test]
179 fn test_backspace() {
180 let input = "ㅇㅏㄴㄴㅕㅇ ㄹㅏㅁㅕㄴ";
181 let mut composer = StringComposer::new();
182 for c in input.chars() {
183 composer.push_char(c).unwrap();
184 }
185 for _ in 0..7 {
186 composer.pop().unwrap();
187 }
188 let result = composer.as_string().unwrap();
189 assert_eq!(result, "안".to_string());
190 }
191}