Skip to main content

obby_proto/
mode.rs

1//! Parsing a MODE change.
2//!
3//! A mode letter takes an argument or not depending on its class in `CHANMODES` and on whether it is
4//! a membership mode from `PREFIX`. Get the arity wrong and every argument after the mistake is
5//! attached to the wrong letter, so this is driven entirely by what the server advertised.
6
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use crate::isupport::Isupport;
11
12/// One mode letter changing, with the argument it consumed.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
16pub struct ModeChange {
17    /// True for `+`, false for `-`.
18    pub set: bool,
19    /// The mode letter.
20    pub mode: char,
21    /// The argument, when this mode takes one in this direction.
22    pub arg: Option<String>,
23    /// True when this letter grants membership status rather than setting a channel mode, so a host
24    /// can route it to the member list instead of the channel.
25    pub membership: bool,
26}
27
28/// Split a channel MODE change into one entry per letter.
29///
30/// `params` is everything after the target: the mode string, then its arguments.
31///
32/// A letter the server never advertised is treated as taking no argument. Consuming one instead
33/// would shift every following argument onto the wrong letter, and a missing argument is the
34/// cheaper mistake to recover from.
35pub fn parse_channel_modes(isupport: &Isupport, params: &[String]) -> Vec<ModeChange> {
36    let mut args = params.iter().skip(1);
37    let Some(spec) = params.first() else {
38        return Vec::new();
39    };
40    let chanmodes = isupport.chanmodes();
41    let prefix = isupport.prefix();
42
43    let mut changes = Vec::new();
44    let mut set = true;
45    for mode in spec.chars() {
46        match mode {
47            '+' => set = true,
48            '-' => set = false,
49            _ => {
50                let membership = prefix.is_membership_mode(mode);
51                let takes_arg = membership
52                    || chanmodes.list.contains(mode)
53                    || chanmodes.always_arg.contains(mode)
54                    || (set && chanmodes.arg_on_set.contains(mode));
55                let arg = takes_arg.then(|| args.next().cloned()).flatten();
56                changes.push(ModeChange {
57                    set,
58                    mode,
59                    arg,
60                    membership,
61                });
62            }
63        }
64    }
65    changes
66}
67
68/// Split a user MODE change. User modes never take an argument.
69pub fn parse_user_modes(spec: &str) -> Vec<ModeChange> {
70    let mut changes = Vec::new();
71    let mut set = true;
72    for mode in spec.chars() {
73        match mode {
74            '+' => set = true,
75            '-' => set = false,
76            _ => changes.push(ModeChange {
77                set,
78                mode,
79                arg: None,
80                membership: false,
81            }),
82        }
83    }
84    changes
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use alloc::borrow::ToOwned;
91    use alloc::vec;
92
93    fn server() -> Isupport {
94        let mut isupport = Isupport::default();
95        isupport.apply("PREFIX=(qaohv)~&@%+");
96        isupport.apply("CHANMODES=beI,k,l,imnpst");
97        isupport
98    }
99
100    fn params(items: &[&str]) -> Vec<String> {
101        items.iter().map(|s| (*s).to_owned()).collect()
102    }
103
104    fn parse(items: &[&str]) -> Vec<ModeChange> {
105        parse_channel_modes(&server(), &params(items))
106    }
107
108    #[test]
109    fn a_flag_takes_no_argument() {
110        assert_eq!(
111            parse(&["+m"]),
112            vec![ModeChange {
113                set: true,
114                mode: 'm',
115                arg: None,
116                membership: false
117            }]
118        );
119    }
120
121    #[test]
122    fn a_list_mode_takes_an_argument_both_ways() {
123        let changes = parse(&["+b-b", "a!*@*", "b!*@*"]);
124        assert_eq!(changes[0].arg.as_deref(), Some("a!*@*"));
125        assert!(changes[0].set);
126        assert_eq!(changes[1].arg.as_deref(), Some("b!*@*"));
127        assert!(!changes[1].set);
128    }
129
130    #[test]
131    fn a_limit_takes_an_argument_only_when_set() {
132        assert_eq!(parse(&["+l", "50"])[0].arg.as_deref(), Some("50"));
133        assert_eq!(parse(&["-l"])[0].arg, None);
134    }
135
136    #[test]
137    fn a_key_takes_an_argument_both_ways() {
138        assert_eq!(parse(&["+k", "secret"])[0].arg.as_deref(), Some("secret"));
139        assert_eq!(parse(&["-k", "secret"])[0].arg.as_deref(), Some("secret"));
140    }
141
142    #[test]
143    fn membership_modes_are_flagged_and_take_a_nick() {
144        let changes = parse(&["+ov-v", "alice", "bob", "carol"]);
145        assert_eq!(
146            changes[0],
147            ModeChange {
148                set: true,
149                mode: 'o',
150                arg: Some("alice".to_owned()),
151                membership: true
152            }
153        );
154        assert_eq!(changes[1].arg.as_deref(), Some("bob"));
155        assert_eq!(
156            changes[2],
157            ModeChange {
158                set: false,
159                mode: 'v',
160                arg: Some("carol".to_owned()),
161                membership: true
162            }
163        );
164    }
165
166    #[test]
167    fn mixes_arities_in_one_line_without_shifting_arguments() {
168        let changes = parse(&["+ontl-k", "alice", "42", "oldkey"]);
169        assert_eq!(
170            changes[0].arg.as_deref(),
171            Some("alice"),
172            "o consumes a nick"
173        );
174        assert_eq!(changes[1].arg, None, "n is a flag");
175        assert_eq!(changes[2].arg, None, "t is a flag");
176        assert_eq!(
177            changes[3].arg.as_deref(),
178            Some("42"),
179            "l consumes a limit when set"
180        );
181        assert_eq!(
182            changes[4].arg.as_deref(),
183            Some("oldkey"),
184            "k consumes a key when unset"
185        );
186    }
187
188    #[test]
189    fn an_unknown_mode_consumes_nothing() {
190        let changes = parse(&["+Zo", "alice"]);
191        assert_eq!(changes[0].arg, None);
192        assert_eq!(
193            changes[1].arg.as_deref(),
194            Some("alice"),
195            "the nick still lands on o"
196        );
197    }
198
199    #[test]
200    fn a_missing_argument_leaves_the_mode_without_one() {
201        let changes = parse(&["+ov", "alice"]);
202        assert_eq!(changes[0].arg.as_deref(), Some("alice"));
203        assert_eq!(changes[1].arg, None);
204    }
205
206    #[test]
207    fn arity_follows_the_advertised_classes_not_a_hardcoded_table() {
208        let mut isupport = Isupport::default();
209        // a server that makes the usual flag `n` an argument mode instead
210        isupport.apply("CHANMODES=b,n,l,impst");
211        let changes = parse_channel_modes(&isupport, &params(&["+n", "value"]));
212        assert_eq!(changes[0].arg.as_deref(), Some("value"));
213    }
214
215    #[test]
216    fn an_empty_change_yields_nothing() {
217        assert!(parse(&[]).is_empty());
218        assert!(parse(&["+"]).is_empty());
219    }
220
221    #[test]
222    fn user_modes_never_take_arguments() {
223        assert_eq!(
224            parse_user_modes("+iw-o"),
225            vec![
226                ModeChange {
227                    set: true,
228                    mode: 'i',
229                    arg: None,
230                    membership: false
231                },
232                ModeChange {
233                    set: true,
234                    mode: 'w',
235                    arg: None,
236                    membership: false
237                },
238                ModeChange {
239                    set: false,
240                    mode: 'o',
241                    arg: None,
242                    membership: false
243                },
244            ]
245        );
246    }
247}