neca_cmd/
lib.rs

1use std::{
2    collections::VecDeque,
3    fmt::{self, Write},
4};
5
6#[derive(Debug, Clone, Copy)]
7pub enum CommandType {
8    Uwu,
9    Tilde,
10    Plus,
11    Normie,
12    Hash,
13    Huh,
14    Neither,
15}
16
17impl CommandType {
18    pub fn write_command(&self, f: &mut fmt::Formatter<'_>, command: &str) -> fmt::Result {
19        if matches!(self, Self::Uwu) {
20            f.write_str(command)?;
21            return f.write_char('~');
22        }
23        match self {
24            Self::Tilde => f.write_char('~')?,
25            Self::Plus => f.write_char('+')?,
26            Self::Normie => f.write_char('!')?,
27            Self::Hash => f.write_char('#')?,
28            Self::Huh => f.write_char('?')?,
29            _ => {}
30        };
31        f.write_str(command)
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct CommandToken {
37    pub tpe: CommandType,
38}
39
40#[derive(Debug, Clone)]
41pub struct CommandExpr {
42    pub name: String,
43    pub args: VecDeque<String>,
44    pub tpe: CommandType,
45}
46
47impl fmt::Display for CommandExpr {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(&self.name)?;
50        for arg in &self.args {
51            f.write_str(":")?;
52            if arg.chars().all(|ch| ch.is_alphanumeric()) {
53                f.write_str(arg)?;
54            } else {
55                write!(f, "{arg:?}")?;
56            }
57        }
58        f.write_str("~")?;
59        Ok(())
60    }
61}
62
63impl CommandExpr {
64    pub fn parse(word: &str) -> Option<Self> {
65        let (word, tpe) = (word.strip_suffix('~').map(|w| (w, CommandType::Uwu)))
66            .or(word.strip_prefix('~').map(|w| (w, CommandType::Tilde)))
67            .or(word.strip_prefix('+').map(|w| (w, CommandType::Plus)))
68            .or(word.strip_prefix('!').map(|w| (w, CommandType::Normie)))
69            .or(word.strip_prefix('#').map(|w| (w, CommandType::Hash)))
70            .or(word.strip_prefix('?').map(|w| (w, CommandType::Huh)))
71            .unwrap_or((word, CommandType::Neither));
72
73        let mut parts = split_balanced(word, &[':']).into_iter();
74        let name = parts.next().unwrap();
75
76        if name.is_empty() {
77            return None;
78        }
79
80        let args: VecDeque<_> = parts.map(|s| unwrap_string_literals(&s)).collect();
81
82        // disallow commands ending in :
83        if args.back().is_some_and(|s| s.is_empty()) {
84            return None;
85        }
86
87        // disallow commands not containing a single :
88        if (matches!(tpe, CommandType::Neither) && args.is_empty()) {
89            return None;
90        }
91
92        Some(Self { name, args, tpe })
93    }
94}
95
96#[derive(Debug, Clone)]
97pub struct CommandMessage {
98    pub parallel: Vec<Vec<CommandExpr>>,
99    /// True if the message did not contain any non-command non-whitespace text
100    pub pure: bool,
101}
102
103impl fmt::Display for CommandMessage {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        for (i, group) in self.parallel.iter().enumerate() {
106            if i > 0 {
107                f.write_str(" | ")?;
108            }
109            for (j, command) in group.iter().enumerate() {
110                if j > 0 {
111                    f.write_str(" ")?;
112                }
113                write!(f, "{command}")?;
114            }
115        }
116        Ok(())
117    }
118}
119
120impl CommandMessage {
121    pub fn parse(content: &str) -> Self {
122        let mut pure = true;
123        Self {
124            parallel: split_balanced(content.trim(), &['|', '/']) // allow / for mobile
125                .into_iter()
126                .map(|group| {
127                    split_balanced(&group, &[' ', ','])
128                        .iter()
129                        .filter_map(|s| {
130                            let s = s.trim().trim_matches('\u{e0000}'); // 7tv spam utf tag
131                            if s.is_empty() {
132                                return None;
133                            }
134                            let parsed = CommandExpr::parse(s);
135                            pure &= parsed.is_some();
136                            parsed
137                        })
138                        .collect()
139                })
140                .filter(|g: &Vec<_>| !g.is_empty())
141                .collect::<Vec<_>>(),
142            pure,
143        }
144    }
145
146    pub fn is_empty(&self) -> bool {
147        self.parallel.iter().all(|seq| seq.is_empty())
148    }
149}
150
151fn unwrap_string_literals(input: &str) -> String {
152    let stripped = match input.strip_prefix('"') {
153        Some(tail) => tail.strip_suffix('"'),
154        None => input
155            .strip_prefix('{')
156            .and_then(|tail| tail.strip_suffix('}'))
157            .map(|s| s.trim()),
158    };
159    let Some(input) = stripped else {
160        return input.to_owned();
161    };
162
163    let mut result = String::with_capacity(input.len());
164    let mut chars = input.chars();
165    while let Some(ch) = chars.next() {
166        match ch {
167            '\\' => match chars.next() {
168                Some('n') => result.push('\n'),
169                // Some('r') => result.push('\r'), // SSE does not support \r
170                Some('t') => result.push('\t'),
171                Some(ch) => result.push(ch),
172                _ => (),
173            },
174            ch => result.push(ch),
175        }
176    }
177    result
178}
179
180// split that considers "string literals"
181fn split_balanced(input: &str, seps: &[char]) -> Vec<String> {
182    let mut result = Vec::new();
183    let mut current = String::new();
184    let mut in_string = false;
185    let mut brace_depth = 0;
186    let mut paren_depth = 0;
187
188    let mut chars = input.chars();
189    while let Some(ch) = chars.next() {
190        if (in_string || brace_depth != 0) && ch == '\\' {
191            if let Some(ch) = chars.next() {
192                current.push('\\');
193                current.push(ch);
194            }
195            continue;
196        }
197        match ch {
198            '"' if brace_depth == 0 => in_string = !in_string,
199            '{' if !in_string => brace_depth += 1,
200            '}' if !in_string => brace_depth -= 1,
201            '(' if !in_string => paren_depth += 1,
202            ')' if !in_string => paren_depth -= 1,
203            _ => {}
204        }
205        if !in_string && brace_depth == 0 && paren_depth == 0 && seps.contains(&ch) {
206            result.push(std::mem::take(&mut current));
207        } else {
208            current.push(ch);
209        }
210    }
211    result.push(current);
212    result
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn parsing() {
221        let message = "Hello, this is left~ and right:.3~ and mouse:123:321~ | and then test~ test2~ and ~nope";
222        let parsed = CommandMessage::parse(message);
223
224        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ nope~"#);
225        // no test2 hah
226    }
227
228    #[test]
229    fn cringing() {
230        let message = "Hello, this is +left and +right:.3 and +mouse:123:321 | and then +test +test2 and +wut~";
231        let parsed = CommandMessage::parse(message);
232
233        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ +wut~"#);
234    }
235
236    #[test]
237    fn normieing() {
238        let message = "Hello, this is !left and !right:.3 and !mouse:123:321 | and then !test !test2 and !wut~";
239        let parsed = CommandMessage::parse(message);
240
241        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ !wut~"#);
242    }
243
244    #[test]
245    fn neithering() {
246        let message =
247            "Hello, this is left and right:.3 and mouse:123:321 | and then test test2 and wut";
248        let parsed = CommandMessage::parse(message);
249
250        insta::assert_snapshot!(parsed, @r#"right:".3"~ mouse:123:321~"#);
251    }
252
253    #[test]
254    fn strings() {
255        let message = r#"print:"hello space"~ +hah:"and | pipe" | ~nope:123 | and-also-escapes:" \"incredible\", lol"~ "#;
256
257        let parsed = CommandMessage::parse(message);
258
259        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:" \"incredible\", lol"~"#);
260    }
261
262    #[test]
263    fn braces() {
264        let message = r#"print:{ hello space }~ +hah:{ and | pipe } | ~nope:123 | and-also-escapes:{  { incredible }, lol }~ "#;
265
266        let parsed = CommandMessage::parse(message);
267
268        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:"{ incredible }, lol"~"#);
269    }
270
271    #[test]
272    fn pure_cmd() {
273        assert!(CommandMessage::parse("U3s~ | w1s~ l600~").pure);
274    }
275
276    #[test]
277    fn seventv_spam_suffix() {
278        assert!(CommandMessage::parse("+lh 󠀀").pure);
279    }
280
281    #[test]
282    fn empty_arg() {
283        let parsed = CommandMessage::parse("command::second-arg~");
284
285        insta::assert_snapshot!(parsed, @"command::\"second-arg\"~");
286    }
287
288    #[test]
289    fn funky() {
290        let parsed = CommandMessage::parse(" test:some-text-with-{ braces and shit }-in-it ");
291
292        insta::assert_snapshot!(parsed, @r#"test:"some-text-with-{ braces and shit }-in-it"~"#);
293    }
294
295    #[test]
296    fn percent_full_arg() {
297        let parsed = CommandMessage::parse(" w:%(1:default) ");
298
299        insta::assert_snapshot!(parsed, @r#"w:"%(1:default)"~"#);
300    }
301}