Skip to main content

serial_codec/
args.rs

1//! Argument splitting for one AT response / URC line (V.250 §5.4.2, 3GPP TS 27.007 §4).
2//!
3//! [`split`] cuts a parameter list on top-level commas. Commas inside a quoted string or inside
4//! parentheses (`(0-5)`, `(2,"Op","Op","00101",7)`) do not split. Each [`Arg`] keeps whether it was
5//! quoted, so `"0001"` (a string) and `0001` (a number) stay distinguishable.
6//!
7//! ```
8//! use serial_codec::args::{split, strip_prefix};
9//!
10//! let body = strip_prefix("+CMGL: 1,\"REC READ\",\"a,b\",,", "+CMGL").unwrap();
11//! let a = split(body);
12//! assert_eq!(a.len(), 5);
13//! assert_eq!(a[0].int::<u32>(), Some(1));
14//! assert_eq!(a[2].str(), Some("a,b"));
15//! assert!(a[3].is_empty() && a[4].is_empty());
16//! ```
17
18use std::ops::RangeInclusive;
19use std::str::FromStr;
20
21/// One comma-separated parameter.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Arg {
24    /// Unquoted, unescaped text for a quoted string; trimmed raw text otherwise
25    /// (parentheses kept for groups).
26    pub text: String,
27    /// The parameter was a `"..."` string.
28    pub quoted: bool,
29}
30
31impl Arg {
32    /// Omitted parameter (`,,`), not an empty quoted string `""`.
33    pub fn is_empty(&self) -> bool {
34        !self.quoted && self.text.is_empty()
35    }
36
37    /// Unquoted decimal integer (leading zeros allowed). Quoted digits are accepted too, as many
38    /// modems quote numeric values.
39    pub fn int<T: FromStr>(&self) -> Option<T> {
40        if self.text.is_empty() || !self.text.bytes().all(|b| b.is_ascii_digit()) {
41            return None;
42        }
43        self.text.parse().ok()
44    }
45
46    /// Hexadecimal value, quoted or not (`"8BE3"`, `00D30156`, `0x1F`).
47    pub fn hex(&self) -> Option<u64> {
48        let t = self.text.trim_start_matches("0x").trim_start_matches("0X");
49        if t.is_empty() || t.len() > 16 {
50            return None;
51        }
52        u64::from_str_radix(t, 16).ok()
53    }
54
55    /// Quoted string value; `None` for an omitted or unquoted parameter.
56    pub fn str(&self) -> Option<&str> {
57        self.quoted.then_some(self.text.as_str())
58    }
59
60    /// String value, quoted or not; `None` when omitted.
61    pub fn text(&self) -> Option<&str> {
62        (!self.is_empty()).then_some(self.text.as_str())
63    }
64
65    /// Contents of a parenthesised group split into arguments: `("SM","ME")` -> `SM`, `ME`.
66    /// `None` when the argument is not a group.
67    pub fn group(&self) -> Option<Vec<Arg>> {
68        if self.quoted {
69            return None;
70        }
71        let inner = self.text.strip_prefix('(')?;
72        let inner = inner.strip_suffix(')').unwrap_or(inner);
73        Some(split(inner))
74    }
75}
76
77/// Splits a parameter list on top-level commas.
78///
79/// Whitespace around parameters is dropped. Inside quotes `\HH` is the character with that hex
80/// code (V.250 §5.4.2.2, so a quote is `\22` and a backslash `\5C`); any other backslash is
81/// plain text and a quote always closes the string (`"C:\",1` is two parameters). An
82/// unterminated string runs to the end of the line. An empty input yields no parameters; `,` yields two empty ones.
83pub fn split(s: &str) -> Vec<Arg> {
84    let s = s.trim();
85    if s.is_empty() {
86        return Vec::new();
87    }
88    let mut out = Vec::new();
89    let mut cur = String::new();
90    let mut quoted = false;
91    let mut in_quote = false;
92    let mut depth = 0usize;
93    let mut chars = s.chars().peekable();
94    while let Some(c) = chars.next() {
95        if in_quote {
96            match c {
97                '"' => {
98                    in_quote = false;
99                    if depth > 0 {
100                        cur.push('"');
101                    }
102                }
103                '\\' => match chars.peek().copied() {
104                    Some(_) => {
105                        let mut look = chars.clone();
106                        let hex: String = look.by_ref().take(2).collect();
107                        match (depth, u8::from_str_radix(&hex, 16)) {
108                            (0, Ok(b))
109                                if hex.len() == 2 && hex.bytes().all(|b| b.is_ascii_hexdigit()) =>
110                            {
111                                cur.push(b as char);
112                                chars = look;
113                            }
114                            _ => cur.push('\\'),
115                        }
116                    }
117                    None => cur.push('\\'),
118                },
119                _ => cur.push(c),
120            }
121            continue;
122        }
123        match c {
124            '"' => {
125                in_quote = true;
126                if depth > 0 {
127                    cur.push('"');
128                } else {
129                    quoted = true;
130                    // Text before the opening quote is junk (` "x"`); only spaces are expected.
131                    cur.retain(|c| !c.is_whitespace());
132                }
133            }
134            '(' => {
135                depth += 1;
136                cur.push(c);
137            }
138            ')' => {
139                depth = depth.saturating_sub(1);
140                cur.push(c);
141            }
142            ',' if depth == 0 => {
143                out.push(finish(&mut cur, quoted));
144                quoted = false;
145            }
146            _ if quoted && depth == 0 => {} // junk after the closing quote (`"x" `)
147            _ => cur.push(c),
148        }
149    }
150    out.push(finish(&mut cur, quoted));
151    out
152}
153
154fn finish(cur: &mut String, quoted: bool) -> Arg {
155    let text = std::mem::take(cur);
156    Arg {
157        text: if quoted {
158            text
159        } else {
160            text.trim().to_string()
161        },
162        quoted,
163    }
164}
165
166/// Returns the text after `<prefix>:` when `line` starts with `prefix` (ASCII case-insensitive).
167/// Surrounding whitespace / CR LF and spaces before or after the colon are allowed.
168/// `strip_prefix("+CREG: 1", "+CREG")` is `Some("1")`; `"+CREGX: 1"` does not match.
169pub fn strip_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
170    let line = line.trim();
171    let head = line.get(..prefix.len())?;
172    if !head.eq_ignore_ascii_case(prefix) {
173        return None;
174    }
175    let rest = line[prefix.len()..].trim_start().strip_prefix(':')?;
176    Some(rest.trim())
177}
178
179/// Parses a 27.007 range list such as `0-1,3` or `(0-5)` into inclusive ranges.
180/// `None` on anything that is not digits, `-` and `,`.
181pub fn parse_ranges(s: &str) -> Option<Vec<RangeInclusive<u32>>> {
182    let s = s.trim();
183    let s = s.strip_prefix('(').unwrap_or(s);
184    let s = s.strip_suffix(')').unwrap_or(s).trim();
185    if s.is_empty() {
186        return Some(Vec::new());
187    }
188    s.split(',')
189        .map(|part| {
190            let part = part.trim();
191            let (a, b) = part.split_once('-').unwrap_or((part, part));
192            let a: u32 = a.trim().parse().ok()?;
193            let b: u32 = b.trim().parse().ok()?;
194            (a <= b).then_some(a..=b)
195        })
196        .collect()
197}
198
199/// [`parse_ranges`] expanded to the listed values; ranges wider than 1024 values are refused
200/// (`None`) so a hostile `(0-4294967295)` cannot allocate gigabytes.
201pub fn parse_values(s: &str) -> Option<Vec<u32>> {
202    let mut out = Vec::new();
203    for r in parse_ranges(s)? {
204        if r.end() - r.start() >= 1024 {
205            return None;
206        }
207        out.extend(r);
208    }
209    Some(out)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn texts(s: &str) -> Vec<(String, bool)> {
217        split(s).into_iter().map(|a| (a.text, a.quoted)).collect()
218    }
219
220    #[test]
221    fn splitting() {
222        assert!(split("").is_empty());
223        assert_eq!(texts(","), vec![("".into(), false), ("".into(), false)]);
224        assert_eq!(
225            texts(r#" 1 , "a,\22b\22\5C" ,, "" "#),
226            vec![
227                ("1".into(), false),
228                (r#"a,"b"\"#.into(), true),
229                ("".into(), false),
230                ("".into(), true)
231            ]
232        );
233        assert_eq!(texts(r#""\41\4""#), vec![("A\\4".into(), true)]);
234        assert_eq!(texts(r#""open, end"#), vec![("open, end".into(), true)]);
235        let g = split(r#"(2,"A,B","x",7),,(0-4)"#);
236        assert_eq!(g.len(), 3);
237        let inner = g[0].group().unwrap();
238        assert_eq!(inner[1].str(), Some("A,B"));
239        assert_eq!(inner[3].int::<u8>(), Some(7));
240        assert_eq!(parse_values(&g[2].text), Some(vec![0, 1, 2, 3, 4]));
241    }
242
243    /// V.250 §5.4.2.2 defines only `\HH` inside strings: a lone backslash is text.
244    #[test]
245    fn backslash_is_not_a_quote_escape() {
246        assert_eq!(
247            texts(r#""C:\",1,"x""#),
248            vec![
249                ("C:\\".into(), true),
250                ("1".into(), false),
251                ("x".into(), true)
252            ]
253        );
254        assert_eq!(texts(r#""a\22b""#), vec![("a\"b".into(), true)]);
255    }
256
257    #[test]
258    fn values_and_prefix() {
259        let a = split(r#"0011,"8BE3",a3fb477,"12""#);
260        assert_eq!(a[0].int::<u8>(), Some(11));
261        assert_eq!(a[1].hex(), Some(0x8be3));
262        assert_eq!(a[2].hex(), Some(0xa3fb477));
263        assert_eq!(a[3].int::<u8>(), Some(12));
264        assert_eq!(a[1].int::<u8>(), None);
265        assert_eq!(strip_prefix("\r\n+creg : 1\r\n", "+CREG"), Some("1"));
266        assert_eq!(strip_prefix("+CREGX: 1", "+CREG"), None);
267        assert_eq!(strip_prefix("+CR", "+CREG"), None);
268        assert_eq!(parse_ranges("(0-1,3)"), Some(vec![0..=1, 3..=3]));
269        assert_eq!(parse_ranges("1-0"), None);
270        assert_eq!(parse_values("0-100000"), None);
271    }
272}