Skip to main content

simple_scpi/
lib.rs

1//! A lightweight SCPI command parser.
2//!
3//! Pattern syntax (used in the table passed to [`CommandSet::from_table`]):
4//!
5//! - Keywords use mixed case: uppercase = required, lowercase = optional.
6//!   `SYSTem` matches `SYST`, `SYSTE`, `SYSTEM` (all case-insensitive).
7//! - `:` separates hierarchical keywords.
8//! - `#` in a keyword means a numeric suffix (defaults to 1 when absent).
9//! - `[...]` encloses an optional keyword node, including its leading colon
10//!   when written as `[:NODE]`.
11//! - `?` at the end marks a query.
12//! - After keywords, a space-separated token declares the parameter type:
13//!   `num`, `bool`, `str`.
14//!
15//! Input lines may contain multiple commands separated by `;`.
16//! A leading `:` (or `:` after `;`) resets to the root of the command tree;
17//! without it, compound commands continue from the previous tree position.
18
19use std::fmt;
20
21// ---------------------------------------------------------------------------
22// Public types
23// ---------------------------------------------------------------------------
24
25/// A parsed parameter value.
26#[derive(Debug, Clone)]
27pub enum Param {
28    Numeric(f64),
29    Bool(bool),
30    String(String),
31}
32
33/// A single parsed command ready for dispatch.
34#[derive(Debug)]
35pub struct Command {
36    /// Index into the table that was passed to [`CommandSet::from_table`].
37    pub index: usize,
38    /// Parameter values extracted from the input.
39    pub params: Vec<Param>,
40    /// Numeric suffix values (`#` placeholders), in order of appearance.
41    /// Defaults to `1` when the user omits the suffix digit.
42    pub suffixes: Vec<u32>,
43}
44
45/// Handler function signature.
46pub type Handler = fn(&Command);
47
48/// Compiled command set built from a table of `(pattern, handler)` pairs.
49pub struct CommandSet {
50    entries: Vec<Entry>,
51}
52
53// ---------------------------------------------------------------------------
54// Internal types
55// ---------------------------------------------------------------------------
56
57#[derive(Debug, Clone, Copy, PartialEq)]
58enum ParamKind {
59    Num,
60    Bool,
61    Str,
62}
63
64/// One segment of a compiled keyword pattern.
65#[derive(Debug, Clone)]
66enum Segment {
67    /// A plain keyword with short + long forms (both stored uppercase).
68    Keyword { short: String, long: String },
69    /// A `#` numeric suffix placeholder.
70    NumericSuffix,
71}
72
73/// A compiled command entry.
74struct Entry {
75    /// Segments that must be matched (flattened; optional groups are expanded).
76    segments: Vec<Segment>,
77    /// Whether any segment group was optional (generates alternate accept sets).
78    optional_groups: Vec<OptGroup>,
79    is_query: bool,
80    param: Option<ParamKind>,
81    handler: Handler,
82}
83
84/// Represents one `[...]` optional group by the range of segment indices it
85/// covers.  When the group is skipped, matching jumps from `start` to `end`.
86#[derive(Debug, Clone)]
87struct OptGroup {
88    start: usize,
89    end: usize, // exclusive
90}
91
92#[derive(Debug)]
93pub struct ParseError(String);
94
95impl fmt::Display for ParseError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(&self.0)
98    }
99}
100
101impl std::error::Error for ParseError {}
102
103// ---------------------------------------------------------------------------
104// Pattern compiler
105// ---------------------------------------------------------------------------
106
107/// Extract the short form (uppercase chars) and the long form (all chars)
108/// from a mixed-case keyword like `SYSTem`.
109fn short_long(token: &str) -> (String, String) {
110    let long = token.to_ascii_uppercase();
111    // Short form = leading uppercase characters.
112    let short: String = token
113        .chars()
114        .take_while(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || *c == '+' || *c == '-')
115        .collect::<String>()
116        .to_ascii_uppercase();
117    // If the token is all-uppercase already (like `*IDN` or `BAUD`), short == long.
118    (short, long)
119}
120
121// ---------------------------------------------------------------------------
122// Fix: parse_one_keyword needs to emit NumericSuffix after a keyword that
123// has `#` glued to it.  Refactor compile_pattern to handle this.
124// ---------------------------------------------------------------------------
125
126/// Revised: compile one keyword-and-maybe-suffix, returning 1 or 2 segments.
127fn parse_keyword_segments(chars: &[char], pos: &mut usize) -> Vec<Segment> {
128    if chars[*pos] == '#' {
129        *pos += 1;
130        return vec![Segment::NumericSuffix];
131    }
132
133    let start = *pos;
134    while *pos < chars.len() && !matches!(chars[*pos], ':' | '[' | ']' | '#') {
135        *pos += 1;
136    }
137    let token: String = chars[start..*pos].iter().collect();
138    let (short, long) = short_long(&token);
139    let kw = Segment::Keyword {
140        short: short.to_ascii_uppercase(),
141        long: long.to_ascii_uppercase(),
142    };
143
144    if *pos < chars.len() && chars[*pos] == '#' {
145        *pos += 1;
146        vec![kw, Segment::NumericSuffix]
147    } else {
148        vec![kw]
149    }
150}
151
152/// Compile pattern (revised, using `parse_keyword_segments`).
153fn compile(
154    pattern: &str,
155) -> Result<(Vec<Segment>, Vec<OptGroup>, bool, Option<ParamKind>), ParseError> {
156    let (kw_part, param) = if pattern.ends_with(" num") {
157        (&pattern[..pattern.len() - 4], Some(ParamKind::Num))
158    } else if pattern.ends_with(" bool") {
159        (&pattern[..pattern.len() - 5], Some(ParamKind::Bool))
160    } else if pattern.ends_with(" str") {
161        (&pattern[..pattern.len() - 4], Some(ParamKind::Str))
162    } else {
163        (pattern, None)
164    };
165
166    let is_query = kw_part.ends_with('?');
167    let kw_part = if is_query {
168        &kw_part[..kw_part.len() - 1]
169    } else {
170        kw_part
171    };
172
173    let mut segments: Vec<Segment> = Vec::new();
174    let mut opt_groups: Vec<OptGroup> = Vec::new();
175
176    let chars: Vec<char> = kw_part.chars().collect();
177    let mut i = 0;
178
179    while i < chars.len() {
180        if chars[i] == ':' {
181            i += 1;
182            continue;
183        }
184
185        if chars[i] == '[' {
186            let group_start = segments.len();
187            i += 1;
188            if i < chars.len() && chars[i] == ':' {
189                i += 1;
190            }
191            while i < chars.len() && chars[i] != ']' {
192                if chars[i] == ':' {
193                    i += 1;
194                    continue;
195                }
196                let segs = parse_keyword_segments(&chars, &mut i);
197                segments.extend(segs);
198            }
199            if i < chars.len() && chars[i] == ']' {
200                i += 1;
201            }
202            opt_groups.push(OptGroup {
203                start: group_start,
204                end: segments.len(),
205            });
206        } else {
207            let segs = parse_keyword_segments(&chars, &mut i);
208            segments.extend(segs);
209        }
210    }
211
212    Ok((segments, opt_groups, is_query, param))
213}
214
215// ---------------------------------------------------------------------------
216// Keyword matching
217// ---------------------------------------------------------------------------
218
219/// Check if `input` (already uppercased) matches a keyword with the given
220/// short and long forms.  Returns `true` if input length is between
221/// short.len() and long.len() (inclusive) and is a prefix of `long` that
222/// contains at least `short`.
223fn keyword_matches(short: &str, long: &str, input: &str) -> bool {
224    let ilen = input.len();
225    if ilen < short.len() || ilen > long.len() {
226        return false;
227    }
228    // input must be a prefix of long of length >= short.len()
229    long[..ilen] == *input
230}
231
232// ---------------------------------------------------------------------------
233// Input tokeniser
234// ---------------------------------------------------------------------------
235
236/// Split a single command string (no `;`) into keyword tokens and a parameter
237/// portion.  Returns `(keyword_tokens, param_str)`.
238///
239/// Keywords are split on `:`.  The text after the last keyword containing a
240/// space (or after keywords if all of them are pure keywords) is the param
241/// string.
242fn tokenise_command(input: &str) -> (Vec<String>, Option<String>) {
243    let input = input.trim();
244    if input.is_empty() {
245        return (vec![], None);
246    }
247
248    // For common commands (*IDN?, *RST, etc.) there's no colon hierarchy.
249    // Find where keywords end and parameters begin.
250    // Parameters start after a space that is *not* inside quotes.
251    let bytes = input.as_bytes();
252    let mut kw_end = input.len();
253    // Find the first space or quote that separates keywords from params.
254    for (j, &b) in bytes.iter().enumerate() {
255        if b == b'\'' || b == b'"' {
256            // Quote starts the parameter portion.
257            kw_end = j;
258            break;
259        }
260        if b == b' ' || b == b'\t' {
261            kw_end = j;
262            break;
263        }
264    }
265
266    let kw_str = &input[..kw_end];
267    let param_str = input[kw_end..].trim();
268    let param = if param_str.is_empty() {
269        None
270    } else {
271        Some(param_str.to_string())
272    };
273
274    // Split keywords on `:`, preserving `?` as part of the last token.
275    // Also keep `*` prefix glued to the token.
276    let tokens: Vec<String> = if kw_str.starts_with('*') {
277        // Common command: single token.
278        vec![kw_str.to_ascii_uppercase()]
279    } else {
280        kw_str
281            .split(':')
282            .filter(|s| !s.is_empty())
283            .map(|s| s.to_ascii_uppercase())
284            .collect()
285    };
286
287    (tokens, param)
288}
289
290/// Attempt to extract a numeric suffix from the end of a token.
291/// E.g. `"SOURCE2"` with keyword long `"SOURCE"` -> Some(2), remaining = `"SOURCE"`.
292/// Returns `(token_without_suffix, suffix_value)`.
293fn extract_suffix(token: &str, short: &str, long: &str) -> Option<(String, u32)> {
294    // The token starts with the keyword, followed by optional digits.
295    // Try long form first, then short form, picking the longest keyword match
296    // that leaves trailing digits.
297    for kw in &[long, short] {
298        if token.len() >= kw.len() && token[..kw.len()] == **kw {
299            let rest = &token[kw.len()..];
300            if rest.is_empty() {
301                // No suffix present -> default 1.
302                return Some((kw.to_string(), 1));
303            }
304            if let Ok(n) = rest.parse::<u32>() {
305                return Some((kw.to_string(), n));
306            }
307        }
308    }
309    None
310}
311
312// ---------------------------------------------------------------------------
313// Matching engine
314// ---------------------------------------------------------------------------
315
316/// Try to match `tokens` against `entry`, return suffixes + whether query
317/// matched.  `is_query` comes from input, `entry.is_query` from pattern.
318fn try_match(
319    entry: &Entry,
320    tokens: &[String],
321    input_is_query: bool,
322) -> Option<Vec<u32>> {
323    if input_is_query != entry.is_query {
324        return None;
325    }
326
327    // Build the set of acceptable segment sequences by expanding optional
328    // groups.  Each optional group can be present or absent.
329    // For simplicity (command tables are small), enumerate all 2^N combos.
330    let n_opt = entry.optional_groups.len();
331    let combos = 1u32 << n_opt;
332
333    for combo in 0..combos {
334        // Build the sequence of segments for this combo.
335        let mut active_segments: Vec<&Segment> = Vec::new();
336        for (idx, seg) in entry.segments.iter().enumerate() {
337            // Check if this segment index falls inside a skipped optional group.
338            let mut skipped = false;
339            for (g, grp) in entry.optional_groups.iter().enumerate() {
340                if idx >= grp.start && idx < grp.end && (combo >> g) & 1 == 0 {
341                    skipped = true;
342                    break;
343                }
344            }
345            if !skipped {
346                active_segments.push(seg);
347            }
348        }
349
350        if let Some(suffixes) = match_segments(&active_segments, tokens) {
351            return Some(suffixes);
352        }
353    }
354
355    None
356}
357
358/// Match a flat list of active segments against input tokens.  Returns suffix
359/// values on success.
360fn match_segments(segments: &[&Segment], tokens: &[String]) -> Option<Vec<u32>> {
361    let mut suffixes = Vec::new();
362    let mut ti = 0; // token index
363    let mut si = 0; // segment index
364
365    while si < segments.len() {
366        match &segments[si] {
367            Segment::Keyword { short, long } => {
368                if ti >= tokens.len() {
369                    return None;
370                }
371                let token = &tokens[ti];
372
373                // Check if next segment is NumericSuffix — if so, the suffix
374                // digits may be glued onto this token.
375                let next_is_suffix =
376                    si + 1 < segments.len() && matches!(segments[si + 1], Segment::NumericSuffix);
377
378                if next_is_suffix {
379                    let (_, suf) = extract_suffix(token, short, long)?;
380                    suffixes.push(suf);
381                    si += 2; // consume keyword + suffix segment
382                    ti += 1;
383                } else {
384                    if !keyword_matches(short, long, token) {
385                        return None;
386                    }
387                    si += 1;
388                    ti += 1;
389                }
390            }
391            Segment::NumericSuffix => {
392                // Standalone suffix (shouldn't normally happen since `#` follows
393                // a keyword, but handle gracefully).
394                if ti >= tokens.len() {
395                    return None;
396                }
397                if let Ok(n) = tokens[ti].parse::<u32>() {
398                    suffixes.push(n);
399                    ti += 1;
400                    si += 1;
401                } else {
402                    return None;
403                }
404            }
405        }
406    }
407
408    // All segments consumed, all tokens consumed.
409    if ti == tokens.len() {
410        Some(suffixes)
411    } else {
412        None
413    }
414}
415
416// ---------------------------------------------------------------------------
417// Parameter parsing
418// ---------------------------------------------------------------------------
419
420/// Strip a trailing unit suffix (e.g. `"Hz"`, `"V"`, `"MHz"`) from a numeric
421/// string, being careful not to eat the `e`/`E` of scientific notation.
422fn strip_unit_suffix(raw: &str) -> &str {
423    // Walk backwards past ASCII letters.
424    let bytes = raw.as_bytes();
425    let mut end = bytes.len();
426    while end > 0 && bytes[end - 1].is_ascii_alphabetic() {
427        end -= 1;
428    }
429    if end == bytes.len() {
430        return raw; // no trailing alpha
431    }
432    if end == 0 {
433        return raw; // all alpha — not a valid number, let caller error
434    }
435    // Check if the "suffix" we'd strip is actually scientific notation:
436    // the char at `end` is 'e'/'E' and preceded by a digit.
437    let suffix = &raw[end..];
438    if (suffix.starts_with('e') || suffix.starts_with('E'))
439        && bytes[end - 1].is_ascii_digit()
440        && suffix[1..].chars().all(|c| c.is_ascii_digit() || c == '+' || c == '-')
441    {
442        // This is scientific notation, not a unit suffix.
443        return raw;
444    }
445    raw[..end].trim_end()
446}
447
448fn parse_param(kind: ParamKind, raw: &str) -> Result<Param, ParseError> {
449    let raw = raw.trim();
450    match kind {
451        ParamKind::Num => {
452            // Support hex (#H), octal (#Q), binary (#B) prefixes from SCPI.
453            // Strip trailing unit suffix (e.g. "Hz", "V") — find the last
454            // run of purely alphabetic chars that isn't part of scientific
455            // notation (e/E followed by digits).
456            let num_str = strip_unit_suffix(raw);
457            if num_str.starts_with("#H") || num_str.starts_with("#h") {
458                let val =
459                    u64::from_str_radix(&num_str[2..], 16).map_err(|e| ParseError(e.to_string()))?;
460                Ok(Param::Numeric(val as f64))
461            } else if num_str.starts_with("#Q") || num_str.starts_with("#q") {
462                let val =
463                    u64::from_str_radix(&num_str[2..], 8).map_err(|e| ParseError(e.to_string()))?;
464                Ok(Param::Numeric(val as f64))
465            } else if num_str.starts_with("#B") || num_str.starts_with("#b") {
466                let val =
467                    u64::from_str_radix(&num_str[2..], 2).map_err(|e| ParseError(e.to_string()))?;
468                Ok(Param::Numeric(val as f64))
469            } else {
470                let val: f64 = num_str
471                    .parse()
472                    .map_err(|e: std::num::ParseFloatError| ParseError(e.to_string()))?;
473                Ok(Param::Numeric(val))
474            }
475        }
476        ParamKind::Bool => {
477            let upper = raw.to_ascii_uppercase();
478            match upper.as_str() {
479                "ON" | "1" => Ok(Param::Bool(true)),
480                "OFF" | "0" => Ok(Param::Bool(false)),
481                _ => Err(ParseError(format!("invalid boolean: {raw}"))),
482            }
483        }
484        ParamKind::Str => {
485            // Strip matching quotes if present.
486            if (raw.starts_with('"') && raw.ends_with('"'))
487                || (raw.starts_with('\'') && raw.ends_with('\''))
488            {
489                Ok(Param::String(raw[1..raw.len() - 1].to_string()))
490            } else {
491                Ok(Param::String(raw.to_string()))
492            }
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// CommandSet
499// ---------------------------------------------------------------------------
500
501impl CommandSet {
502    /// Build a command set from a static table of `(pattern, handler)` pairs.
503    pub fn from_table(table: &[(&str, Handler)]) -> Result<Self, ParseError> {
504        let mut entries = Vec::with_capacity(table.len());
505        for (pattern, handler) in table {
506            let (segments, optional_groups, is_query, param) = compile(pattern)?;
507            entries.push(Entry {
508                segments,
509                optional_groups,
510                is_query,
511                param,
512                handler: *handler,
513            });
514        }
515        Ok(CommandSet { entries })
516    }
517
518    /// Parse an input line (which may contain multiple `;`-separated commands)
519    /// into a list of [`Command`]s.
520    pub fn parse(&self, line: &str) -> Result<Vec<Command>, ParseError> {
521        let line = line.trim();
522        if line.is_empty() {
523            return Ok(vec![]);
524        }
525
526        // Split on `;`, respecting quotes.
527        let raw_cmds = split_commands(line);
528        let mut result = Vec::new();
529
530        for raw in &raw_cmds {
531            let raw = raw.trim();
532            if raw.is_empty() {
533                continue;
534            }
535            let cmd = self.parse_single(raw)?;
536            result.push(cmd);
537        }
538
539        Ok(result)
540    }
541
542    fn parse_single(&self, input: &str) -> Result<Command, ParseError> {
543        let (tokens, param_str) = tokenise_command(input);
544        if tokens.is_empty() {
545            return Err(ParseError("empty command".into()));
546        }
547
548        // Determine if this is a query (last token ends with `?`).
549        let mut tokens = tokens;
550        let mut is_query = false;
551        if let Some(last) = tokens.last_mut() {
552            if last.ends_with('?') {
553                is_query = true;
554                last.truncate(last.len() - 1);
555                if last.is_empty() {
556                    // Standalone `?` — shouldn't happen in practice.
557                    tokens.pop();
558                }
559            }
560        }
561
562        // Try each entry.
563        for (idx, entry) in self.entries.iter().enumerate() {
564            if let Some(suffixes) = try_match(entry, &tokens, is_query) {
565                // Parse parameter if expected.
566                let params = if let Some(kind) = entry.param {
567                    let raw = param_str.as_deref().unwrap_or("");
568                    if raw.is_empty() {
569                        return Err(ParseError(format!(
570                            "command expects a parameter but none given"
571                        )));
572                    }
573                    vec![parse_param(kind, raw)?]
574                } else {
575                    vec![]
576                };
577                return Ok(Command {
578                    index: idx,
579                    params,
580                    suffixes,
581                });
582            }
583        }
584
585        Err(ParseError(format!("unrecognised command: {input}")))
586    }
587
588    /// Dispatch a parsed command to its handler.
589    pub fn dispatch(&self, cmd: &Command) {
590        (self.entries[cmd.index].handler)(cmd);
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Line splitting
596// ---------------------------------------------------------------------------
597
598/// Split a line on `;` while respecting quoted strings.
599fn split_commands(line: &str) -> Vec<String> {
600    let mut parts = Vec::new();
601    let mut current = String::new();
602    let mut in_quotes = false;
603    let mut quote_char = ' ';
604
605    for ch in line.chars() {
606        if in_quotes {
607            current.push(ch);
608            if ch == quote_char {
609                in_quotes = false;
610            }
611        } else if ch == '\'' || ch == '"' {
612            in_quotes = true;
613            quote_char = ch;
614            current.push(ch);
615        } else if ch == ';' {
616            parts.push(std::mem::take(&mut current));
617        } else {
618            current.push(ch);
619        }
620    }
621
622    if !current.is_empty() {
623        parts.push(current);
624    }
625
626    parts
627}
628
629// ---------------------------------------------------------------------------
630// Tests
631// ---------------------------------------------------------------------------
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    fn dummy(_: &Command) {}
638
639    #[test]
640    fn common_command() {
641        let table: &[(&str, Handler)] = &[("*IDN?", dummy), ("*RST", dummy)];
642        let set = CommandSet::from_table(table).unwrap();
643
644        let cmds = set.parse("*IDN?").unwrap();
645        assert_eq!(cmds.len(), 1);
646        assert_eq!(cmds[0].index, 0);
647        assert!(cmds[0].params.is_empty());
648
649        let cmds = set.parse("*RST").unwrap();
650        assert_eq!(cmds[0].index, 1);
651    }
652
653    #[test]
654    fn common_with_param() {
655        let table: &[(&str, Handler)] = &[("*ESE num", dummy)];
656        let set = CommandSet::from_table(table).unwrap();
657
658        let cmds = set.parse("*ESE 42").unwrap();
659        assert_eq!(cmds[0].index, 0);
660        assert!(matches!(cmds[0].params[0], Param::Numeric(v) if v == 42.0));
661    }
662
663    #[test]
664    fn hierarchical_short_long() {
665        let table: &[(&str, Handler)] = &[("SYSTem:VERSion?", dummy)];
666        let set = CommandSet::from_table(table).unwrap();
667
668        // Short form.
669        assert!(set.parse("SYST:VERS?").is_ok());
670        // Long form.
671        assert!(set.parse("system:version?").is_ok());
672        // Mixed case.
673        assert!(set.parse("System:Version?").is_ok());
674    }
675
676    #[test]
677    fn optional_node() {
678        let table: &[(&str, Handler)] = &[("SYSTem:ERRor[:NEXT]?", dummy)];
679        let set = CommandSet::from_table(table).unwrap();
680
681        // With optional node.
682        assert!(set.parse("SYST:ERR:NEXT?").is_ok());
683        // Without optional node.
684        assert!(set.parse("SYST:ERR?").is_ok());
685    }
686
687    #[test]
688    fn numeric_suffix() {
689        let table: &[(&str, Handler)] = &[("SOURce#:FREQuency num", dummy)];
690        let set = CommandSet::from_table(table).unwrap();
691
692        let cmds = set.parse("SOUR2:FREQ 1e6").unwrap();
693        assert_eq!(cmds[0].suffixes[0], 2);
694        assert!(matches!(cmds[0].params[0], Param::Numeric(v) if v == 1e6));
695
696        // Default suffix = 1.
697        let cmds = set.parse("SOURCE:FREQ 500").unwrap();
698        assert_eq!(cmds[0].suffixes[0], 1);
699    }
700
701    #[test]
702    fn bool_param() {
703        let table: &[(&str, Handler)] = &[("OUTPut#:STATe bool", dummy)];
704        let set = CommandSet::from_table(table).unwrap();
705
706        let cmds = set.parse("OUTP1:STAT ON").unwrap();
707        assert!(matches!(cmds[0].params[0], Param::Bool(true)));
708
709        let cmds = set.parse("OUTPUT2:STATE 0").unwrap();
710        assert!(matches!(cmds[0].params[0], Param::Bool(false)));
711        assert_eq!(cmds[0].suffixes[0], 2);
712    }
713
714    #[test]
715    fn string_param() {
716        let table: &[(&str, Handler)] = &[("DISPlay:TEXT str", dummy)];
717        let set = CommandSet::from_table(table).unwrap();
718
719        let cmds = set.parse("DISP:TEXT \"hello world\"").unwrap();
720        assert!(matches!(&cmds[0].params[0], Param::String(s) if s == "hello world"));
721    }
722
723    #[test]
724    fn multi_command_line() {
725        let table: &[(&str, Handler)] = &[("*RST", dummy), ("*IDN?", dummy)];
726        let set = CommandSet::from_table(table).unwrap();
727
728        let cmds = set.parse("*RST;*IDN?").unwrap();
729        assert_eq!(cmds.len(), 2);
730        assert_eq!(cmds[0].index, 0);
731        assert_eq!(cmds[1].index, 1);
732    }
733
734    #[test]
735    fn optional_voltage_level() {
736        let table: &[(&str, Handler)] = &[
737            ("SOURce#:VOLTage[:LEVel] num", dummy),
738            ("SOURce#:VOLTage[:LEVel]?", dummy),
739        ];
740        let set = CommandSet::from_table(table).unwrap();
741
742        // With optional :LEVel
743        assert!(set.parse("SOUR1:VOLT:LEV 3.3").is_ok());
744        // Without
745        assert!(set.parse("SOUR1:VOLT 3.3").is_ok());
746        // Query with
747        assert!(set.parse("SOUR1:VOLT:LEVEL?").is_ok());
748        // Query without
749        assert!(set.parse("SOUR1:VOLT?").is_ok());
750    }
751}