Skip to main content

obby_proto/
isupport.rs

1//! The `005` ISUPPORT tokens, and the typed settings they drive.
2//!
3//! Everything a client does with names and modes depends on these: which characters start a channel,
4//! which modes take an argument, which prefix outranks which. A client that assumes defaults instead
5//! of reading them is wrong on any server that is not the one it was written against.
6
7use alloc::borrow::ToOwned;
8use alloc::collections::BTreeMap;
9use alloc::string::String;
10use alloc::vec::Vec;
11
12use crate::casemap::{CaseFolded, Casemapping};
13
14/// The membership prefixes a server uses, in rank order, highest first.
15///
16/// Parsed from `PREFIX=(ov)@+`, which pairs each mode letter with the character that shows it in a
17/// NAMES reply.
18#[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    /// Parse a `PREFIX` value. A malformed value leaves the default in place, because guessing here
35    /// silently corrupts every member list.
36    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    /// The prefix character a mode letter grants, such as `o` giving `@`.
49    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    /// The mode letter a prefix character stands for, such as `@` meaning `o`.
55    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    /// True when this mode letter grants membership status rather than setting a channel mode.
61    pub fn is_membership_mode(&self, mode: char) -> bool {
62        self.modes.contains(&mode)
63    }
64
65    /// How highly a prefix ranks, counting from zero for the highest. Used to sort a member list.
66    pub fn rank(&self, prefix: char) -> Option<usize> {
67        self.chars.iter().position(|c| *c == prefix)
68    }
69
70    /// Split the leading prefixes off a NAMES entry, giving `("@+", "nick")`.
71    ///
72    /// With `multi-prefix` an entry carries every prefix the member holds; without it, only the
73    /// highest. Both split the same way.
74    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/// The four classes of channel mode, from `CHANMODES=beI,k,l,imnpst`.
84///
85/// The class decides whether a mode takes an argument, which is the only way to parse a MODE line at
86/// all.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ChanModes {
89    /// List modes such as bans. Always take an argument, setting and unsetting.
90    pub list: String,
91    /// Modes that always take an argument, such as a key.
92    pub always_arg: String,
93    /// Modes that take an argument only when set, such as a user limit.
94    pub arg_on_set: String,
95    /// Flags that never take an argument.
96    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    /// Parse a `CHANMODES` value. Classes past the fourth are ignored, as the specification says to
112    /// treat them as flags we do not know about.
113    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/// Everything the server told us about itself in `005`.
125#[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/// One token after the wire grammar has been read off it.
136#[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    /// The token name, with any `-` prefix or `+` append marker already removed.
141    pub name: String,
142    /// The value now in force, which for an append is the whole accumulated value.
143    pub value: Option<String>,
144    /// True when the server withdrew the token.
145    pub removed: bool,
146}
147
148impl Isupport {
149    /// Apply one token.
150    ///
151    /// A leading `-` removes the token and restores the default. A `+=` in place of `=` appends to
152    /// the value already held, which is how `draft/extended-isupport-0.2` carries a value too long
153    /// for one line. The typed views always see the cumulative value, never the fragment.
154    ///
155    /// Returns what the token turned out to mean, so a caller never has to read the wire grammar a
156    /// second time to find out.
157    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            // a malformed PREFIX keeps the previous value rather than clearing the member list
200            "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    /// The raw value of a token, `None` if the token is absent, `Some(None)` if it is a bare flag.
219    pub fn get(&self, name: &str) -> Option<Option<&str>> {
220        self.tokens.get(name).map(Option::as_deref)
221    }
222
223    /// True when the server advertised this token at all.
224    pub fn has(&self, name: &str) -> bool {
225        self.tokens.contains_key(name)
226    }
227
228    /// A token whose value is a number, such as `LINELEN` or `MONITOR`.
229    pub fn number(&self, name: &str) -> Option<u32> {
230        self.get(name)?.and_then(|value| value.parse().ok())
231    }
232
233    /// How the server folds case.
234    pub fn casemapping(&self) -> Casemapping {
235        self.casemapping
236    }
237
238    /// Fold a nick or channel name into a key.
239    pub fn fold(&self, name: &str) -> CaseFolded {
240        self.casemapping.fold(name)
241    }
242
243    /// The membership prefixes.
244    pub fn prefix(&self) -> &Prefix {
245        &self.prefix
246    }
247
248    /// The channel mode classes.
249    pub fn chanmodes(&self) -> &ChanModes {
250        &self.chanmodes
251    }
252
253    /// True when this name starts with a character the server treats as a channel prefix.
254    ///
255    /// `CHANTYPES` defaults to `#&` when absent, and Obby adds `^` for voice channels and `$` for
256    /// stream channels, which arrive in this token like any other.
257    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    /// True when this character targets a channel subset, as in `@#channel`.
267    pub fn is_statusmsg(&self, c: char) -> bool {
268        self.statusmsg.contains(c)
269    }
270
271    /// The most messages of one command that may share a line, from `TARGMAX`.
272    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
282/// Undo the `\xHH` escaping an ISUPPORT value may carry.
283///
284/// A value cannot hold a space or an equals sign directly, so a server that needs one sends its hex
285/// code. An incomplete or invalid escape is left as written.
286fn 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}