Skip to main content

fcp_core/
tokenizer.rs

1/// Tokenize splits an FCP operation string into tokens, handling quoted strings,
2/// escape sequences, and embedded newlines.
3pub fn tokenize(input: &str) -> Vec<String> {
4    // Iterate over chars, not bytes — indexing raw UTF-8 bytes and casting
5    // them individually to `char` splits multi-byte sequences apart and
6    // produces mojibake for any non-ASCII input.
7    let chars: Vec<char> = input.chars().collect();
8    let n = chars.len();
9    let mut tokens = Vec::new();
10    let mut i = 0;
11
12    while i < n {
13        // Skip spaces
14        while i < n && chars[i] == ' ' {
15            i += 1;
16        }
17        if i >= n {
18            break;
19        }
20
21        if chars[i] == '"' {
22            // Fully quoted token
23            i += 1;
24            let mut token = String::new();
25            while i < n && chars[i] != '"' {
26                if chars[i] == '\\' && i + 1 < n {
27                    let next = chars[i + 1];
28                    if next == 'n' {
29                        token.push('\n');
30                        i += 2;
31                    } else {
32                        i += 1;
33                        token.push(chars[i]);
34                        i += 1;
35                    }
36                } else {
37                    token.push(chars[i]);
38                    i += 1;
39                }
40            }
41            if i < n {
42                i += 1; // skip closing quote
43            }
44            tokens.push(token);
45        } else {
46            // Unquoted token (may contain embedded quoted sections)
47            let mut token = String::new();
48            while i < n && chars[i] != ' ' {
49                if chars[i] == '"' {
50                    token.push('"');
51                    i += 1;
52                    while i < n && chars[i] != '"' {
53                        if chars[i] == '\\' && i + 1 < n {
54                            let next = chars[i + 1];
55                            if next == 'n' {
56                                token.push('\n');
57                                i += 2;
58                            } else {
59                                i += 1;
60                                token.push(chars[i]);
61                                i += 1;
62                            }
63                        } else {
64                            token.push(chars[i]);
65                            i += 1;
66                        }
67                    }
68                    if i < n {
69                        token.push('"');
70                        i += 1;
71                    }
72                } else {
73                    token.push(chars[i]);
74                    i += 1;
75                }
76            }
77            tokens.push(token.replace("\\n", "\n"));
78        }
79    }
80
81    tokens
82}
83
84/// Returns true if the token is a key:value pair (not a selector, not an arrow).
85pub fn is_key_value(token: &str) -> bool {
86    if token.starts_with('@') {
87        return false;
88    }
89    if is_arrow(token) {
90        return false;
91    }
92    let Some((key, value)) = token.split_once(':') else {
93        return false;
94    };
95    if key.is_empty() || value.is_empty() {
96        return false;
97    }
98    key.chars()
99        .all(|ch| matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-'))
100}
101
102/// Parses a key:value token into its key and value parts.
103/// If the value is wrapped in double quotes, they are stripped.
104pub fn parse_key_value(token: &str) -> (String, String) {
105    let (key, raw_value) = token.split_once(':').unwrap();
106    let value = raw_value
107        .strip_prefix('"')
108        .and_then(|s| s.strip_suffix('"'))
109        .unwrap_or(raw_value);
110    (key.to_string(), value.to_string())
111}
112
113/// Parses a key:value token, also returning whether the value was quoted.
114pub fn parse_key_value_with_meta(token: &str) -> (String, String, bool) {
115    let (key, raw_value) = token.split_once(':').unwrap();
116    if let Some(unquoted) = raw_value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
117        (key.to_string(), unquoted.to_string(), true)
118    } else {
119        (key.to_string(), raw_value.to_string(), false)
120    }
121}
122
123/// Returns true if the token is an arrow operator: ->, <->, or --
124pub fn is_arrow(token: &str) -> bool {
125    token == "->" || token == "<->" || token == "--"
126}
127
128/// Returns true if the token is a selector (starts with @).
129pub fn is_selector(token: &str) -> bool {
130    token.starts_with('@')
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_tokenize_simple_tokens() {
139        let got = tokenize("add svc AuthService");
140        assert_eq!(got, vec!["add", "svc", "AuthService"]);
141    }
142
143    #[test]
144    fn test_tokenize_quoted_strings() {
145        let got = tokenize(r#"add svc "Auth Service" theme:blue"#);
146        assert_eq!(got, vec!["add", "svc", "Auth Service", "theme:blue"]);
147    }
148
149    #[test]
150    fn test_tokenize_escaped_quotes() {
151        let got = tokenize(r#"label A "say \"hello\"""#);
152        assert_eq!(got, vec!["label", "A", r#"say "hello""#]);
153    }
154
155    #[test]
156    fn test_tokenize_empty_input() {
157        let got = tokenize("");
158        assert!(got.is_empty());
159    }
160
161    #[test]
162    fn test_tokenize_whitespace_only() {
163        let got = tokenize("   ");
164        assert!(got.is_empty());
165    }
166
167    #[test]
168    fn test_tokenize_multiple_spaces() {
169        let got = tokenize("add   svc   A");
170        assert_eq!(got, vec!["add", "svc", "A"]);
171    }
172
173    #[test]
174    fn test_tokenize_newline_in_unquoted() {
175        let got = tokenize(r"add svc Container\nRegistry");
176        assert_eq!(got, vec!["add", "svc", "Container\nRegistry"]);
177    }
178
179    #[test]
180    fn test_tokenize_newline_in_quoted() {
181        let got = tokenize(r#"add svc "Container\nRegistry""#);
182        assert_eq!(got, vec!["add", "svc", "Container\nRegistry"]);
183    }
184
185    #[test]
186    fn test_tokenize_embedded_quoted_value() {
187        let got = tokenize(r#"label:"Line1\nLine2""#);
188        assert_eq!(got, vec!["label:\"Line1\nLine2\""]);
189    }
190
191    #[test]
192    fn test_tokenize_multiple_newlines() {
193        let got = tokenize(r"add svc A\nB\nC");
194        assert_eq!(got, vec!["add", "svc", "A\nB\nC"]);
195    }
196
197    #[test]
198    fn test_tokenize_single_token() {
199        let got = tokenize("add");
200        assert_eq!(got, vec!["add"]);
201    }
202
203    #[test]
204    fn test_tokenize_empty_quoted_string() {
205        let got = tokenize(r#""""#);
206        assert_eq!(got, vec![""]);
207    }
208
209    #[test]
210    fn test_tokenize_unclosed_quote() {
211        let got = tokenize(r#""hello world"#);
212        assert_eq!(got, vec!["hello world"]);
213    }
214
215    #[test]
216    fn test_tokenize_escaped_backslash() {
217        let got = tokenize(r#""path\\dir""#);
218        assert_eq!(got, vec![r"path\dir"]);
219    }
220
221    #[test]
222    fn test_tokenize_colons_in_value() {
223        let got = tokenize("url:http://example.com");
224        assert_eq!(got, vec!["url:http://example.com"]);
225    }
226
227    #[test]
228    fn test_tokenize_unicode_labels() {
229        // Regression test: the old byte-indexed implementation cast individual
230        // UTF-8 bytes to `char`, splitting multi-byte sequences and producing
231        // mojibake. Cover 2-, 3-, and 4-byte encodings.
232        let got = tokenize("café 日本語 😀");
233        assert_eq!(got, vec!["café", "日本語", "😀"]);
234    }
235
236    #[test]
237    fn test_tokenize_unicode_quoted_label() {
238        let got = tokenize(r#"label "Café Étudiant""#);
239        assert_eq!(got, vec!["label", "Café Étudiant"]);
240    }
241
242    #[test]
243    fn test_is_key_value() {
244        let tests = vec![
245            ("theme:blue", true),
246            ("url:http://x", true),
247            ("@type:db", false),
248            ("->", false),
249            ("hello", false),
250            ("key:", false),
251            (":value", false),
252        ];
253        for (input, want) in tests {
254            assert_eq!(
255                is_key_value(input),
256                want,
257                "is_key_value({:?}) = {}, want {}",
258                input,
259                is_key_value(input),
260                want
261            );
262        }
263    }
264
265    #[test]
266    fn test_parse_key_value() {
267        let (key, value) = parse_key_value("theme:blue");
268        assert_eq!(key, "theme");
269        assert_eq!(value, "blue");
270
271        let (key, value) = parse_key_value("url:http://x:8080");
272        assert_eq!(key, "url");
273        assert_eq!(value, "http://x:8080");
274    }
275
276    #[test]
277    fn test_parse_key_value_strips_quotes() {
278        let (key, value) = parse_key_value("label:\"Line1\nLine2\"");
279        assert_eq!(key, "label");
280        assert_eq!(value, "Line1\nLine2");
281    }
282
283    #[test]
284    fn test_parse_key_value_with_meta() {
285        let (key, value, was_quoted) = parse_key_value_with_meta(r#"engine_version:"15""#);
286        assert_eq!(key, "engine_version");
287        assert_eq!(value, "15");
288        assert!(was_quoted);
289
290        let (key, value, was_quoted) = parse_key_value_with_meta("port:80");
291        assert_eq!(key, "port");
292        assert_eq!(value, "80");
293        assert!(!was_quoted);
294    }
295
296    #[test]
297    fn test_is_arrow() {
298        assert!(is_arrow("->"));
299        assert!(is_arrow("<->"));
300        assert!(is_arrow("--"));
301        assert!(!is_arrow("=>"));
302        assert!(!is_arrow("add"));
303    }
304
305    #[test]
306    fn test_is_selector() {
307        assert!(is_selector("@type:db"));
308        assert!(is_selector("@all"));
309        assert!(!is_selector("type:db"));
310        assert!(!is_selector("add"));
311    }
312}