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