ircparser/
lib.rs

1// BSD 3-Clause License
2//
3// Copyright (c) 2022-present, Ethan Henderson
4// All rights reserved.
5//
6// Redistribution and use in source and binary forms, with or without
7// modification, are permitted provided that the following conditions are met:
8//
9// 1. Redistributions of source code must retain the above copyright notice, this
10//    list of conditions and the following disclaimer.
11//
12// 2. Redistributions in binary form must reproduce the above copyright notice,
13//    this list of conditions and the following disclaimer in the documentation
14//    and/or other materials provided with the distribution.
15//
16// 3. Neither the name of the copyright holder nor the names of its
17//    contributors may be used to endorse or promote products derived from
18//    this software without specific prior written permission.
19//
20// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
31//! An IRC (RFC1459) parser and formatter, built in Rust.
32//!
33//! ## Parsing messages
34//!
35//! You can parse IRC messages using the provided `parse` function.
36//!
37//! ```
38//! let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
39//! match ircparser::parse(msg) {
40//!     Ok(mut x) => {
41//!         let line = x.pop_front().unwrap();
42//!
43//!         assert_eq!(&line.tags["id"], "123");
44//!         if line.source.is_some() {
45//!             assert_eq!(line.source.unwrap(), ":nick!user@host.tmi.twitch.tv");
46//!         }
47//!         assert_eq!(line.command, "PRIVMSG");
48//!         assert_eq!(line.params[0], "#rickastley");
49//!         assert_eq!(line.params[1], "Never gonna give you up!");
50//!     }
51//!     Err(e) => {
52//!         println!("A parsing error occured: {e}");
53//!         return;
54//!     }
55//! };
56//! ```
57
58mod line;
59
60pub use line::Line;
61use std::collections::{HashMap, VecDeque};
62
63type ParseResult<T> = Result<T, ParseError>;
64
65/// Exception thrown when an error occurs during message parsing.
66#[derive(Debug, Clone)]
67pub struct ParseError {
68    /// The details of this error.
69    pub details: String,
70}
71
72impl ParseError {
73    /// Generates a new [`ParseError`].
74    ///
75    /// # Arguments
76    /// - `details` - The details of this error.
77    ///
78    /// # Example
79    /// ```
80    /// let e = ircparser::ParseError::new("err");
81    ///
82    /// assert_eq!(e.details, "err".to_string())
83    /// ```
84    ///
85    pub fn new(details: &str) -> Self {
86        Self {
87            details: details.into(),
88        }
89    }
90}
91
92impl std::fmt::Display for ParseError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "{}", self.details)
95    }
96}
97
98fn find_index(text: &str, char: char, start: usize) -> Option<usize> {
99    for (k, _) in text.match_indices(char) {
100        if k > start {
101            return Some(k);
102        }
103    }
104
105    None
106}
107
108/// Parses an IRC message.
109///
110/// # Arguments
111/// - `text` - The text you want to parse. This can comprise of multiple
112/// lines. In this case, each line (separated by a newline character)
113/// will be a separate element in the return value.
114///
115/// # Returns
116/// - [`VecDeque<Line>`] - A [`VecDeque`] of all parsed [`Line`]s. This
117/// will be empty if no valid lines were passed and no errors occur.
118///
119/// # Example
120/// ```
121/// let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
122///
123/// match ircparser::parse(msg) {
124///     Ok(mut x) => {
125///         let line = x.pop_front().unwrap();
126///
127///         assert_eq!(&line.tags["id"], "123");
128///         if line.source.is_some() {
129///             assert_eq!(line.source.unwrap(), ":nick!user@host.tmi.twitch.tv");
130///         }
131///         assert_eq!(line.command, "PRIVMSG");
132///         assert_eq!(line.params[0], "#rickastley");
133///         assert_eq!(line.params[1], "Never gonna give you up!");
134///     }
135///     Err(e) => {
136///         println!("A parsing error occured: {e}");
137///         return;
138///     }
139/// };
140/// ```
141///
142/// # Notice
143/// The behaviour of this function changed in v0.2.0. It can now accept
144/// multiple lines at once, but as a consequence, now returns a
145/// [`VecDeque`] of [`Line`] objects instead of a single [`Line`].
146///
147pub fn parse(text: &str) -> ParseResult<VecDeque<Line>> {
148    let mut parsed_lines: VecDeque<Line> = VecDeque::new();
149
150    for line in text.split("\r\n") {
151        if line.is_empty() {
152            // If the line length is 0, we can assume the previous line
153            // ended in \r\n, and that this line doesn't need to be
154            // processed.
155            continue;
156        }
157
158        let mut idx = 0;
159        let mut tags: HashMap<String, String> = HashMap::new();
160        let mut source: Option<String> = None;
161
162        // Parse tags component.
163        if line.starts_with('@') {
164            idx = line.find(' ').unwrap();
165
166            for part in Some(&line[1..idx]).unwrap().split(';') {
167                let kv: Vec<&str> = part.split('=').collect();
168                tags.insert(kv[0].to_string(), kv[1].to_string());
169            }
170
171            idx += 1;
172        }
173
174        // Parse source component.
175        if line.chars().nth(idx).unwrap() == ':' {
176            let end_idx = find_index(line, ' ', idx).unwrap();
177            source = Some(line[idx..end_idx].to_string());
178            idx = end_idx + 1;
179        }
180
181        // Parse command component.
182        let end_idx = match find_index(line, ' ', idx) {
183            Some(val) => val,
184            None => return Err(ParseError { details: "Couldn't find index of ' '".to_string() }),
185        };
186        let command = &line[idx..end_idx];
187        idx = end_idx + 1;
188
189        let c_idx = match find_index(line, ':', idx) {
190            Some(x) => x - 1,
191            None => line.len(),
192        };
193
194        // Parse params component.
195        let mut params: Vec<String> = line[idx..c_idx].split(' ').map(|x| x.to_string()).collect();
196        if c_idx != line.len() {
197            params.push(line[c_idx + 2..].to_string());
198        }
199
200        parsed_lines.push_back(Line::new(tags, source, command, params));
201    }
202
203    Ok(parsed_lines)
204}
205
206#[cfg(test)]
207mod test_lib {
208    use super::parse;
209    use collection_macros::hashmap;
210    use std::collections::HashMap;
211
212    #[test]
213    fn test_single_partial() {
214        let msg = "PRIVMSG #rickastley :Never gonna give you up!\r\n";
215
216        match parse(msg) {
217            Ok(mut x) => {
218                assert_eq!(x.len(), 1);
219                let line = x.pop_front().unwrap();
220
221                assert_eq!(line.tags, HashMap::new());
222                assert_eq!(line.source, None);
223                assert_eq!(line.command, "PRIVMSG");
224                assert_eq!(line.params, vec!["#rickastley", "Never gonna give you up!"]);
225            }
226            Err(e) => {
227                println!("A parsing error occured: {e}");
228                assert!(false);
229            }
230        }
231    }
232
233    #[test]
234    fn test_single_full() {
235        let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
236        match parse(msg) {
237            Ok(mut x) => {
238                assert_eq!(x.len(), 1);
239                let line = x.pop_front().unwrap();
240
241                assert_eq!(
242                    line.tags,
243                    hashmap! {
244                        String::from("id") => String::from("123"),
245                        String::from("name") => String::from("rick"),
246                    }
247                );
248                assert_eq!(
249                    line.source,
250                    Some(String::from(":nick!user@host.tmi.twitch.tv"))
251                );
252                assert_eq!(line.command, "PRIVMSG");
253                assert_eq!(line.params, vec!["#rickastley", "Never gonna give you up!"]);
254            }
255            Err(e) => {
256                println!("A parsing error occured: {e}");
257                return;
258            }
259        };
260    }
261
262    #[test]
263    fn test_readme_example() {
264        let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
265        match parse(msg) {
266            Ok(mut x) => {
267                println!("{x:?}");
268                assert_eq!(x.len(), 1);
269                let line = x.pop_front().unwrap();
270
271                assert_eq!(&line.tags["id"], "123");
272                if line.source.is_some() {
273                    assert_eq!(line.source.unwrap(), ":nick!user@host.tmi.twitch.tv");
274                }
275                assert_eq!(line.command, "PRIVMSG");
276                assert_eq!(line.params[0], "#rickastley");
277                assert_eq!(line.params[1], "Never gonna give you up!");
278            }
279            Err(e) => {
280                println!("A parsing error occured: {e}");
281                assert!(false);
282            }
283        };
284    }
285
286    #[test]
287    fn test_empty() {
288        match parse("") {
289            Ok(x) => {
290                assert_eq!(x.len(), 0);
291            }
292            Err(e) => {
293                println!("A parsing error occured: {e}");
294                assert!(false);
295            }
296        };
297    }
298
299    #[test]
300    fn test_multiline() {
301        let msg = "@id=123 PRIVMSG #rickastley :Never gonna give you up!\r\n@id=456 PRIVMSG #rickastley :Never gonna let you down!\r\n";
302        match parse(msg) {
303            Ok(mut x) => {
304                assert_eq!(x.len(), 2);
305
306                let l1 = x.pop_front().unwrap();
307                let l2 = x.pop_front().unwrap();
308
309                assert_eq!(&l1.tags["id"], "123");
310                assert_eq!(&l2.tags["id"], "456");
311                assert_eq!(l1.command, l2.command);
312                assert_eq!(l1.params[1], "Never gonna give you up!");
313                assert_eq!(l2.params[1], "Never gonna let you down!");
314            }
315            Err(e) => {
316                println!("A parsing error occured: {e}");
317                assert!(false);
318            }
319        }
320    }
321}