Skip to main content

rd_ast/
option.rs

1//! Parsing for Sweave-style `\Sexpr` option lists.
2//!
3//! The option storage/view contract is in the crate's included `CONTRACT.md`.
4//!
5//! The grammar is a comma-separated list of scalar `key=value` entries. It
6//! has no quoting, escaping, or nesting. Parsed pairs are preserved (with
7//! surrounding ASCII whitespace removed) while unknown keys, duplicates, and
8//! invalid typed values are collected as soft diagnostics. Typed resolution
9//! uses the last valid value for each key. This grammar is distinct from the
10//! `\link` and `\item` option shapes; those have their own semantic views.
11
12use std::{borrow::Cow, fmt};
13
14use crate::{RdNode, RdNodeKind, RdPath, RdShapeError, RdShapeErrorKind};
15
16/// A parsed, borrowed Rd option list and its non-fatal diagnostics.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RdOptionList<'a> {
19    path: RdPath,
20    pairs: Vec<RdOptionPair<'a>>,
21    diagnostics: Vec<RdOptionError>,
22    _nodes: std::marker::PhantomData<&'a [RdNode]>,
23}
24
25impl<'a> RdOptionList<'a> {
26    /// Parses plain string leaves as a comma-separated scalar option list.
27    ///
28    /// R's `parse_Rd()` represents a `\\RdOpts` body as a `VERB` leaf while
29    /// `\\Sexpr`'s `Rd_option` content is a `TEXT` leaf. Both are plain string
30    /// leaves, so both kinds are concatenated in source order without
31    /// normalization.
32    ///
33    /// `path` is the path of the option-content container supplied by the
34    /// caller. A future `\\Sexpr` view passes `base_path.with_option()`; a
35    /// future `\\RdOpts` view passes its own tagged-node path whose children
36    /// are the option text. Pair positions are reported by `pair_index`, not
37    /// by adding path segments.
38    pub fn parse(nodes: &'a [RdNode], path: RdPath) -> Result<Self, RdOptionError> {
39        let mut text = String::new();
40        for (index, node) in nodes.iter().enumerate() {
41            match node {
42                RdNode::Text(value) | RdNode::Verb(value) => text.push_str(value),
43                other => {
44                    return Err(RdShapeError::new(
45                        path.with_child(index),
46                        None,
47                        RdShapeErrorKind::UnexpectedContent {
48                            actual: RdNodeKind::of(other),
49                        },
50                    )
51                    .into());
52                }
53            }
54        }
55
56        let mut pairs = Vec::new();
57        let mut diagnostics = Vec::new();
58        if text.is_empty() {
59            return Ok(Self {
60                path,
61                pairs,
62                diagnostics,
63                _nodes: std::marker::PhantomData,
64            });
65        }
66        for (pair_index, entry) in text.split(',').enumerate() {
67            if entry.is_empty() {
68                return Err(RdOptionError::malformed(
69                    path.clone(),
70                    pair_index,
71                    entry,
72                    RdOptionPairErrorKind::EmptyPair,
73                ));
74            }
75            let Some((raw_key, raw_value)) = entry.split_once('=') else {
76                return Err(RdOptionError::malformed(
77                    path.clone(),
78                    pair_index,
79                    entry,
80                    RdOptionPairErrorKind::MissingEquals,
81                ));
82            };
83            let key = raw_key.trim_ascii();
84            let value = raw_value.trim_ascii();
85            let reason = if key.is_empty() {
86                Some(RdOptionPairErrorKind::EmptyKey)
87            } else if value.is_empty() {
88                Some(RdOptionPairErrorKind::EmptyValue)
89            } else {
90                None
91            };
92            if let Some(reason) = reason {
93                return Err(RdOptionError::malformed(
94                    path.clone(),
95                    pair_index,
96                    entry,
97                    reason,
98                ));
99            }
100
101            let pair = RdOptionPair {
102                index: pair_index,
103                key: Cow::Owned(key.to_string()),
104                value: Cow::Owned(value.to_string()),
105            };
106            if RdSexprOptionKey::from_name(key).is_none() {
107                diagnostics.push(RdOptionError::UnknownKey {
108                    path: path.clone(),
109                    pair_index,
110                    key: key.to_string(),
111                });
112            }
113            if let Some(first_pair_index) = pairs.iter().find(|p| p.key() == key).map(|p| p.index) {
114                diagnostics.push(RdOptionError::DuplicateKey {
115                    path: path.clone(),
116                    pair_index,
117                    key: key.to_string(),
118                    first_pair_index,
119                });
120            }
121            if let Some(known) = RdSexprOptionKey::from_name(key)
122                && decode_value(known, value).is_err()
123            {
124                diagnostics.push(RdOptionError::InvalidValue {
125                    path: path.clone(),
126                    pair_index,
127                    key: known,
128                    value: value.to_string(),
129                    expected: known.value_kind(),
130                });
131            }
132            pairs.push(pair);
133        }
134
135        Ok(Self {
136            path,
137            pairs,
138            diagnostics,
139            _nodes: std::marker::PhantomData,
140        })
141    }
142
143    /// Returns the base path supplied to the parser.
144    pub fn path(&self) -> &RdPath {
145        &self.path
146    }
147    /// Returns all parsed pairs in source order.
148    pub fn pairs(&self) -> &[RdOptionPair<'a>] {
149        &self.pairs
150    }
151    /// Returns non-fatal diagnostics, in occurrence order.
152    pub fn diagnostics(&self) -> &[RdOptionError] {
153        &self.diagnostics
154    }
155    /// Resolves valid typed values, using the last valid occurrence per key.
156    pub fn typed(&self) -> RdSexprOptionOverrides {
157        let mut result = RdSexprOptionOverrides::empty();
158        for pair in &self.pairs {
159            if let Some(key) = pair.known_key()
160                && let Ok(value) = decode_value(key, pair.value())
161            {
162                result.set(key, value);
163            }
164        }
165        result
166    }
167}
168
169/// One preserved key/value pair from an option list.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct RdOptionPair<'a> {
172    index: usize,
173    key: Cow<'a, str>,
174    value: Cow<'a, str>,
175}
176
177impl<'a> RdOptionPair<'a> {
178    /// Returns the zero-based pair index.
179    pub fn index(&self) -> usize {
180        self.index
181    }
182    /// Returns the trimmed key spelling.
183    pub fn key(&self) -> &str {
184        &self.key
185    }
186    /// Returns the trimmed value spelling.
187    pub fn value(&self) -> &str {
188        &self.value
189    }
190    /// Returns the typed vocabulary key, if recognized.
191    pub fn known_key(&self) -> Option<RdSexprOptionKey> {
192        RdSexprOptionKey::from_name(&self.key)
193    }
194}
195
196/// The recognized names in a `\Sexpr` option list.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198#[non_exhaustive]
199pub enum RdSexprOptionKey {
200    /// Evaluation stage.
201    Stage,
202    /// Results rendering mode.
203    Results,
204    /// Echo source code.
205    Echo,
206    /// Evaluate the expression.
207    Eval,
208    /// Keep source code.
209    KeepSource,
210    /// Strip whitespace.
211    StripWhite,
212    /// Figure width.
213    Width,
214    /// Figure height.
215    Height,
216    /// Figure number.
217    Fig,
218}
219
220impl RdSexprOptionKey {
221    /// Converts an exact Rd option name to its vocabulary key.
222    pub fn from_name(name: &str) -> Option<Self> {
223        Some(match name {
224            "stage" => Self::Stage,
225            "results" => Self::Results,
226            "echo" => Self::Echo,
227            "eval" => Self::Eval,
228            "keep.source" => Self::KeepSource,
229            "strip.white" => Self::StripWhite,
230            "width" => Self::Width,
231            "height" => Self::Height,
232            "fig" => Self::Fig,
233            _ => return None,
234        })
235    }
236    /// Returns the canonical Rd spelling of this key.
237    pub const fn as_name(self) -> &'static str {
238        match self {
239            Self::Stage => "stage",
240            Self::Results => "results",
241            Self::Echo => "echo",
242            Self::Eval => "eval",
243            Self::KeepSource => "keep.source",
244            Self::StripWhite => "strip.white",
245            Self::Width => "width",
246            Self::Height => "height",
247            Self::Fig => "fig",
248        }
249    }
250    fn value_kind(self) -> RdOptionValueKind {
251        match self {
252            Self::Stage => RdOptionValueKind::Stage,
253            Self::Results => RdOptionValueKind::Results,
254            Self::Echo | Self::Eval | Self::KeepSource => RdOptionValueKind::Boolean,
255            Self::StripWhite => RdOptionValueKind::StripWhite,
256            Self::Width | Self::Height | Self::Fig => RdOptionValueKind::Boolean,
257        }
258    }
259}
260
261/// An Rd `stage` value.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263#[non_exhaustive]
264pub enum RdSexprStage {
265    /// Build stage.
266    Build,
267    /// Install stage.
268    Install,
269    /// Render stage.
270    Render,
271}
272/// An Rd `results` value.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274#[non_exhaustive]
275pub enum RdSexprResults {
276    /// Text results.
277    Text,
278    /// Verbatim results.
279    Verbatim,
280    /// Rd results.
281    Rd,
282    /// Hidden results.
283    Hide,
284}
285/// An Rd `strip.white` value.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287#[non_exhaustive]
288pub enum RdStripWhite {
289    /// Do not trim whitespace.
290    None,
291    /// Trim ordinary whitespace.
292    Trim,
293    /// Trim all whitespace.
294    All,
295}
296
297/// Typed overrides extracted from an Rd option list.
298#[derive(Debug, Clone, Copy, PartialEq)]
299pub struct RdSexprOptionOverrides {
300    /// Stage override.
301    pub stage: Option<RdSexprStage>,
302    /// Results override.
303    pub results: Option<RdSexprResults>,
304    /// Echo override.
305    pub echo: Option<bool>,
306    /// Evaluation override.
307    pub eval: Option<bool>,
308    /// Keep-source override.
309    pub keep_source: Option<bool>,
310    /// Strip-whitespace override.
311    pub strip_white: Option<RdStripWhite>,
312}
313impl RdSexprOptionOverrides {
314    /// Creates an override set with no values.
315    pub const fn empty() -> Self {
316        Self {
317            stage: None,
318            results: None,
319            echo: None,
320            eval: None,
321            keep_source: None,
322            strip_white: None,
323        }
324    }
325    /// Applies present overrides to effective options.
326    pub fn apply_to(self, options: &mut RdEffectiveSexprOptions) {
327        if let Some(v) = self.stage {
328            options.stage = v;
329        }
330        if let Some(v) = self.results {
331            options.results = v;
332        }
333        if let Some(v) = self.echo {
334            options.echo = v;
335        }
336        if let Some(v) = self.eval {
337            options.eval = v;
338        }
339        if let Some(v) = self.keep_source {
340            options.keep_source = v;
341        }
342        if let Some(v) = self.strip_white {
343            options.strip_white = v;
344        }
345    }
346    fn set(&mut self, key: RdSexprOptionKey, value: DecodedValue) {
347        match (key, value) {
348            (RdSexprOptionKey::Stage, DecodedValue::Stage(v)) => self.stage = Some(v),
349            (RdSexprOptionKey::Results, DecodedValue::Results(v)) => self.results = Some(v),
350            (RdSexprOptionKey::Echo, DecodedValue::Boolean(v)) => self.echo = Some(v),
351            (RdSexprOptionKey::Eval, DecodedValue::Boolean(v)) => self.eval = Some(v),
352            (RdSexprOptionKey::KeepSource, DecodedValue::Boolean(v)) => self.keep_source = Some(v),
353            (RdSexprOptionKey::StripWhite, DecodedValue::StripWhite(v)) => {
354                self.strip_white = Some(v)
355            }
356            _ => {}
357        }
358    }
359}
360
361/// Fully resolved options for an Rd `\Sexpr` expression.
362#[derive(Debug, Clone, Copy, PartialEq)]
363pub struct RdEffectiveSexprOptions {
364    /// Evaluation stage.
365    pub stage: RdSexprStage,
366    /// Results rendering mode.
367    pub results: RdSexprResults,
368    /// Echo source code.
369    pub echo: bool,
370    /// Evaluate the expression.
371    pub eval: bool,
372    /// Keep source code.
373    pub keep_source: bool,
374    /// Strip whitespace.
375    pub strip_white: RdStripWhite,
376}
377impl Default for RdEffectiveSexprOptions {
378    fn default() -> Self {
379        Self {
380            stage: RdSexprStage::Install,
381            results: RdSexprResults::Text,
382            echo: false,
383            eval: true,
384            keep_source: true,
385            strip_white: RdStripWhite::Trim,
386        }
387    }
388}
389
390/// A hard or soft option-list diagnostic.
391#[derive(Debug, Clone, PartialEq, Eq)]
392#[non_exhaustive]
393pub enum RdOptionError {
394    /// A node had an invalid shape.
395    Shape(RdShapeError),
396    /// A scalar pair was malformed.
397    MalformedPair {
398        path: RdPath,
399        pair_index: usize,
400        text: String,
401        reason: RdOptionPairErrorKind,
402    },
403    /// The key is not recognized.
404    UnknownKey {
405        path: RdPath,
406        pair_index: usize,
407        key: String,
408    },
409    /// The key occurred previously.
410    DuplicateKey {
411        path: RdPath,
412        pair_index: usize,
413        key: String,
414        first_pair_index: usize,
415    },
416    /// A recognized typed value could not be decoded.
417    InvalidValue {
418        path: RdPath,
419        pair_index: usize,
420        key: RdSexprOptionKey,
421        value: String,
422        expected: RdOptionValueKind,
423    },
424}
425impl RdOptionError {
426    fn malformed(
427        path: RdPath,
428        pair_index: usize,
429        text: &str,
430        reason: RdOptionPairErrorKind,
431    ) -> Self {
432        Self::MalformedPair {
433            path,
434            pair_index,
435            text: text.to_string(),
436            reason,
437        }
438    }
439
440    /// Returns the diagnostic path.
441    pub fn path(&self) -> &RdPath {
442        match self {
443            Self::Shape(error) => error.path(),
444            Self::MalformedPair { path, .. }
445            | Self::UnknownKey { path, .. }
446            | Self::DuplicateKey { path, .. }
447            | Self::InvalidValue { path, .. } => path,
448        }
449    }
450}
451impl From<RdShapeError> for RdOptionError {
452    fn from(error: RdShapeError) -> Self {
453        Self::Shape(error)
454    }
455}
456impl fmt::Display for RdOptionError {
457    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458        match self {
459            Self::Shape(e) => e.fmt(f),
460            Self::MalformedPair {
461                path,
462                pair_index,
463                reason,
464                ..
465            } => write!(f, "malformed option pair {pair_index}: {reason} at {path}"),
466            Self::UnknownKey {
467                path,
468                pair_index,
469                key,
470            } => write!(
471                f,
472                "unknown option key '{key}' (pair {pair_index}) at {path}"
473            ),
474            Self::DuplicateKey {
475                path,
476                pair_index,
477                key,
478                first_pair_index,
479            } => write!(
480                f,
481                "duplicate option key '{key}' (pair {pair_index}; first pair {first_pair_index}) at {path}"
482            ),
483            Self::InvalidValue {
484                path,
485                pair_index,
486                key,
487                value,
488                expected,
489            } => write!(
490                f,
491                "invalid value '{value}' for option {} (pair {pair_index}; expected {expected}) at {path}",
492                key.as_name()
493            ),
494        }
495    }
496}
497impl std::error::Error for RdOptionError {}
498
499/// Reasons a scalar option pair is malformed.
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501#[non_exhaustive]
502pub enum RdOptionPairErrorKind {
503    /// The entry was empty.
504    EmptyPair,
505    /// No equals separator was present.
506    MissingEquals,
507    /// The key was empty.
508    EmptyKey,
509    /// The value was empty.
510    EmptyValue,
511}
512/// Expected typed value categories for known options.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514#[non_exhaustive]
515pub enum RdOptionValueKind {
516    /// A boolean.
517    Boolean,
518    /// A stage name.
519    Stage,
520    /// A results name.
521    Results,
522    /// A strip-white name.
523    StripWhite,
524}
525impl fmt::Display for RdOptionPairErrorKind {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        f.write_str(match self {
528            Self::EmptyPair => "empty pair",
529            Self::MissingEquals => "missing '='",
530            Self::EmptyKey => "empty key",
531            Self::EmptyValue => "empty value",
532        })
533    }
534}
535impl fmt::Display for RdOptionValueKind {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        f.write_str(match self {
538            Self::Boolean => "boolean",
539            Self::Stage => "stage",
540            Self::Results => "results",
541            Self::StripWhite => "strip.white",
542        })
543    }
544}
545
546#[derive(Debug, Clone, Copy)]
547enum DecodedValue {
548    Boolean(bool),
549    Stage(RdSexprStage),
550    Results(RdSexprResults),
551    StripWhite(RdStripWhite),
552}
553fn decode_value(key: RdSexprOptionKey, value: &str) -> Result<DecodedValue, ()> {
554    match key {
555        RdSexprOptionKey::Echo | RdSexprOptionKey::Eval | RdSexprOptionKey::KeepSource => value
556            .eq_ignore_ascii_case("true")
557            .then_some(DecodedValue::Boolean(true))
558            .or_else(|| {
559                value
560                    .eq_ignore_ascii_case("false")
561                    .then_some(DecodedValue::Boolean(false))
562            })
563            .ok_or(()),
564        RdSexprOptionKey::Stage => match value {
565            "build" => Ok(DecodedValue::Stage(RdSexprStage::Build)),
566            "install" => Ok(DecodedValue::Stage(RdSexprStage::Install)),
567            "render" => Ok(DecodedValue::Stage(RdSexprStage::Render)),
568            _ => Err(()),
569        },
570        RdSexprOptionKey::Results => match value {
571            "text" => Ok(DecodedValue::Results(RdSexprResults::Text)),
572            "verbatim" => Ok(DecodedValue::Results(RdSexprResults::Verbatim)),
573            "rd" => Ok(DecodedValue::Results(RdSexprResults::Rd)),
574            "hide" => Ok(DecodedValue::Results(RdSexprResults::Hide)),
575            _ => Err(()),
576        },
577        RdSexprOptionKey::StripWhite => {
578            if value.eq_ignore_ascii_case("true") {
579                Ok(DecodedValue::StripWhite(RdStripWhite::Trim))
580            } else if value.eq_ignore_ascii_case("all") {
581                Ok(DecodedValue::StripWhite(RdStripWhite::All))
582            } else if value.eq_ignore_ascii_case("false") {
583                Ok(DecodedValue::StripWhite(RdStripWhite::None))
584            } else {
585                Err(())
586            }
587        }
588        RdSexprOptionKey::Width | RdSexprOptionKey::Height | RdSexprOptionKey::Fig => {
589            Ok(DecodedValue::Boolean(false))
590        }
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::{RdPathSegment, RdTag};
598
599    fn path() -> RdPath {
600        RdPath::new(vec![RdPathSegment::TopLevel(4)])
601    }
602    fn text(value: &str) -> RdNode {
603        RdNode::Text(value.to_string())
604    }
605
606    #[test]
607    fn concatenates_text_nodes_and_preserves_pairs() {
608        let nodes = [text(" stage = build,echo=true"), text(", results = rd ")];
609        let parsed = RdOptionList::parse(&nodes, path()).unwrap();
610        assert_eq!(
611            parsed.pairs().iter().map(|p| p.index()).collect::<Vec<_>>(),
612            [0, 1, 2]
613        );
614        assert_eq!(parsed.pairs()[0].key(), "stage");
615        assert_eq!(parsed.pairs()[0].value(), "build");
616        assert_eq!(parsed.typed().stage, Some(RdSexprStage::Build));
617        assert_eq!(parsed.typed().echo, Some(true));
618        assert_eq!(parsed.typed().results, Some(RdSexprResults::Rd));
619    }
620
621    #[test]
622    fn accepts_verb_and_mixed_string_leaves() {
623        let verb = [RdNode::Verb("stage=build".to_string())];
624        assert_eq!(
625            RdOptionList::parse(&verb, path()).unwrap().typed().stage,
626            Some(RdSexprStage::Build)
627        );
628        let mixed = [
629            RdNode::Text("stage=build,".to_string()),
630            RdNode::Verb("results=rd".to_string()),
631        ];
632        let parsed = RdOptionList::parse(&mixed, path()).unwrap();
633        assert_eq!(parsed.typed().stage, Some(RdSexprStage::Build));
634        assert_eq!(parsed.typed().results, Some(RdSexprResults::Rd));
635    }
636
637    #[test]
638    fn malformed_reasons_are_hard_errors() {
639        for (input, expected) in [
640            ("", RdOptionPairErrorKind::EmptyPair),
641            ("noequals", RdOptionPairErrorKind::MissingEquals),
642            (" =value", RdOptionPairErrorKind::EmptyKey),
643            ("key= ", RdOptionPairErrorKind::EmptyValue),
644        ] {
645            let nodes = [text(input)];
646            let result = RdOptionList::parse(&nodes, path());
647            if input.is_empty() {
648                assert!(result.is_ok());
649            } else {
650                assert_eq!(result.as_ref().unwrap_err().path(), &path());
651                assert!(
652                    matches!(result, Err(RdOptionError::MalformedPair { reason, .. }) if reason == expected)
653                );
654            }
655        }
656        let error = RdOptionList::parse(&[text("a=1,,b=2")], path()).unwrap_err();
657        assert_eq!(error.path(), &path());
658        assert!(matches!(
659            error,
660            RdOptionError::MalformedPair {
661                reason: RdOptionPairErrorKind::EmptyPair,
662                ..
663            }
664        ));
665    }
666
667    #[test]
668    fn rejects_non_text_at_node_path() {
669        let nodes = [RdNode::tagged(RdTag::Title, None, vec![])];
670        let error = RdOptionList::parse(&nodes, path()).unwrap_err();
671        assert!(
672            matches!(error, RdOptionError::Shape(shape) if shape.path() == &path().with_child(0) && matches!(shape.kind(), RdShapeErrorKind::UnexpectedContent { actual: RdNodeKind::Tagged }))
673        );
674    }
675
676    #[test]
677    fn decodes_all_typed_keys_and_case_insensitive_values() {
678        let nodes = [text(
679            "stage=render,results=verbatim,echo=TrUe,eval=false,keep.source=FALSE,strip.white=TRUE",
680        )];
681        let typed = RdOptionList::parse(&nodes, path()).unwrap().typed();
682        assert_eq!(typed.stage, Some(RdSexprStage::Render));
683        assert_eq!(typed.results, Some(RdSexprResults::Verbatim));
684        assert_eq!(typed.echo, Some(true));
685        assert_eq!(typed.eval, Some(false));
686        assert_eq!(typed.keep_source, Some(false));
687        assert_eq!(typed.strip_white, Some(RdStripWhite::Trim));
688        for value in ["false", "all", "true"] {
689            let nodes = [text(&format!("strip.white={value}"))];
690            let parsed = RdOptionList::parse(&nodes, path()).unwrap();
691            assert_eq!(
692                parsed.typed().strip_white,
693                Some(match value {
694                    "false" => RdStripWhite::None,
695                    "all" => RdStripWhite::All,
696                    _ => RdStripWhite::Trim,
697                })
698            );
699        }
700    }
701
702    #[test]
703    fn collects_soft_diagnostics_and_keeps_pairs() {
704        let nodes = [text("unknown=x,echo=true,echo=maybe,echo=false")];
705        let parsed = RdOptionList::parse(&nodes, path()).unwrap();
706        assert_eq!(parsed.pairs().len(), 4);
707        assert!(
708            parsed
709                .diagnostics()
710                .iter()
711                .any(|e| matches!(e, RdOptionError::UnknownKey { key, .. } if key == "unknown"))
712        );
713        assert!(
714            parsed
715                .diagnostics()
716                .iter()
717                .all(|error| error.path() == &path())
718        );
719        assert!(parsed.diagnostics().iter().any(|e| matches!(
720            e,
721            RdOptionError::DuplicateKey {
722                first_pair_index: 1,
723                pair_index: 2,
724                ..
725            }
726        )));
727        assert!(parsed.diagnostics().iter().any(|e| matches!(
728            e,
729            RdOptionError::InvalidValue {
730                pair_index: 2,
731                expected: RdOptionValueKind::Boolean,
732                ..
733            }
734        )));
735        assert_eq!(parsed.typed().echo, Some(false));
736    }
737
738    #[test]
739    fn formats_soft_diagnostics_with_pair_indices() {
740        let nodes = [text("unknown=x,echo=true,echo=maybe")];
741        let parsed = RdOptionList::parse(&nodes, path()).unwrap();
742        let messages: Vec<_> = parsed
743            .diagnostics()
744            .iter()
745            .map(ToString::to_string)
746            .collect();
747        assert_eq!(
748            messages,
749            [
750                "unknown option key 'unknown' (pair 0) at top-level[4]",
751                "duplicate option key 'echo' (pair 2; first pair 1) at top-level[4]",
752                "invalid value 'maybe' for option echo (pair 2; expected boolean) at top-level[4]",
753            ]
754        );
755    }
756
757    #[test]
758    fn invalid_duplicate_does_not_erase_last_valid_value() {
759        let nodes = [text("stage=build,stage=bad")];
760        let parsed = RdOptionList::parse(&nodes, path()).unwrap();
761        assert_eq!(parsed.typed().stage, Some(RdSexprStage::Build));
762    }
763
764    #[test]
765    fn figure_keys_are_known_but_raw_only() {
766        let nodes = [text("width=bad,height=also-bad,fig=anything")];
767        let parsed = RdOptionList::parse(&nodes, path()).unwrap();
768        assert_eq!(
769            parsed
770                .pairs()
771                .iter()
772                .map(|p| p.known_key())
773                .collect::<Vec<_>>(),
774            [
775                Some(RdSexprOptionKey::Width),
776                Some(RdSexprOptionKey::Height),
777                Some(RdSexprOptionKey::Fig)
778            ]
779        );
780        assert_eq!(parsed.typed(), RdSexprOptionOverrides::empty());
781        assert!(parsed.diagnostics().is_empty());
782    }
783
784    #[test]
785    fn empty_inputs_and_defaults_are_valid() {
786        assert!(RdOptionList::parse(&[], path()).unwrap().pairs().is_empty());
787        assert!(
788            RdOptionList::parse(&[text("")], path())
789                .unwrap()
790                .pairs()
791                .is_empty()
792        );
793        let defaults = RdEffectiveSexprOptions::default();
794        assert_eq!(
795            defaults,
796            RdEffectiveSexprOptions {
797                stage: RdSexprStage::Install,
798                results: RdSexprResults::Text,
799                echo: false,
800                eval: true,
801                keep_source: true,
802                strip_white: RdStripWhite::Trim
803            }
804        );
805    }
806
807    #[test]
808    fn option_error_is_displayable_standard_error() {
809        let error = RdOptionList::parse(&[text("bad")], path()).unwrap_err();
810        assert!(error.to_string().contains("missing '='"));
811        let _: Box<dyn std::error::Error> = Box::new(error);
812    }
813}