1use alloc::borrow::ToOwned;
8use alloc::collections::BTreeMap;
9use alloc::string::String;
10use alloc::vec::Vec;
11
12use crate::casemap::{CaseFolded, Casemapping};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Prefix {
20 modes: Vec<char>,
21 chars: Vec<char>,
22}
23
24impl Default for Prefix {
25 fn default() -> Self {
26 Self {
27 modes: alloc::vec!['o', 'v'],
28 chars: alloc::vec!['@', '+'],
29 }
30 }
31}
32
33impl Prefix {
34 pub fn parse(value: &str) -> Option<Self> {
37 let inner = value.strip_prefix('(')?;
38 let (modes, chars) = inner.split_once(')')?;
39 if modes.is_empty() || modes.chars().count() != chars.chars().count() {
40 return None;
41 }
42 Some(Self {
43 modes: modes.chars().collect(),
44 chars: chars.chars().collect(),
45 })
46 }
47
48 pub fn char_for_mode(&self, mode: char) -> Option<char> {
50 let index = self.modes.iter().position(|m| *m == mode)?;
51 self.chars.get(index).copied()
52 }
53
54 pub fn mode_for_char(&self, prefix: char) -> Option<char> {
56 let index = self.chars.iter().position(|c| *c == prefix)?;
57 self.modes.get(index).copied()
58 }
59
60 pub fn is_membership_mode(&self, mode: char) -> bool {
62 self.modes.contains(&mode)
63 }
64
65 pub fn rank(&self, prefix: char) -> Option<usize> {
67 self.chars.iter().position(|c| *c == prefix)
68 }
69
70 pub fn split<'a>(&self, entry: &'a str) -> (&'a str, &'a str) {
75 let end = entry
76 .char_indices()
77 .find(|(_, c)| !self.chars.contains(c))
78 .map_or(entry.len(), |(i, _)| i);
79 entry.split_at(end)
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ChanModes {
89 pub list: String,
91 pub always_arg: String,
93 pub arg_on_set: String,
95 pub flag: String,
97}
98
99impl Default for ChanModes {
100 fn default() -> Self {
101 Self {
102 list: "b".to_owned(),
103 always_arg: "k".to_owned(),
104 arg_on_set: "l".to_owned(),
105 flag: "imnpst".to_owned(),
106 }
107 }
108}
109
110impl ChanModes {
111 pub fn parse(value: &str) -> Self {
114 let mut parts = value.split(',');
115 Self {
116 list: parts.next().unwrap_or_default().to_owned(),
117 always_arg: parts.next().unwrap_or_default().to_owned(),
118 arg_on_set: parts.next().unwrap_or_default().to_owned(),
119 flag: parts.next().unwrap_or_default().to_owned(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct Isupport {
127 tokens: BTreeMap<String, Option<String>>,
128 casemapping: Casemapping,
129 prefix: Prefix,
130 chanmodes: ChanModes,
131 chantypes: String,
132 statusmsg: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
138#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
139pub struct Token {
140 pub name: String,
142 pub value: Option<String>,
144 pub removed: bool,
146}
147
148impl Isupport {
149 pub fn apply(&mut self, token: &str) -> Token {
158 if let Some(name) = token.strip_prefix('-') {
159 let name = name.split('=').next().unwrap_or(name);
160 self.tokens.remove(name);
161 self.recompute(name, None);
162 return Token {
163 name: name.to_owned(),
164 value: None,
165 removed: true,
166 };
167 }
168 let Some((name, raw)) = token.split_once('=') else {
169 self.tokens.insert(token.to_owned(), None);
170 self.recompute(token, None);
171 return Token {
172 name: token.to_owned(),
173 value: None,
174 removed: false,
175 };
176 };
177 let (name, value) = match name.strip_suffix('+') {
178 Some(name) => {
179 let mut joined = self.tokens.get(name).cloned().flatten().unwrap_or_default();
180 joined.push_str(&unescape(raw));
181 (name, joined)
182 }
183 None => (name, unescape(raw)),
184 };
185 self.tokens.insert(name.to_owned(), Some(value.clone()));
186 self.recompute(name, Some(&value));
187 Token {
188 name: name.to_owned(),
189 value: Some(value),
190 removed: false,
191 }
192 }
193
194 fn recompute(&mut self, name: &str, value: Option<&str>) {
195 match name {
196 "CASEMAPPING" => {
197 self.casemapping = value.map_or_else(Casemapping::default, Casemapping::parse);
198 }
199 "PREFIX" => {
201 self.prefix = value.and_then(Prefix::parse).unwrap_or_else(|| {
202 if value.is_none() {
203 Prefix::default()
204 } else {
205 self.prefix.clone()
206 }
207 });
208 }
209 "CHANMODES" => {
210 self.chanmodes = value.map_or_else(ChanModes::default, ChanModes::parse);
211 }
212 "CHANTYPES" => value.unwrap_or_default().clone_into(&mut self.chantypes),
213 "STATUSMSG" => value.unwrap_or_default().clone_into(&mut self.statusmsg),
214 _ => {}
215 }
216 }
217
218 pub fn get(&self, name: &str) -> Option<Option<&str>> {
220 self.tokens.get(name).map(Option::as_deref)
221 }
222
223 pub fn has(&self, name: &str) -> bool {
225 self.tokens.contains_key(name)
226 }
227
228 pub fn number(&self, name: &str) -> Option<u32> {
230 self.get(name)?.and_then(|value| value.parse().ok())
231 }
232
233 pub fn casemapping(&self) -> Casemapping {
235 self.casemapping
236 }
237
238 pub fn fold(&self, name: &str) -> CaseFolded {
240 self.casemapping.fold(name)
241 }
242
243 pub fn prefix(&self) -> &Prefix {
245 &self.prefix
246 }
247
248 pub fn chanmodes(&self) -> &ChanModes {
250 &self.chanmodes
251 }
252
253 pub fn is_channel(&self, name: &str) -> bool {
258 let types = if self.chantypes.is_empty() {
259 "#&"
260 } else {
261 &self.chantypes
262 };
263 name.chars().next().is_some_and(|c| types.contains(c))
264 }
265
266 pub fn is_statusmsg(&self, c: char) -> bool {
268 self.statusmsg.contains(c)
269 }
270
271 pub fn targmax(&self, command: &str) -> Option<u32> {
273 let value = self.get("TARGMAX")??;
274 value
275 .split(',')
276 .filter_map(|pair| pair.split_once(':'))
277 .find(|(name, _)| name.eq_ignore_ascii_case(command))
278 .and_then(|(_, limit)| limit.parse().ok())
279 }
280}
281
282fn unescape(value: &str) -> String {
287 if !value.contains("\\x") {
288 return value.to_owned();
289 }
290 let mut out = String::with_capacity(value.len());
291 let mut rest = value;
292 while let Some(at) = rest.find("\\x") {
293 let (before, after) = rest.split_at(at);
294 out.push_str(before);
295 if let Some(byte) = after.get(2..4).and_then(|h| u8::from_str_radix(h, 16).ok()) {
296 out.push(byte as char);
297 rest = after.get(4..).unwrap_or_default();
298 } else {
299 out.push_str("\\x");
300 rest = after.get(2..).unwrap_or_default();
301 }
302 }
303 out.push_str(rest);
304 out
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn isupport(tokens: &[&str]) -> Isupport {
312 let mut isupport = Isupport::default();
313 for token in tokens {
314 let _ = isupport.apply(token);
315 }
316 isupport
317 }
318
319 #[test]
320 fn parses_prefix_into_ranked_pairs() {
321 let prefix = Prefix::parse("(qaohv)~&@%+").expect("valid prefix");
322 assert_eq!(prefix.char_for_mode('o'), Some('@'));
323 assert_eq!(prefix.mode_for_char('%'), Some('h'));
324 assert_eq!(prefix.rank('~'), Some(0));
325 assert!(prefix.rank('@') > prefix.rank('&'));
326 assert!(prefix.is_membership_mode('v'));
327 assert!(!prefix.is_membership_mode('b'));
328 }
329
330 #[test]
331 fn rejects_a_prefix_whose_halves_do_not_match() {
332 assert!(Prefix::parse("(ov)@").is_none());
333 assert!(Prefix::parse("ov)@+").is_none());
334 assert!(Prefix::parse("()").is_none());
335 }
336
337 #[test]
338 fn a_malformed_prefix_keeps_the_previous_value() {
339 let isupport = isupport(&["PREFIX=(qaohv)~&@%+", "PREFIX=nonsense"]);
340 assert_eq!(isupport.prefix().mode_for_char('~'), Some('q'));
341 }
342
343 #[test]
344 fn splits_every_prefix_off_a_names_entry() {
345 let prefix = Prefix::parse("(qaohv)~&@%+").expect("valid prefix");
346 assert_eq!(prefix.split("@+nick"), ("@+", "nick"));
347 assert_eq!(prefix.split("nick"), ("", "nick"));
348 assert_eq!(prefix.split("~&@%+nick"), ("~&@%+", "nick"));
349 }
350
351 #[test]
352 fn parses_chanmodes_into_four_classes() {
353 let modes = ChanModes::parse("beI,k,l,imnpstn");
354 assert_eq!(modes.list, "beI");
355 assert_eq!(modes.always_arg, "k");
356 assert_eq!(modes.arg_on_set, "l");
357 assert_eq!(modes.flag, "imnpstn");
358 }
359
360 #[test]
361 fn a_short_chanmodes_leaves_later_classes_empty() {
362 let modes = ChanModes::parse("b,k");
363 assert_eq!(modes.arg_on_set, "");
364 assert_eq!(modes.flag, "");
365 }
366
367 #[test]
368 fn chantypes_defaults_when_the_server_is_silent() {
369 let isupport = Isupport::default();
370 assert!(isupport.is_channel("#chan"));
371 assert!(isupport.is_channel("&chan"));
372 assert!(!isupport.is_channel("^voice"));
373 }
374
375 #[test]
376 fn chantypes_covers_the_obby_voice_and_stream_prefixes() {
377 let isupport = isupport(&["CHANTYPES=#^$"]);
378 assert!(isupport.is_channel("^general"));
379 assert!(isupport.is_channel("$radio"));
380 assert!(!isupport.is_channel("&chan"));
381 assert!(!isupport.is_channel("nick"));
382 }
383
384 #[test]
385 fn a_negated_token_restores_the_default() {
386 let mut isupport = isupport(&["CASEMAPPING=ascii"]);
387 assert_eq!(isupport.casemapping(), Casemapping::Ascii);
388 isupport.apply("-CASEMAPPING");
389 assert_eq!(isupport.casemapping(), Casemapping::Rfc1459);
390 assert!(!isupport.has("CASEMAPPING"));
391 }
392
393 #[test]
394 fn reads_a_numeric_token() {
395 let isupport = isupport(&["LINELEN=1024", "MONITOR=100", "NETWORK=obby"]);
396 assert_eq!(isupport.number("LINELEN"), Some(1024));
397 assert_eq!(isupport.number("NETWORK"), None);
398 assert_eq!(isupport.number("MISSING"), None);
399 }
400
401 #[test]
402 fn reads_a_per_command_target_limit() {
403 let isupport = isupport(&["TARGMAX=PRIVMSG:4,WHOIS:1,JOIN:"]);
404 assert_eq!(isupport.targmax("PRIVMSG"), Some(4));
405 assert_eq!(isupport.targmax("privmsg"), Some(4));
406 assert_eq!(
407 isupport.targmax("JOIN"),
408 None,
409 "an empty limit means unlimited"
410 );
411 assert_eq!(isupport.targmax("KICK"), None);
412 }
413
414 #[test]
415 fn unescapes_a_hex_escape_in_a_value() {
416 assert_eq!(unescape(r"a\x20b"), "a b");
417 assert_eq!(unescape(r"a\x3Db"), "a=b");
418 assert_eq!(unescape("plain"), "plain");
419 }
420
421 #[test]
422 fn leaves_a_broken_escape_as_written() {
423 assert_eq!(unescape(r"a\xZZb"), r"a\xZZb");
424 assert_eq!(unescape(r"a\x2"), r"a\x2");
425 }
426
427 #[test]
428 fn appends_a_value_split_across_lines() {
429 let isupport = isupport(&["CHANTYPES=#", "CHANTYPES+=^$"]);
430 assert_eq!(isupport.get("CHANTYPES"), Some(Some("#^$")));
431 assert!(
432 isupport.is_channel("^voice"),
433 "the typed view sees the cumulative value"
434 );
435 assert!(isupport.is_channel("#chan"));
436 }
437
438 #[test]
439 fn appends_onto_nothing_when_the_token_was_absent() {
440 let isupport = isupport(&["ELIST+=CTU"]);
441 assert_eq!(isupport.get("ELIST"), Some(Some("CTU")));
442 }
443
444 #[test]
445 fn an_append_unescapes_each_fragment_before_joining() {
446 let isupport = isupport(&[r"NETWORK=obby", r"NETWORK+=\x20net"]);
447 assert_eq!(isupport.get("NETWORK"), Some(Some("obby net")));
448 }
449
450 #[test]
451 fn reports_the_name_without_its_grammar_markers() {
452 let mut isupport = Isupport::default();
453 assert_eq!(
454 isupport.apply("CHANTYPES=#"),
455 Token {
456 name: "CHANTYPES".to_owned(),
457 value: Some("#".to_owned()),
458 removed: false
459 }
460 );
461 assert_eq!(
462 isupport.apply("CHANTYPES+=^"),
463 Token {
464 name: "CHANTYPES".to_owned(),
465 value: Some("#^".to_owned()),
466 removed: false
467 },
468 "an append reports the accumulated value under the bare name"
469 );
470 assert_eq!(
471 isupport.apply("-CHANTYPES"),
472 Token {
473 name: "CHANTYPES".to_owned(),
474 value: None,
475 removed: true
476 },
477 "a removal reports the bare name, never the leading dash"
478 );
479 assert_eq!(
480 isupport.apply("SAFELIST"),
481 Token {
482 name: "SAFELIST".to_owned(),
483 value: None,
484 removed: false
485 }
486 );
487 }
488
489 #[test]
490 fn recognises_a_status_message_prefix() {
491 let isupport = isupport(&["STATUSMSG=@+"]);
492 assert!(isupport.is_statusmsg('@'));
493 assert!(!isupport.is_statusmsg('#'));
494 }
495}