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 chars = input.chars();
187    while let Some(ch) = chars.next() {
188        if (in_string || brace_depth != 0) && ch == '\\' {
189            if let Some(ch) = chars.next() {
190                current.push('\\');
191                current.push(ch);
192            }
193            continue;
194        }
195        match ch {
196            '"' if brace_depth == 0 => in_string = !in_string,
197            '{' if !in_string => brace_depth += 1,
198            '}' if !in_string => brace_depth -= 1,
199            _ => {}
200        }
201        if !in_string && brace_depth == 0 && seps.contains(&ch) {
202            result.push(std::mem::take(&mut current));
203        } else {
204            current.push(ch);
205        }
206    }
207    result.push(current);
208    result
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn parsing() {
217        let message = "Hello, this is left~ and right:.3~ and mouse:123:321~ | and then test~ test2~ and ~nope";
218        let parsed = CommandMessage::parse(message);
219
220        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ nope~"#); // no test2 hah
221    }
222
223    #[test]
224    fn cringing() {
225        let message = "Hello, this is +left and +right:.3 and +mouse:123:321 | and then +test +test2 and +wut~";
226        let parsed = CommandMessage::parse(message);
227
228        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ +wut~"#);
229    }
230
231    #[test]
232    fn normieing() {
233        let message = "Hello, this is !left and !right:.3 and !mouse:123:321 | and then !test !test2 and !wut~";
234        let parsed = CommandMessage::parse(message);
235
236        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test2~ !wut~"#);
237    }
238
239    #[test]
240    fn neithering() {
241        let message =
242            "Hello, this is left and right:.3 and mouse:123:321 | and then test test2 and wut";
243        let parsed = CommandMessage::parse(message);
244
245        insta::assert_snapshot!(parsed, @r#"right:".3"~ mouse:123:321~"#);
246    }
247
248    #[test]
249    fn strings() {
250        let message = r#"print:"hello space"~ +hah:"and | pipe" | ~nope:123 | and-also-escapes:" \"incredible\", lol"~ "#;
251
252        let parsed = CommandMessage::parse(message);
253
254        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:" \"incredible\", lol"~"#);
255    }
256
257    #[test]
258    fn braces() {
259        let message = r#"print:{ hello space }~ +hah:{ and | pipe } | ~nope:123 | and-also-escapes:{  { incredible }, lol }~ "#;
260
261        let parsed = CommandMessage::parse(message);
262
263        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:"{ incredible }, lol"~"#);
264    }
265
266    #[test]
267    fn pure_cmd() {
268        assert!(CommandMessage::parse("U3s~ | w1s~ l600~").pure);
269    }
270
271    #[test]
272    fn seventv_spam_suffix() {
273        assert!(CommandMessage::parse("+lh 󠀀").pure);
274    }
275
276    #[test]
277    fn empty_arg() {
278        let parsed = CommandMessage::parse("command::second-arg~");
279
280        insta::assert_snapshot!(parsed, @"command::\"second-arg\"~");
281    }
282}