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 mut name = parts.next().unwrap();
75
76        if name.is_empty() || name.starts_with('-') || name.ends_with('-') {
77            return None;
78        }
79
80        let mut args: VecDeque<_> = parts.map(|s| unwrap_string_literals(&s)).collect();
81
82        if (matches!(tpe, CommandType::Neither) && args.is_empty()) {
83            return None;
84        }
85
86        if let Some((_, prefix, number)) = lazy_regex::regex_captures!(r"^(.*?)(\d+s?)$", &name) {
87            args.push_front(number.to_owned());
88            name = prefix.to_owned();
89        }
90
91        Some(Self { name, args, tpe })
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct CommandMessage {
97    pub parallel: Vec<Vec<CommandExpr>>,
98    /// True if the message did not contain any non-command non-whitespace text
99    pub pure: bool,
100}
101
102impl fmt::Display for CommandMessage {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        for (i, group) in self.parallel.iter().enumerate() {
105            if i > 0 {
106                f.write_str(" | ")?;
107            }
108            for (j, command) in group.iter().enumerate() {
109                if j > 0 {
110                    f.write_str(" ")?;
111                }
112                write!(f, "{command}")?;
113            }
114        }
115        Ok(())
116    }
117}
118
119impl CommandMessage {
120    pub fn parse(content: &str) -> Self {
121        let mut pure = true;
122        Self {
123            parallel: split_balanced(content.trim(), &['|', '/']) // allow / for mobile
124                .into_iter()
125                .map(|group| {
126                    split_balanced(&group, &[' ', ','])
127                        .iter()
128                        .filter_map(|s| {
129                            let s = s.trim().trim_end_matches('\u{e0000}'); // 7tv spam utf tag
130                            if s.is_empty() {
131                                return None;
132                            }
133                            let parsed = CommandExpr::parse(s);
134                            pure &= parsed.is_some();
135                            parsed
136                        })
137                        .collect()
138                })
139                .filter(|g: &Vec<_>| !g.is_empty())
140                .collect::<Vec<_>>(),
141            pure,
142        }
143    }
144
145    pub fn is_empty(&self) -> bool {
146        self.parallel.iter().all(|seq| seq.is_empty())
147    }
148}
149
150fn unwrap_string_literals(input: &str) -> String {
151    let stripped = match input.strip_prefix('"') {
152        Some(tail) => tail.strip_suffix('"'),
153        None => input
154            .strip_prefix('{')
155            .and_then(|tail| tail.strip_suffix('}'))
156            .map(|s| s.trim()),
157    };
158    let Some(input) = stripped else {
159        return input.to_owned();
160    };
161
162    let mut result = String::with_capacity(input.len());
163    let mut chars = input.chars();
164    while let Some(ch) = chars.next() {
165        match ch {
166            '\\' => match chars.next() {
167                Some('n') => result.push('\n'),
168                // Some('r') => result.push('\r'), // SSE does not support \r
169                Some('t') => result.push('\t'),
170                Some(ch) => result.push(ch),
171                _ => (),
172            },
173            ch => result.push(ch),
174        }
175    }
176    result
177}
178
179// split that considers "string literals"
180fn split_balanced(input: &str, seps: &[char]) -> Vec<String> {
181    let mut result = Vec::new();
182    let mut current = String::new();
183    let mut in_string = false;
184    let mut brace_depth = 0;
185    let mut chars = input.chars();
186    while let Some(ch) = chars.next() {
187        if (in_string || brace_depth != 0) && ch == '\\' {
188            if let Some(ch) = chars.next() {
189                current.push('\\');
190                current.push(ch);
191            }
192            continue;
193        }
194        match ch {
195            '"' if brace_depth == 0 => in_string = !in_string,
196            '{' if !in_string => brace_depth += 1,
197            '}' if !in_string => brace_depth -= 1,
198            _ => {}
199        }
200        if !in_string && brace_depth == 0 && seps.contains(&ch) {
201            result.push(std::mem::take(&mut current));
202        } else {
203            current.push(ch);
204        }
205    }
206    result.push(current);
207    result
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn parsing() {
216        let message = "Hello, this is left~ and right:.3~ and mouse:123:321~ | and then test~ test2~ and ~nope";
217        let parsed = CommandMessage::parse(message);
218
219        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test:2~ nope~"#); // no test2 hah
220    }
221
222    #[test]
223    fn cringing() {
224        let message = "Hello, this is +left and +right:.3 and +mouse:123:321 | and then +test +test2 and +wut~";
225        let parsed = CommandMessage::parse(message);
226
227        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test:2~ +wut~"#);
228    }
229
230    #[test]
231    fn normieing() {
232        let message = "Hello, this is !left and !right:.3 and !mouse:123:321 | and then !test !test2 and !wut~";
233        let parsed = CommandMessage::parse(message);
234
235        insta::assert_snapshot!(parsed, @r#"left~ right:".3"~ mouse:123:321~ | test~ test:2~ !wut~"#);
236    }
237
238    #[test]
239    fn neithering() {
240        let message =
241            "Hello, this is left and right:.3 and mouse:123:321 | and then test test2 and wut";
242        let parsed = CommandMessage::parse(message);
243
244        insta::assert_snapshot!(parsed, @r#"right:".3"~ mouse:123:321~"#);
245    }
246
247    #[test]
248    fn strings() {
249        let message = r#"print:"hello space"~ +hah:"and | pipe" | ~nope:123 | and-also-escapes:" \"incredible\", lol"~ "#;
250
251        let parsed = CommandMessage::parse(message);
252
253        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:" \"incredible\", lol"~"#);
254    }
255
256    #[test]
257    fn braces() {
258        let message = r#"print:{ hello space }~ +hah:{ and | pipe } | ~nope:123 | and-also-escapes:{  { incredible }, lol }~ "#;
259
260        let parsed = CommandMessage::parse(message);
261
262        insta::assert_snapshot!(parsed, @r#"print:"hello space"~ hah:"and | pipe"~ | nope:123~ | and-also-escapes:"{ incredible }, lol"~"#);
263    }
264
265    #[test]
266    fn number_arg() {
267        let message = r#"test123~ wait123s~ nope432h~"#;
268
269        let parsed = CommandMessage::parse(message);
270
271        insta::assert_snapshot!(parsed, @"test:123~ wait:123s~ nope432h~");
272    }
273
274    #[test]
275    fn pure_cmd() {
276        assert!(CommandMessage::parse("U3s~ | w1s~ l600~").pure);
277    }
278
279    #[test]
280    fn seventv_spam_suffix() {
281        assert!(CommandMessage::parse("+lh 󠀀").pure);
282    }
283
284    #[test]
285    fn empty_arg() {
286        let parsed = CommandMessage::parse("command::second-arg~");
287
288        insta::assert_snapshot!(parsed, @"command::\"second-arg\"~");
289    }
290}