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 = find_index(line, ' ', idx).unwrap();
183        let command = &line[idx..end_idx];
184        idx = end_idx + 1;
185
186        let c_idx = match find_index(line, ':', idx) {
187            Some(x) => x - 1,
188            None => line.len(),
189        };
190
191        // Parse params component.
192        let mut params: Vec<String> = line[idx..c_idx].split(' ').map(|x| x.to_string()).collect();
193        if c_idx != line.len() {
194            params.push(line[c_idx + 2..].to_string());
195        }
196
197        parsed_lines.push_back(Line::new(tags, source, command, params));
198    }
199
200    Ok(parsed_lines)
201}
202
203#[cfg(test)]
204mod test_lib {
205    use super::parse;
206    use collection_macros::hashmap;
207    use std::collections::HashMap;
208
209    #[test]
210    fn test_single_partial() {
211        let msg = "PRIVMSG #rickastley :Never gonna give you up!\r\n";
212
213        match parse(msg) {
214            Ok(mut x) => {
215                assert_eq!(x.len(), 1);
216                let line = x.pop_front().unwrap();
217
218                assert_eq!(line.tags, HashMap::new());
219                assert_eq!(line.source, None);
220                assert_eq!(line.command, "PRIVMSG");
221                assert_eq!(line.params, vec!["#rickastley", "Never gonna give you up!"]);
222            }
223            Err(e) => {
224                println!("A parsing error occured: {e}");
225                assert!(false);
226            }
227        }
228    }
229
230    #[test]
231    fn test_single_full() {
232        let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
233        match parse(msg) {
234            Ok(mut x) => {
235                assert_eq!(x.len(), 1);
236                let line = x.pop_front().unwrap();
237
238                assert_eq!(
239                    line.tags,
240                    hashmap! {
241                        String::from("id") => String::from("123"),
242                        String::from("name") => String::from("rick"),
243                    }
244                );
245                assert_eq!(
246                    line.source,
247                    Some(String::from(":nick!user@host.tmi.twitch.tv"))
248                );
249                assert_eq!(line.command, "PRIVMSG");
250                assert_eq!(line.params, vec!["#rickastley", "Never gonna give you up!"]);
251            }
252            Err(e) => {
253                println!("A parsing error occured: {e}");
254                return;
255            }
256        };
257    }
258
259    #[test]
260    fn test_readme_example() {
261        let msg = "@id=123;name=rick :nick!user@host.tmi.twitch.tv PRIVMSG #rickastley :Never gonna give you up!\r\n";
262        match parse(msg) {
263            Ok(mut x) => {
264                println!("{x:?}");
265                assert_eq!(x.len(), 1);
266                let line = x.pop_front().unwrap();
267
268                assert_eq!(&line.tags["id"], "123");
269                if line.source.is_some() {
270                    assert_eq!(line.source.unwrap(), ":nick!user@host.tmi.twitch.tv");
271                }
272                assert_eq!(line.command, "PRIVMSG");
273                assert_eq!(line.params[0], "#rickastley");
274                assert_eq!(line.params[1], "Never gonna give you up!");
275            }
276            Err(e) => {
277                println!("A parsing error occured: {e}");
278                assert!(false);
279            }
280        };
281    }
282
283    #[test]
284    fn test_empty() {
285        match parse("") {
286            Ok(x) => {
287                assert_eq!(x.len(), 0);
288            }
289            Err(e) => {
290                println!("A parsing error occured: {e}");
291                assert!(false);
292            }
293        };
294    }
295
296    #[test]
297    fn test_multiline() {
298        let msg = "@id=123 PRIVMSG #rickastley :Never gonna give you up!\r\n@id=456 PRIVMSG #rickastley :Never gonna let you down!\r\n";
299        match parse(msg) {
300            Ok(mut x) => {
301                assert_eq!(x.len(), 2);
302
303                let l1 = x.pop_front().unwrap();
304                let l2 = x.pop_front().unwrap();
305
306                assert_eq!(&l1.tags["id"], "123");
307                assert_eq!(&l2.tags["id"], "456");
308                assert_eq!(l1.command, l2.command);
309                assert_eq!(l1.params[1], "Never gonna give you up!");
310                assert_eq!(l2.params[1], "Never gonna let you down!");
311            }
312            Err(e) => {
313                println!("A parsing error occured: {e}");
314                assert!(false);
315            }
316        }
317    }
318}