Skip to main content

shuck_parser/parser/
zsh_options.rs

1/// Tri-state value for a parser-visible zsh option.
2///
3/// `Unknown` is used when the parser cannot prove a single value, for example
4/// after merging control-flow paths that set an option differently. Consumers
5/// should only branch on [`OptionValue::On`] or [`OptionValue::Off`] when the
6/// corresponding `is_definitely_*` helper returns `true`.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
8pub enum OptionValue {
9    /// The option is enabled.
10    On,
11    /// The option is disabled.
12    Off,
13    /// The option value is unknown or differs across merged states.
14    #[default]
15    Unknown,
16}
17
18impl OptionValue {
19    /// Returns `true` when the option is known to be enabled.
20    pub const fn is_definitely_on(self) -> bool {
21        matches!(self, Self::On)
22    }
23
24    /// Returns `true` when the option is known to be disabled.
25    pub const fn is_definitely_off(self) -> bool {
26        matches!(self, Self::Off)
27    }
28
29    /// Merge two option values, preserving certainty only when they agree.
30    ///
31    /// This is intended for conservative flow joins: `On + On` remains `On`,
32    /// `Off + Off` remains `Off`, and every mixed or unknown combination
33    /// becomes [`OptionValue::Unknown`].
34    pub const fn merge(self, other: Self) -> Self {
35        match (self, other) {
36            (Self::On, Self::On) => Self::On,
37            (Self::Off, Self::Off) => Self::Off,
38            _ => Self::Unknown,
39        }
40    }
41}
42
43/// Target emulation mode for zsh's `emulate` behavior.
44///
45/// The parser uses this to derive the option snapshot implied by commands such
46/// as `emulate sh` or `emulate ksh`.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum ZshEmulationMode {
49    /// Native zsh behavior.
50    Zsh,
51    /// `sh` compatibility mode.
52    Sh,
53    /// `ksh` compatibility mode.
54    Ksh,
55    /// `csh` compatibility mode.
56    Csh,
57}
58
59/// Snapshot of parser-visible zsh option state.
60///
61/// The fields here intentionally cover options that can change syntax,
62/// tokenization, or word interpretation. They are not a full zsh runtime option
63/// table. Use [`ZshOptionState::zsh_default`] for native zsh parsing, then
64/// apply `setopt`, `unsetopt`, or `emulate` effects when a caller has already
65/// discovered them.
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub struct ZshOptionState {
68    /// Whether unquoted parameter expansion is treated as eligible for
69    /// shell-style word splitting.
70    pub sh_word_split: OptionValue,
71    /// Whether parameter expansion results are treated as glob patterns.
72    pub glob_subst: OptionValue,
73    /// Whether array parameters can participate in brace-like expansion.
74    pub rc_expand_param: OptionValue,
75    /// Whether ordinary filename generation is enabled.
76    pub glob: OptionValue,
77    /// Whether unmatched filename-generation patterns are treated as errors.
78    pub nomatch: OptionValue,
79    /// Whether unmatched filename-generation patterns can expand to nothing.
80    pub null_glob: OptionValue,
81    /// Whether csh-style null glob handling is enabled.
82    pub csh_null_glob: OptionValue,
83    /// Whether zsh extended glob operators are enabled.
84    pub extended_glob: OptionValue,
85    /// Whether ksh-style glob operators are enabled.
86    pub ksh_glob: OptionValue,
87    /// Whether sh-compatible glob parsing is enabled.
88    pub sh_glob: OptionValue,
89    /// Whether unparenthesized zsh glob qualifiers are enabled.
90    pub bare_glob_qual: OptionValue,
91    /// Whether glob patterns match dotfiles without an explicit dot.
92    pub glob_dots: OptionValue,
93    /// Whether leading `=` words are eligible for command-path expansion.
94    pub equals: OptionValue,
95    /// Whether assignment-like words can apply `=` expansion after the first
96    /// equals sign.
97    pub magic_equal_subst: OptionValue,
98    /// Whether file expansion follows sh-compatible ordering.
99    pub sh_file_expansion: OptionValue,
100    /// Whether assignment values can be parsed as glob assignments.
101    pub glob_assign: OptionValue,
102    /// Whether brace characters should be treated literally instead of as zsh
103    /// brace syntax.
104    pub ignore_braces: OptionValue,
105    /// Whether unmatched closing braces should be treated literally.
106    pub ignore_close_braces: OptionValue,
107    /// Whether character-class brace expansion syntax is enabled.
108    pub brace_ccl: OptionValue,
109    /// Whether array indexing follows ksh-style zero-based behavior.
110    pub ksh_arrays: OptionValue,
111    /// Whether subscript zero is accepted with ksh-style array semantics.
112    pub ksh_zero_subscript: OptionValue,
113    /// Whether zsh short loop forms are accepted.
114    pub short_loops: OptionValue,
115    /// Whether zsh short `repeat` forms are accepted.
116    pub short_repeat: OptionValue,
117    /// Whether doubled single quotes are decoded inside single-quoted strings.
118    pub rc_quotes: OptionValue,
119    /// Whether `#` starts comments in interactive-style zsh parsing contexts.
120    pub interactive_comments: OptionValue,
121    /// Whether C-style numeric base prefixes are accepted in arithmetic text.
122    pub c_bases: OptionValue,
123    /// Whether leading zeroes are interpreted as octal arithmetic literals.
124    pub octal_zeroes: OptionValue,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128enum ZshOptionField {
129    ShWordSplit,
130    GlobSubst,
131    RcExpandParam,
132    Glob,
133    Nomatch,
134    NullGlob,
135    CshNullGlob,
136    ExtendedGlob,
137    KshGlob,
138    ShGlob,
139    BareGlobQual,
140    GlobDots,
141    Equals,
142    MagicEqualSubst,
143    ShFileExpansion,
144    GlobAssign,
145    IgnoreBraces,
146    IgnoreCloseBraces,
147    BraceCcl,
148    KshArrays,
149    KshZeroSubscript,
150    ShortLoops,
151    ShortRepeat,
152    RcQuotes,
153    InteractiveComments,
154    CBases,
155    OctalZeroes,
156}
157
158impl ZshOptionState {
159    /// Default zsh option state used for native zsh parsing.
160    ///
161    /// This is the parser's baseline before source-level commands such as
162    /// `emulate`, `setopt`, and `unsetopt` are considered.
163    pub const fn zsh_default() -> Self {
164        Self {
165            sh_word_split: OptionValue::Off,
166            glob_subst: OptionValue::Off,
167            rc_expand_param: OptionValue::Off,
168            glob: OptionValue::On,
169            nomatch: OptionValue::On,
170            null_glob: OptionValue::Off,
171            csh_null_glob: OptionValue::Off,
172            extended_glob: OptionValue::Off,
173            ksh_glob: OptionValue::Off,
174            sh_glob: OptionValue::Off,
175            bare_glob_qual: OptionValue::On,
176            glob_dots: OptionValue::Off,
177            equals: OptionValue::On,
178            magic_equal_subst: OptionValue::Off,
179            sh_file_expansion: OptionValue::Off,
180            glob_assign: OptionValue::Off,
181            ignore_braces: OptionValue::Off,
182            ignore_close_braces: OptionValue::Off,
183            brace_ccl: OptionValue::Off,
184            ksh_arrays: OptionValue::Off,
185            ksh_zero_subscript: OptionValue::Off,
186            short_loops: OptionValue::On,
187            short_repeat: OptionValue::On,
188            rc_quotes: OptionValue::Off,
189            interactive_comments: OptionValue::On,
190            c_bases: OptionValue::Off,
191            octal_zeroes: OptionValue::Off,
192        }
193    }
194
195    /// Return the option state implied by `emulate <mode>`.
196    ///
197    /// This models the subset of emulation effects that the parser currently
198    /// needs. Callers can further refine the returned state with
199    /// [`ZshOptionState::apply_setopt`] and [`ZshOptionState::apply_unsetopt`].
200    pub fn for_emulate(mode: ZshEmulationMode) -> Self {
201        let mut state = Self::zsh_default();
202        match mode {
203            ZshEmulationMode::Zsh => {}
204            ZshEmulationMode::Sh => {
205                state.sh_word_split = OptionValue::On;
206                state.glob_subst = OptionValue::On;
207                state.sh_glob = OptionValue::On;
208                state.sh_file_expansion = OptionValue::On;
209                state.bare_glob_qual = OptionValue::Off;
210                state.ksh_arrays = OptionValue::Off;
211            }
212            ZshEmulationMode::Ksh => {
213                state.sh_word_split = OptionValue::On;
214                state.glob_subst = OptionValue::On;
215                state.ksh_glob = OptionValue::On;
216                state.ksh_arrays = OptionValue::On;
217                state.sh_glob = OptionValue::On;
218                state.bare_glob_qual = OptionValue::Off;
219            }
220            ZshEmulationMode::Csh => {
221                state.csh_null_glob = OptionValue::On;
222                state.sh_word_split = OptionValue::Off;
223                state.glob_subst = OptionValue::Off;
224            }
225        }
226        state
227    }
228
229    /// Apply a zsh `setopt`-style option name to this snapshot.
230    ///
231    /// Names are matched with zsh-style aliases, underscores, and `no_`
232    /// prefixes where supported by this parser. Returns `true` when the option
233    /// name was recognized and this snapshot was updated.
234    pub fn apply_setopt(&mut self, name: &str) -> bool {
235        self.apply_named_option(name, true)
236    }
237
238    /// Apply a zsh `unsetopt`-style option name to this snapshot.
239    ///
240    /// Names are matched with zsh-style aliases, underscores, and `no_`
241    /// prefixes where supported by this parser. Returns `true` when the option
242    /// name was recognized and this snapshot was updated.
243    pub fn apply_unsetopt(&mut self, name: &str) -> bool {
244        self.apply_named_option(name, false)
245    }
246
247    fn set_field(&mut self, field: ZshOptionField, value: OptionValue) {
248        match field {
249            ZshOptionField::ShWordSplit => self.sh_word_split = value,
250            ZshOptionField::GlobSubst => self.glob_subst = value,
251            ZshOptionField::RcExpandParam => self.rc_expand_param = value,
252            ZshOptionField::Glob => self.glob = value,
253            ZshOptionField::Nomatch => self.nomatch = value,
254            ZshOptionField::NullGlob => self.null_glob = value,
255            ZshOptionField::CshNullGlob => self.csh_null_glob = value,
256            ZshOptionField::ExtendedGlob => self.extended_glob = value,
257            ZshOptionField::KshGlob => self.ksh_glob = value,
258            ZshOptionField::ShGlob => self.sh_glob = value,
259            ZshOptionField::BareGlobQual => self.bare_glob_qual = value,
260            ZshOptionField::GlobDots => self.glob_dots = value,
261            ZshOptionField::Equals => self.equals = value,
262            ZshOptionField::MagicEqualSubst => self.magic_equal_subst = value,
263            ZshOptionField::ShFileExpansion => self.sh_file_expansion = value,
264            ZshOptionField::GlobAssign => self.glob_assign = value,
265            ZshOptionField::IgnoreBraces => self.ignore_braces = value,
266            ZshOptionField::IgnoreCloseBraces => self.ignore_close_braces = value,
267            ZshOptionField::BraceCcl => self.brace_ccl = value,
268            ZshOptionField::KshArrays => self.ksh_arrays = value,
269            ZshOptionField::KshZeroSubscript => self.ksh_zero_subscript = value,
270            ZshOptionField::ShortLoops => self.short_loops = value,
271            ZshOptionField::ShortRepeat => self.short_repeat = value,
272            ZshOptionField::RcQuotes => self.rc_quotes = value,
273            ZshOptionField::InteractiveComments => self.interactive_comments = value,
274            ZshOptionField::CBases => self.c_bases = value,
275            ZshOptionField::OctalZeroes => self.octal_zeroes = value,
276        }
277    }
278
279    fn field(&self, field: ZshOptionField) -> OptionValue {
280        match field {
281            ZshOptionField::ShWordSplit => self.sh_word_split,
282            ZshOptionField::GlobSubst => self.glob_subst,
283            ZshOptionField::RcExpandParam => self.rc_expand_param,
284            ZshOptionField::Glob => self.glob,
285            ZshOptionField::Nomatch => self.nomatch,
286            ZshOptionField::NullGlob => self.null_glob,
287            ZshOptionField::CshNullGlob => self.csh_null_glob,
288            ZshOptionField::ExtendedGlob => self.extended_glob,
289            ZshOptionField::KshGlob => self.ksh_glob,
290            ZshOptionField::ShGlob => self.sh_glob,
291            ZshOptionField::BareGlobQual => self.bare_glob_qual,
292            ZshOptionField::GlobDots => self.glob_dots,
293            ZshOptionField::Equals => self.equals,
294            ZshOptionField::MagicEqualSubst => self.magic_equal_subst,
295            ZshOptionField::ShFileExpansion => self.sh_file_expansion,
296            ZshOptionField::GlobAssign => self.glob_assign,
297            ZshOptionField::IgnoreBraces => self.ignore_braces,
298            ZshOptionField::IgnoreCloseBraces => self.ignore_close_braces,
299            ZshOptionField::BraceCcl => self.brace_ccl,
300            ZshOptionField::KshArrays => self.ksh_arrays,
301            ZshOptionField::KshZeroSubscript => self.ksh_zero_subscript,
302            ZshOptionField::ShortLoops => self.short_loops,
303            ZshOptionField::ShortRepeat => self.short_repeat,
304            ZshOptionField::RcQuotes => self.rc_quotes,
305            ZshOptionField::InteractiveComments => self.interactive_comments,
306            ZshOptionField::CBases => self.c_bases,
307            ZshOptionField::OctalZeroes => self.octal_zeroes,
308        }
309    }
310
311    /// Merge two option snapshots field by field.
312    ///
313    /// Each field preserves a definite value only when both inputs agree. This
314    /// is useful for conservative joins across control-flow paths.
315    pub fn merge(&self, other: &Self) -> Self {
316        let mut merged = Self::zsh_default();
317        for field in ZshOptionField::ALL {
318            merged.set_field(field, self.field(field).merge(other.field(field)));
319        }
320        merged
321    }
322
323    fn apply_named_option(&mut self, name: &str, enable: bool) -> bool {
324        let Some((field, value)) = parse_zsh_option_assignment(name, enable) else {
325            return false;
326        };
327        self.set_field(
328            field,
329            if value {
330                OptionValue::On
331            } else {
332                OptionValue::Off
333            },
334        );
335        true
336    }
337}
338
339impl ZshOptionField {
340    const ALL: [Self; 27] = [
341        Self::ShWordSplit,
342        Self::GlobSubst,
343        Self::RcExpandParam,
344        Self::Glob,
345        Self::Nomatch,
346        Self::NullGlob,
347        Self::CshNullGlob,
348        Self::ExtendedGlob,
349        Self::KshGlob,
350        Self::ShGlob,
351        Self::BareGlobQual,
352        Self::GlobDots,
353        Self::Equals,
354        Self::MagicEqualSubst,
355        Self::ShFileExpansion,
356        Self::GlobAssign,
357        Self::IgnoreBraces,
358        Self::IgnoreCloseBraces,
359        Self::BraceCcl,
360        Self::KshArrays,
361        Self::KshZeroSubscript,
362        Self::ShortLoops,
363        Self::ShortRepeat,
364        Self::RcQuotes,
365        Self::InteractiveComments,
366        Self::CBases,
367        Self::OctalZeroes,
368    ];
369}
370
371fn parse_zsh_option_assignment(name: &str, enable: bool) -> Option<(ZshOptionField, bool)> {
372    let mut normalized = String::with_capacity(name.len());
373    for ch in name.chars() {
374        if matches!(ch, '_' | '-') {
375            continue;
376        }
377        normalized.push(ch.to_ascii_lowercase());
378    }
379
380    let (normalized, invert) = if let Some(rest) = normalized.strip_prefix("no") {
381        (rest, true)
382    } else {
383        (normalized.as_str(), false)
384    };
385
386    let field = match normalized {
387        "shwordsplit" => ZshOptionField::ShWordSplit,
388        "globsubst" => ZshOptionField::GlobSubst,
389        "rcexpandparam" => ZshOptionField::RcExpandParam,
390        "glob" | "noglob" => ZshOptionField::Glob,
391        "nomatch" => ZshOptionField::Nomatch,
392        "nullglob" => ZshOptionField::NullGlob,
393        "cshnullglob" => ZshOptionField::CshNullGlob,
394        "extendedglob" => ZshOptionField::ExtendedGlob,
395        "kshglob" => ZshOptionField::KshGlob,
396        "shglob" => ZshOptionField::ShGlob,
397        "bareglobqual" => ZshOptionField::BareGlobQual,
398        "globdots" => ZshOptionField::GlobDots,
399        "equals" => ZshOptionField::Equals,
400        "magicequalsubst" => ZshOptionField::MagicEqualSubst,
401        "shfileexpansion" => ZshOptionField::ShFileExpansion,
402        "globassign" => ZshOptionField::GlobAssign,
403        "ignorebraces" => ZshOptionField::IgnoreBraces,
404        "ignoreclosebraces" => ZshOptionField::IgnoreCloseBraces,
405        "braceccl" => ZshOptionField::BraceCcl,
406        "ksharrays" => ZshOptionField::KshArrays,
407        "kshzerosubscript" => ZshOptionField::KshZeroSubscript,
408        "shortloops" => ZshOptionField::ShortLoops,
409        "shortrepeat" => ZshOptionField::ShortRepeat,
410        "rcquotes" => ZshOptionField::RcQuotes,
411        "interactivecomments" => ZshOptionField::InteractiveComments,
412        "cbases" => ZshOptionField::CBases,
413        "octalzeroes" => ZshOptionField::OctalZeroes,
414        _ => return None,
415    };
416
417    Some((field, if invert { !enable } else { enable }))
418}