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}