Skip to main content

lean_ctx/core/shell_allowlist/
tokenizer.rs

1/// Tokenize a shell command segment respecting single/double quotes and backslash escapes.
2/// Returns tokens with outer quotes stripped, matching how the shell would parse them.
3/// E.g. `git -C "Program Files" status` → `["git", "-C", "Program Files", "status"]`
4pub fn shell_tokenize(input: &str) -> Vec<String> {
5    let mut tokens = Vec::new();
6    let mut current = String::new();
7    let mut chars = input.chars().peekable();
8    let mut in_single = false;
9    let mut in_double = false;
10    let mut parameter_depth: u32 = 0;
11
12    while let Some(c) = chars.next() {
13        match c {
14            '\'' if !in_double => in_single = !in_single,
15            '"' if !in_single => in_double = !in_double,
16            '\\' if !in_single => {
17                // #1781: inside double quotes a backslash is literal unless it
18                // precedes `$`, `` ` ``, `"`, `\` or a newline (POSIX 2.2.3).
19                // The old arm consumed it unconditionally, so a quoted Windows
20                // path — `Start-Process "C:\Program Files\Docker\Docker Desktop.exe"`
21                // — tokenized as `C:Program FilesDockerDocker Desktop.exe`, and
22                // the allowlist then refused a fragment that was never a command
23                // and asked the user to report it. Outside quotes the escape
24                // still applies to any character, which `tokenize_backslash_escape`
25                // pins.
26                let escapes_next =
27                    !in_double || matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n'));
28                if escapes_next {
29                    if let Some(next) = chars.next() {
30                        current.push(next);
31                    }
32                } else {
33                    current.push('\\');
34                }
35            }
36            '$' if !in_single && chars.peek() == Some(&'{') => {
37                parameter_depth += 1;
38                current.push(c);
39            }
40            '}' if !in_single && parameter_depth > 0 => {
41                parameter_depth -= 1;
42                current.push(c);
43            }
44            c if c.is_whitespace() && !in_single && !in_double && parameter_depth == 0 => {
45                if !current.is_empty() {
46                    tokens.push(std::mem::take(&mut current));
47                }
48            }
49            _ => current.push(c),
50        }
51    }
52    if !current.is_empty() {
53        tokens.push(current);
54    }
55    tokens
56}
57
58/// Returns the byte length of the first shell token in `input`, respecting quotes
59/// and `(...)` nesting. Used by `skip_env_assignments` to advance past env
60/// assignments with quoted values like `FOO="bar baz"` — and, critically, past
61/// assignments whose value is a command substitution like `FOO=$(cmd a b)`
62/// (#855): without paren-depth tracking, whitespace *inside* the unclosed
63/// `$(...)` looked like the end of the token, splitting `s=$(gh pr view …)`
64/// into a bogus token `s=$(gh` plus a leftover `pr` that got misread as the
65/// base command.
66pub(super) fn quote_aware_token_end(input: &str) -> usize {
67    let bytes = input.as_bytes();
68    let len = bytes.len();
69    let mut i = 0;
70    let mut in_single = false;
71    let mut in_double = false;
72    let mut paren_depth: u32 = 0;
73    let mut parameter_depth: u32 = 0;
74
75    while i < len {
76        let ch = bytes[i];
77        match ch {
78            b'\'' if !in_double => {
79                in_single = !in_single;
80                i += 1;
81            }
82            b'"' if !in_single => {
83                in_double = !in_double;
84                i += 1;
85            }
86            b'\\' if !in_single => {
87                i = (i + 2).min(len);
88            }
89            b'(' if !in_single && !in_double => {
90                paren_depth += 1;
91                i += 1;
92            }
93            b')' if !in_single && !in_double && paren_depth > 0 => {
94                paren_depth -= 1;
95                i += 1;
96            }
97            b'$' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'{') => {
98                parameter_depth += 1;
99                i += 1;
100            }
101            b'}' if !in_single && parameter_depth > 0 => {
102                parameter_depth -= 1;
103                i += 1;
104            }
105            b if b.is_ascii_whitespace()
106                && !in_single
107                && !in_double
108                && paren_depth == 0
109                && parameter_depth == 0 =>
110            {
111                return i;
112            }
113            _ => i += 1,
114        }
115    }
116    len
117}
118/// Extract ALL command segments from a compound shell command.
119/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
120pub(super) fn extract_all_commands(command: &str) -> Vec<String> {
121    split_on_operators(command)
122        .into_iter()
123        .map(|s| s.trim().to_string())
124        .filter(|s| !s.is_empty())
125        .collect()
126}
127
128/// One lexical context.
129///
130/// A command substitution starts a **fresh** one. POSIX says quoting restarts
131/// inside `$( … )`, so in `echo "$(python3 -c "a;b")"` the inner `"…"` is its
132/// own string and its `;` separates nothing (GH #1646). Tracking quotes as two
133/// flat booleans could not express that: the scanner never noticed `$(` while
134/// inside double quotes, so the inner opening quote *closed* the outer one,
135/// `a;b` looked unquoted, and the `;` split a bogus command out of a Python
136/// source line — which the allowlist then rejected with an unusable suggestion.
137///
138/// Treating the substitution as one word here loses no enforcement: its real
139/// contents are re-split and checked by `substitution::extract_substitution_commands`,
140/// which is where commands that a substitution genuinely *runs* are caught.
141#[derive(Clone, Copy)]
142struct Frame {
143    in_single_quote: bool,
144    in_double_quote: bool,
145    paren_depth: u32,
146    /// #939: brace groups (`{ cmd; }`) need the same operator-shielding as
147    /// `( cmd )` subshells — otherwise a `}` that closes a `{` opened on an
148    /// earlier physical line (e.g. after heredoc-body stripping collapses the
149    /// body between them) is misread as its own bare command segment.
150    brace_depth: u32,
151    /// #1793: `[[ … ]]` is a bash conditional *construct*, not a command, and
152    /// the `&&`/`||` inside it join predicates rather than commands. Without
153    /// shielding, `[[ -x a && -x b ]]` was split mid-condition into fragments
154    /// like `[[ -x a` and `-x b ]]`, none of which resolve to a real command.
155    bracket_depth: u32,
156    kind: FrameKind,
157}
158
159#[derive(Clone, Copy, PartialEq, Eq)]
160enum FrameKind {
161    /// The command line itself. Separators split only at this level.
162    Base,
163    /// Opened by `$(`, closed by the matching `)`.
164    DollarParen,
165    /// Opened and closed by a backtick — the older substitution form, same
166    /// quoting-restart rule.
167    Backtick,
168}
169
170impl Frame {
171    const fn new(kind: FrameKind) -> Self {
172        Self {
173            in_single_quote: false,
174            in_double_quote: false,
175            paren_depth: 0,
176            brace_depth: 0,
177            bracket_depth: 0,
178            kind,
179        }
180    }
181
182    /// Inside quotes, or inside a group whose closing delimiter is still open.
183    const fn shields_operators(&self) -> bool {
184        self.in_single_quote
185            || self.in_double_quote
186            || self.paren_depth > 0
187            || self.brace_depth > 0
188            || self.bracket_depth > 0
189    }
190}
191
192/// True when the two bytes at `i` form a standalone `[[` token — the opener of
193/// a bash conditional (#1793).
194///
195/// Bash requires `[[` to be its own word, and so does this: a word boundary
196/// before and whitespace after. That distinction is what keeps a glob character
197/// class (`ls a[[:alpha:]]`) or a nested array subscript (`${arr[[i]]}`) from
198/// opening a depth that would never close and swallow the rest of the line.
199fn opens_double_bracket(bytes: &[u8], i: usize) -> bool {
200    if bytes.get(i) != Some(&b'[') || bytes.get(i + 1) != Some(&b'[') {
201        return false;
202    }
203    let boundary_before = i == 0
204        || matches!(
205            bytes[i - 1],
206            b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b'('
207        );
208    let space_after = matches!(bytes.get(i + 2), Some(b' ' | b'\t' | b'\n' | b'\r'));
209    boundary_before && space_after
210}
211
212/// True when the two bytes at `i` form a standalone `]]` token closing a
213/// conditional opened by [`opens_double_bracket`]. Mirrors its word-boundary
214/// rule: whitespace before, and a separator or end of input after.
215fn closes_double_bracket(bytes: &[u8], i: usize) -> bool {
216    if bytes.get(i) != Some(&b']') || bytes.get(i + 1) != Some(&b']') {
217        return false;
218    }
219    let space_before = i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\n' | b'\r');
220    let separator_after = matches!(
221        bytes.get(i + 2),
222        None | Some(b' ' | b'\t' | b'\n' | b'\r' | b';' | b'&' | b'|' | b')')
223    );
224    space_before && separator_after
225}
226
227/// Split command string on shell operators: ;, &&, ||, |
228/// Respects single/double quotes, parentheses nesting, and backslash escapes
229/// outside single quotes (GL #1160): `rg split\.label\|quantityLabel` is ONE
230/// command — the escaped pipe is regex data, not an operator. The old scanner
231/// split there and blocked the pattern fragment as an unknown command; same
232/// for `find … -exec rm {} \;`.
233pub(super) fn split_on_operators(command: &str) -> Vec<&str> {
234    split_with_separators(command)
235        .into_iter()
236        .map(|(segment, _)| segment)
237        .collect()
238}
239
240/// The operator that ends a command segment.
241///
242/// Where a segment runs can depend on it (#1850): a `cd` followed by `&&`
243/// moves the next command, one followed by `||` only runs the next command if
244/// it failed, and one in a pipeline or in the background moves nothing at all.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum Separator {
247    /// `;`, a newline or a carriage return.
248    Sequence,
249    /// `&&`
250    And,
251    /// `||`
252    Or,
253    /// `|`
254    Pipe,
255    /// A single `&`.
256    Background,
257}
258
259/// Every segment together with the separator that ends it; the last segment
260/// has none. Same scanner as `split_on_operators`, so the two never disagree
261/// about where a command ends.
262pub fn segments_with_separators(command: &str) -> Vec<(String, Option<Separator>)> {
263    split_with_separators(command)
264        .into_iter()
265        .map(|(segment, sep)| (segment.trim().to_string(), sep))
266        .filter(|(segment, _)| !segment.is_empty())
267        .collect()
268}
269
270/// The top-level statements of a command line: the original text between
271/// `;`/newline separators, each still holding its `&&`, `||`, `|` and `&`
272/// verbatim. Same scanner as [`segments_with_separators`].
273pub(super) fn sequence_statements(command: &str) -> Vec<&str> {
274    let base = command.as_ptr() as usize;
275    let mut out = Vec::new();
276    let mut start: Option<usize> = None;
277    for (segment, sep) in split_with_separators(command) {
278        let at = segment.as_ptr() as usize - base;
279        let begin = *start.get_or_insert(at);
280        if matches!(sep, Some(Separator::Sequence) | None) {
281            out.push(&command[begin..at + segment.len()]);
282            start = None;
283        }
284    }
285    if let Some(begin) = start {
286        out.push(&command[begin..]);
287    }
288    out.into_iter()
289        .map(str::trim)
290        .filter(|statement| !statement.is_empty())
291        .collect()
292}
293
294fn split_with_separators(command: &str) -> Vec<(&str, Option<Separator>)> {
295    let mut segments = Vec::new();
296    let mut start = 0;
297    let bytes = command.as_bytes();
298    let len = bytes.len();
299    let mut i = 0;
300    // Stack, not booleans: see [`Frame`]. Index 0 is always the command line.
301    let mut stack: Vec<Frame> = vec![Frame::new(FrameKind::Base)];
302
303    while i < len {
304        let ch = bytes[i];
305        // `$(` and a backtick open a substitution wherever the shell would
306        // expand one: unquoted, or inside double quotes. Single quotes inhibit
307        // it entirely, which the single-quote arm below handles by consuming
308        // everything to the closing quote.
309        let opens_substitution = |i: usize| -> Option<(FrameKind, usize)> {
310            match bytes[i] {
311                b'$' if i + 1 < len && bytes[i + 1] == b'(' => Some((FrameKind::DollarParen, 2)),
312                b'`' => Some((FrameKind::Backtick, 1)),
313                _ => None,
314            }
315        };
316
317        let frame = *stack.last().expect("base frame is never popped");
318
319        if frame.in_single_quote {
320            if ch == b'\'' {
321                stack.last_mut().expect("frame").in_single_quote = false;
322            }
323            i += 1;
324            continue;
325        }
326
327        if frame.in_double_quote {
328            match ch {
329                // \" stays inside the string; \\ consumes both so `"x\\"` closes.
330                b'\\' => i = (i + 2).min(len),
331                b'"' => {
332                    stack.last_mut().expect("frame").in_double_quote = false;
333                    i += 1;
334                }
335                _ => {
336                    if let Some((kind, width)) = opens_substitution(i) {
337                        stack.push(Frame::new(kind));
338                        i += width;
339                    } else {
340                        i += 1;
341                    }
342                }
343            }
344            continue;
345        }
346
347        // Unquoted within the current frame.
348        if let Some((kind, width)) = opens_substitution(i) {
349            if kind == FrameKind::Backtick && frame.kind == FrameKind::Backtick {
350                // The same character closes a backtick substitution.
351                stack.pop();
352            } else {
353                stack.push(Frame::new(kind));
354            }
355            i += width;
356            continue;
357        }
358
359        let at_top = stack.len() == 1;
360        let unshielded = at_top && !frame.shields_operators();
361
362        // #1793: enter/leave a `[[ … ]]` conditional so its internal `&&`/`||`
363        // stay part of the condition. Both delimiters must be standalone words
364        // (see the helpers), so a glob class (`ls a[[:alpha:]]`) or an array
365        // subscript never opens a depth that would swallow the rest of the line.
366        if ch == b'[' && opens_double_bracket(bytes, i) {
367            stack.last_mut().expect("frame").bracket_depth += 1;
368            i += 2;
369            continue;
370        }
371        if ch == b']' && frame.bracket_depth > 0 && closes_double_bracket(bytes, i) {
372            let top = stack.last_mut().expect("frame");
373            top.bracket_depth = top.bracket_depth.saturating_sub(1);
374            i += 2;
375            continue;
376        }
377
378        match ch {
379            b'\\' => {
380                // Escaped char is data (bash semantics outside quotes) — never
381                // an operator or quote opener.
382                i = (i + 2).min(len);
383            }
384            b'\'' => {
385                stack.last_mut().expect("frame").in_single_quote = true;
386                i += 1;
387            }
388            b'"' => {
389                stack.last_mut().expect("frame").in_double_quote = true;
390                i += 1;
391            }
392            b'(' => {
393                stack.last_mut().expect("frame").paren_depth += 1;
394                i += 1;
395            }
396            b')' => {
397                let top = stack.last_mut().expect("frame");
398                if top.kind == FrameKind::DollarParen && top.paren_depth == 0 {
399                    // Matching close of `$(` — back to the enclosing context,
400                    // whose quoting resumes exactly where it left off.
401                    stack.pop();
402                } else {
403                    top.paren_depth = top.paren_depth.saturating_sub(1);
404                }
405                i += 1;
406            }
407            b'{' => {
408                stack.last_mut().expect("frame").brace_depth += 1;
409                i += 1;
410            }
411            b'}' => {
412                let top = stack.last_mut().expect("frame");
413                top.brace_depth = top.brace_depth.saturating_sub(1);
414                i += 1;
415            }
416            // GH #2131: bash allows line breaks between a function head and
417            // its body (`run()\n{\n…\n}`). The newline ends no command there,
418            // so the definition stays one segment, as on a single line.
419            b'\n' | b'\r' if unshielded && is_function_head(&command[start..i]) => {
420                i += 1;
421            }
422            b'\n' | b'\r' | b';' if unshielded => {
423                segments.push((&command[start..i], Some(Separator::Sequence)));
424                i += 1;
425                start = i;
426            }
427            b'&' if unshielded => {
428                if i + 1 < len && bytes[i + 1] == b'&' {
429                    // &&
430                    segments.push((&command[start..i], Some(Separator::And)));
431                    i += 2;
432                    start = i;
433                } else if (i > 0 && matches!(bytes[i - 1], b'>' | b'<'))
434                    || (i + 1 < len && bytes[i + 1] == b'>')
435                {
436                    // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file`, `<&3`,
437                    // `0<&-` (prev is '>' or '<') or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
438                    // command — splitting here would mistake the fd/target (e.g. `1`) for a
439                    // standalone command and falsely block it (#334).
440                    i += 1;
441                } else {
442                    // single & (background operator) — still a command separator
443                    segments.push((&command[start..i], Some(Separator::Background)));
444                    i += 1;
445                    start = i;
446                }
447            }
448            b'|' if unshielded => {
449                if i + 1 < len && bytes[i + 1] == b'|' {
450                    // ||
451                    segments.push((&command[start..i], Some(Separator::Or)));
452                    i += 2;
453                    start = i;
454                } else if i > 0 && bytes[i - 1] == b'>' {
455                    // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
456                    // the redirect operator and the following token is a file
457                    // path, not a command. Splitting here treated the target
458                    // (e.g. `out` in `date >| out`) as a command and falsely
459                    // blocked it against the allowlist (#387).
460                    i += 1;
461                } else {
462                    // pipe
463                    segments.push((&command[start..i], Some(Separator::Pipe)));
464                    i += 1;
465                    start = i;
466                }
467            }
468            _ => {
469                i += 1;
470            }
471        }
472    }
473
474    if start < len {
475        segments.push((&command[start..], None));
476    }
477
478    segments
479}
480
481/// Extract normalized command tokens from a single segment (no operators):
482/// skip env assignments, skip a PowerShell assignment, return empty for a
483/// PowerShell value expression, skip a leading `{` brace-group token (#939 —
484/// lean-ctx's own agent wrapper emits this form), and take the basename of
485/// the first token. Shared by `extract_base_from_segment` (which returns just
486/// the first token) and the subcommand-scoped allowlist matcher (which needs
487/// the rest of the tokens too), so the two normalizations cannot drift
488/// (GH #1419 follow-up).
489pub(super) fn extract_command_tokens_from_segment(segment: &str) -> Vec<String> {
490    let trimmed = segment.trim();
491    if trimmed.is_empty() {
492        return Vec::new();
493    }
494
495    let cmd_part = skip_powershell_assignment(skip_env_assignments(trimmed));
496    if cmd_part.is_empty() {
497        return Vec::new();
498    }
499    if is_powershell_value_expression(cmd_part) {
500        return Vec::new();
501    }
502
503    let tokens = shell_tokenize(cmd_part);
504    // #939: a leading `{` brace-group token (e.g. from
505    // `agent_wrapper::rebuild`'s `{ <real command>\n} && pwd ...` wrapping)
506    // is not itself a command — skip it so the base extracted is the real
507    // command inside the group, not the brace.
508    let mut token_iter = tokens.into_iter();
509    let first_token = match token_iter.next() {
510        Some(t) if t == "{" => token_iter.next(),
511        other => other,
512    };
513    let Some(first_token) = first_token else {
514        return Vec::new();
515    };
516    let basename = first_token
517        .rsplit('/')
518        .next()
519        .unwrap_or(&first_token)
520        .to_string();
521
522    std::iter::once(basename).chain(token_iter).collect()
523}
524
525/// Extract the base command name from a single segment (no operators).
526pub(super) fn extract_base_from_segment(segment: &str) -> String {
527    extract_command_tokens_from_segment(segment)
528        .into_iter()
529        .next()
530        .unwrap_or_default()
531}
532
533/// Skip a local PowerShell assignment (`$result = Get-Content …`) so the
534/// wrapped cmdlet is validated. Environment/scoped variables contain `:` and
535/// intentionally remain unrecognized, preventing `$env:PATH = …` bypasses.
536pub(super) fn skip_powershell_assignment(segment: &str) -> &str {
537    let trimmed = segment.trim_start();
538    let Some(variable_end) = powershell_local_variable_end(trimmed) else {
539        return trimmed;
540    };
541    let after_variable = trimmed[variable_end..].trim_start();
542    let Some(after_equals) = after_variable.strip_prefix('=') else {
543        return trimmed;
544    };
545    after_equals.trim_start()
546}
547
548fn powershell_local_variable_end(segment: &str) -> Option<usize> {
549    let bytes = segment.as_bytes();
550    if bytes.first() != Some(&b'$') {
551        return None;
552    }
553    let mut end = 1;
554    let first = *bytes.get(end)?;
555    if !(first.is_ascii_alphabetic() || first == b'_') {
556        return None;
557    }
558    end += 1;
559    while bytes
560        .get(end)
561        .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
562    {
563        end += 1;
564    }
565    (bytes.get(end) != Some(&b':')).then_some(end)
566}
567
568/// A bare local variable or simple index lookup is pipeline data, not a command.
569/// Method calls and scoped variables deliberately fall through to deny-by-default.
570fn is_powershell_value_expression(segment: &str) -> bool {
571    let trimmed = segment.trim();
572    let Some(variable_end) = powershell_local_variable_end(trimmed) else {
573        return false;
574    };
575    let suffix = trimmed[variable_end..].trim();
576    suffix.is_empty()
577        || (suffix.starts_with('[')
578            && suffix.ends_with(']')
579            && !suffix.contains(['(', ')', ';', '|', '&', '=']))
580}
581
582/// #1488: detect whether a segment is a shell function definition.
583/// Pattern: `NAME() {`, `function NAME {` or `function NAME() {`; GH #2131
584/// adds `NAME(){`, a body on the next line and a `( … )` subshell body.
585/// Returns the function name if it is a definition, `None` otherwise.
586pub(super) fn detect_function_def(segment: &str) -> Option<String> {
587    function_def_parts(segment).map(|(name, _)| name.to_string())
588}
589
590/// #1488: the commands of a function definition segment, between the outer
591/// `{` and `}` (or `(` and `)`) — `greet() { echo hi; echo bye; }` gives
592/// `echo hi; echo bye;`. The allowlist expands it like a top-level command
593/// line, so control flow in a body (`for … do … done`, `if … then … fi`) is not
594/// mistaken for commands (GH #2002).
595pub(super) fn function_body(segment: &str) -> Option<&str> {
596    function_def_parts(segment).map(|(_, body)| body)
597}
598
599/// Split a function definition into its name and body text.
600///
601/// The body must be one balanced `{ … }` group or `( … )` subshell that ends
602/// the segment (GH #2131). Anything after it, such as a redirect evaluated on
603/// every call, makes this `None`, so the segment falls through to base
604/// extraction and is blocked rather than half-checked.
605fn function_def_parts(segment: &str) -> Option<(&str, &str)> {
606    let (name, rest) = function_def_head(segment)?;
607    let body = rest.trim();
608    if body.starts_with('{') {
609        return Some((name, super::compound::balanced_brace_inner(body)?));
610    }
611    if body.starts_with('(') {
612        let (inner, end) = super::compound::balanced_paren_at(body, 0)?;
613        return (end == body.len()).then_some((name, inner.trim()));
614    }
615    None
616}
617
618/// GH #2131: is `text` exactly a function head with its body still to come?
619/// See [`split_with_separators`].
620fn is_function_head(text: &str) -> bool {
621    function_def_head(text).is_some_and(|(_, rest)| rest.trim().is_empty())
622}
623
624/// Parse a function head — `NAME()`, `NAME ()`, `function NAME` or
625/// `function NAME()` — and return the name and the text after the head. Bash
626/// accepts any whitespace, line breaks included, before the body, and
627/// `NAME(){` needs none. `function NAME` without `()` needs whitespace, as
628/// `function f{` names a function `f{`.
629fn function_def_head(segment: &str) -> Option<(&str, &str)> {
630    let cmd_part = skip_env_assignments(segment.trim());
631    let keyword_rest = cmd_part
632        .strip_prefix("function")
633        .filter(|rest| rest.starts_with(|c: char| c.is_whitespace()));
634    let (name, after_name) = match keyword_rest {
635        Some(rest) => split_function_name(rest.trim_start())?,
636        None => split_function_name(cmd_part)?,
637    };
638    match strip_empty_parens(after_name) {
639        Some(rest) => Some((name, rest)),
640        None if keyword_rest.is_some()
641            && (after_name.is_empty() || after_name.starts_with(|c: char| c.is_whitespace())) =>
642        {
643            Some((name, after_name))
644        }
645        None => None,
646    }
647}
648
649fn split_function_name(text: &str) -> Option<(&str, &str)> {
650    let end = text
651        .find(|c: char| !(c.is_alphanumeric() || c == '_'))
652        .unwrap_or(text.len());
653    (end > 0).then(|| text.split_at(end))
654}
655
656/// `()` with optional blanks before and inside it; returns what follows.
657fn strip_empty_parens(text: &str) -> Option<&str> {
658    let rest = text.trim_start_matches([' ', '\t']).strip_prefix('(')?;
659    rest.trim_start_matches([' ', '\t']).strip_prefix(')')
660}
661
662/// Shell builtins that legitimately export or mutate environment variables.
663/// A segment beginning with one of these (`export PATH=…`, `readonly FOO=bar`)
664/// is not a bare inline `PATH=… cmd` hijack — skip the builtin and any
665/// following `VAR=value` tokens so export-only segments contribute no leaf
666/// command and `export PATH=… ; python3 …` resolves to `python3`.
667const ENV_SETTING_BUILTINS: &[&str] =
668    &["export", "unset", "readonly", "local", "declare", "typeset"];
669
670/// Skip leading KEY=VALUE environment variable assignments.
671/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
672/// skips the entire `FOO="bar baz"` token.
673pub(super) fn skip_env_assignments(segment: &str) -> &str {
674    let mut rest = segment;
675    loop {
676        let rest_trimmed = rest.trim_start();
677        if rest_trimmed.is_empty() {
678            return rest_trimmed;
679        }
680        let end = quote_aware_token_end(rest_trimmed);
681        if end == 0 {
682            return rest_trimmed;
683        }
684        let raw_token = &rest_trimmed[..end];
685        let unquoted: String = raw_token
686            .chars()
687            .filter(|c| *c != '"' && *c != '\'')
688            .collect();
689        let base_token = unquoted.rsplit('/').next().unwrap_or(unquoted.as_str());
690        if ENV_SETTING_BUILTINS.contains(&base_token) {
691            rest = &rest_trimmed[end..];
692            continue;
693        }
694        let is_posix_assignment = unquoted.split_once('=').is_some_and(|(name, _)| {
695            let mut bytes = name.bytes();
696            bytes
697                .next()
698                .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
699                && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
700        });
701        if is_posix_assignment {
702            rest = &rest_trimmed[end..];
703        } else {
704            return rest_trimmed;
705        }
706    }
707}
708/// Public accessor for extracting all command segments.
709pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
710    extract_all_commands(command)
711}
712// Legacy compat: single-segment extraction (used by other callers)
713pub fn extract_base_command(command: &str) -> String {
714    let first_seg = split_on_operators(command)
715        .into_iter()
716        .next()
717        .unwrap_or(command);
718    extract_base_from_segment(first_seg)
719}