Skip to main content

zsh/extensions/
intercepts.rs

1//! Command intercept / advice machinery — extension; no zsh C counterpart.
2#[allow(unused_imports)]
3use crate::ported::vm_helper::ShellExecutor;
4#[allow(unused_imports)]
5use std::collections::HashMap;
6
7/// AOP advice type — before, after, or around.
8#[derive(Debug, Clone)]
9/// Aspect-oriented advice classification.
10/// zshrs-original — no C zsh counterpart. C zsh's closest
11/// analog is the function-wrapper hook in Src/module.c
12/// (`addwrapper()`, used by `zsh/zprof`), but per-function
13/// before/after/around AOP intercepts are unique to zshrs.
14pub enum AdviceKind {
15    /// Run code before the command executes.
16    Before,
17    /// Run code after the command executes. $? and INTERCEPT_MS available.
18    After,
19    /// Wrap the command. Code must call `intercept_proceed` to run original.
20    Around,
21}
22
23/// An intercept registration.
24#[derive(Debug, Clone)]
25/// One AOP intercept registered against a function pattern.
26/// zshrs-original — no C counterpart.
27pub struct Intercept {
28    /// Pattern to match command names. Supports glob: "git *", "_*", "*".
29    pub pattern: String,
30    /// What kind of advice.
31    pub kind: AdviceKind,
32    /// Shell code to execute as advice.
33    pub code: String,
34    /// Unique ID for removal.
35    pub id: u32,
36}
37
38// ===========================================================
39// Block-body syntax — `intercept <kind> <pat> { code }`
40//
41// zshrs-only; no C counterpart. `}` cannot be a bare argument in
42// zsh (`echo }` is "parse error near `}'"), so the documented brace
43// form is not reachable through ordinary word lexing. Worse, the
44// body's own operators would be eaten by the OUTER command: in
45// `intercept before git { echo hi >> ~/git.log }` the `>>` lexes as
46// a redirection of `intercept` itself and never reaches argv, so no
47// amount of re-joining the words downstream can rebuild the body.
48//
49// The lexer therefore captures the span between the braces as RAW
50// SOURCE and hands it over as a single STRING token, which is what
51// `builtin_intercept` wants anyway — it stores advice as text and
52// runs it through `execute_advice`. The parser and the grammar are
53// untouched: `intercept before git { … }` becomes an ordinary
54// four-word simple command.
55// ===========================================================
56
57thread_local! {
58    /// Raised when the command word just lexed was `intercept`, so the
59    /// next `{` in ARGUMENT position opens an advice body rather than
60    /// starting a brace expansion. Cleared by the next command word, and
61    /// by the capture itself.
62    static LEX_ININTERCEPT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
63}
64
65/// Note a token the lexer produced at command position, arming or
66/// disarming the block-body capture.
67///
68/// Assignment, not a one-way set: every command word re-decides, so an
69/// `intercept` that never reached a `{` cannot leave the capture armed
70/// for an unrelated later command.
71///
72/// `quoted` is true when the original token carried Snull/Dnull/Bnull
73/// quote markers — `'intercept' before git { … }` is a quoted word and
74/// gets no keyword treatment, the same rule the reserved-word table
75/// follows for `"if"` / `"}"` (lex.rs:3953).
76pub(crate) fn note_command_word(word: &str, quoted: bool) {
77    LEX_ININTERCEPT.set(word == "intercept" && !quoted);
78}
79
80/// True when the `{` the lexer is looking at opens an advice body.
81///
82/// False under `zshrs --zsh`: that mode promises identical behaviour to
83/// `/bin/zsh`, which rejects the construct outright, so the capture must
84/// stand down and let the normal path produce zsh's own diagnostic.
85///
86/// False whenever the lexer runs with any `lexflags` set. Only the
87/// execution parser lexes with `lexflags == 0`; every non-zero mode is a
88/// word splitter that must see the line as zsh's lexer does — `{`, then
89/// each body word, then `}`:
90///   * `LEXFLAGS_ZLE` — get_comp_string (c:Src/Zle/zle_tricky.c:1170) and
91///     the highlighter. Capturing there swallowed an unclosed body to end
92///     of input and returned LEXERR, so `intercept before ls { ech<TAB>`
93///     lost every word from `{` on; `_intercept` steps past the brace with
94///     `compset -N '{'` instead.
95///   * `LEXFLAGS_ACTIVE` plus the comment/newline modes — bufferwords for
96///     the `(z)` / `(Z:…:)` flags (c:Src/hist.c:3406). Capturing there
97///     returned the whole body as one quoted word.
98pub(crate) fn wants_block() -> bool {
99    use crate::ported::lex::LEX_LEXFLAGS;
100    LEX_ININTERCEPT.get()
101        && LEX_LEXFLAGS.get() == 0
102        && !crate::dash_mode::zsh_dropin()
103}
104
105/// Clear the armed state. Called once a body is captured, and whenever
106/// the lexer resets its context.
107pub(crate) fn disarm() {
108    LEX_ININTERCEPT.set(false);
109}
110
111/// Consume the raw source of an advice body, starting just past its
112/// opening `{`, and return the text between the braces.
113///
114/// `getc` is the lexer's own character source, so the body is taken off
115/// the real input stream — a body may span lines, and on the interactive
116/// REPL the continuation prompt appears exactly as it does inside `{ }`
117/// anywhere else.
118///
119/// Brace counting alone would be wrong: a `}` inside quotes, a comment, a
120/// command substitution, or a here-document is not a terminator. Each of
121/// those is tracked so the scan ends on the brace a reader would pick.
122///
123/// Returns `None` at end of input with the body still open. The consumed
124/// characters are NOT pushed back — there is nothing left to push them
125/// back in front of — and the caller falls through to the ordinary path,
126/// where the unterminated construct raises the usual parse error.
127pub(crate) fn scan_block_body<G>(mut getc: G) -> Option<String>
128where
129    G: FnMut() -> Option<char>,
130{
131    let mut body = String::new();
132    let mut depth: u32 = 1;
133    // Delimiters of here-documents opened on the line being scanned. A
134    // `<<EOF` body is arbitrary text — its braces, quotes and `#` are all
135    // literal — so it is skipped wholesale at the next newline.
136    let mut pending_heredocs: Vec<(String, bool)> = Vec::new();
137    // True while the scanner is between words, where `#` starts a comment.
138    let mut at_word_start = true;
139
140    loop {
141        let c = getc()?;
142
143        match c {
144            '\\' => {
145                // A backslash quotes the next character anywhere outside
146                // single quotes, including a brace and a newline.
147                body.push(c);
148                body.push(getc()?);
149                at_word_start = false;
150                continue;
151            }
152            '\'' => {
153                // Single quotes take everything literally — no escapes.
154                body.push(c);
155                loop {
156                    let q = getc()?;
157                    body.push(q);
158                    if q == '\'' {
159                        break;
160                    }
161                }
162                at_word_start = false;
163                continue;
164            }
165            '"' => {
166                body.push(c);
167                scan_double_quoted(&mut getc, &mut body)?;
168                at_word_start = false;
169                continue;
170            }
171            '`' => {
172                body.push(c);
173                loop {
174                    let q = getc()?;
175                    body.push(q);
176                    match q {
177                        '\\' => body.push(getc()?),
178                        '`' => break,
179                        _ => {}
180                    }
181                }
182                at_word_start = false;
183                continue;
184            }
185            '#' if at_word_start => {
186                // Comment to end of line. The newline itself is left for
187                // the heredoc check below.
188                body.push(c);
189                loop {
190                    match getc() {
191                        Some('\n') => {
192                            body.push('\n');
193                            break;
194                        }
195                        Some(ch) => body.push(ch),
196                        None => return None,
197                    }
198                }
199                at_word_start = true;
200                if !pending_heredocs.is_empty() {
201                    drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
202                }
203                continue;
204            }
205            '<' => {
206                body.push(c);
207                let (delim, stopped_at) = scan_heredoc_intro(&mut getc, &mut body)?;
208                if let Some(d) = delim {
209                    pending_heredocs.push(d);
210                }
211                at_word_start = false;
212                // Reading the delimiter word necessarily consumed the
213                // character that ended it, and for `cat <<EOF` that
214                // character IS the newline the here-document body starts
215                // after. The main loop will never see it, so the drain has
216                // to happen here or the body's text gets scanned as shell.
217                if stopped_at == Some('\n') {
218                    at_word_start = true;
219                    if !pending_heredocs.is_empty() {
220                        drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
221                    }
222                }
223                continue;
224            }
225            '\n' => {
226                body.push(c);
227                at_word_start = true;
228                if !pending_heredocs.is_empty() {
229                    drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
230                }
231                continue;
232            }
233            '{' => {
234                depth += 1;
235                body.push(c);
236            }
237            '}' => {
238                depth -= 1;
239                if depth == 0 {
240                    return Some(body);
241                }
242                body.push(c);
243            }
244            _ => body.push(c),
245        }
246        at_word_start = c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')');
247    }
248}
249
250/// Copy a double-quoted string, stopping after its closing quote.
251///
252/// Backslash still escapes inside double quotes, and `$( … )` may contain
253/// arbitrary shell — including more quotes and braces — so the nested
254/// command substitution is copied through by paren depth rather than
255/// scanned for a terminator.
256fn scan_double_quoted<G>(getc: &mut G, body: &mut String) -> Option<()>
257where
258    G: FnMut() -> Option<char>,
259{
260    loop {
261        let c = getc()?;
262        body.push(c);
263        match c {
264            '\\' => body.push(getc()?),
265            '"' => return Some(()),
266            '$' => {
267                let n = getc()?;
268                body.push(n);
269                if n == '(' {
270                    let mut depth = 1;
271                    while depth > 0 {
272                        let q = getc()?;
273                        body.push(q);
274                        match q {
275                            '\\' => body.push(getc()?),
276                            '(' => depth += 1,
277                            ')' => depth -= 1,
278                            _ => {}
279                        }
280                    }
281                }
282            }
283            _ => {}
284        }
285    }
286}
287
288/// After a `<`, decide whether this is a here-document and if so return
289/// its delimiter plus whether `<<-` tab-stripping is in effect.
290///
291/// `<<<` is a here-STRING: an ordinary word follows, with no body to skip.
292#[allow(clippy::type_complexity)]
293fn scan_heredoc_intro<G>(
294    getc: &mut G,
295    body: &mut String,
296) -> Option<(Option<(String, bool)>, Option<char>)>
297where
298    G: FnMut() -> Option<char>,
299{
300    let second = getc()?;
301    body.push(second);
302    if second != '<' {
303        return Some((None, Some(second)));
304    }
305    let mut third = getc()?;
306    body.push(third);
307    if third == '<' {
308        // here-string
309        return Some((None, Some(third)));
310    }
311    let strip_tabs = third == '-';
312    if strip_tabs {
313        third = getc()?;
314        body.push(third);
315    }
316    // Skip blanks between the operator and the delimiter word.
317    let mut c = third;
318    while c == ' ' || c == '\t' {
319        c = getc()?;
320        body.push(c);
321    }
322    // The delimiter may be quoted; the quotes are not part of it.
323    let mut delim = String::new();
324    // The character the delimiter word ended on, handed back so the caller
325    // knows whether the here-document body starts immediately.
326    let mut stopped_at: Option<char> = None;
327    loop {
328        match c {
329            '\'' | '"' => {
330                let close = c;
331                loop {
332                    let q = getc()?;
333                    body.push(q);
334                    if q == close {
335                        break;
336                    }
337                    delim.push(q);
338                }
339            }
340            '\\' => {
341                let q = getc()?;
342                body.push(q);
343                delim.push(q);
344            }
345            _ if c.is_whitespace() || c == ';' || c == '&' || c == '|' || c == ')' => {
346                stopped_at = Some(c);
347                break;
348            }
349            _ => delim.push(c),
350        }
351        match getc() {
352            Some(n) => {
353                c = n;
354                body.push(n);
355            }
356            None => break,
357        }
358    }
359    if delim.is_empty() {
360        Some((None, stopped_at))
361    } else {
362        Some((Some((delim, strip_tabs)), stopped_at))
363    }
364}
365
366/// Copy here-document bodies verbatim, one per pending delimiter.
367///
368/// Everything up to the terminator line is literal text: braces, quotes
369/// and `#` inside it must not steer the scan.
370fn drain_heredocs<G>(
371    getc: &mut G,
372    body: &mut String,
373    pending: &mut Vec<(String, bool)>,
374) -> Option<()>
375where
376    G: FnMut() -> Option<char>,
377{
378    for (delim, strip_tabs) in pending.drain(..) {
379        loop {
380            let mut line = String::new();
381            let mut hit_eof = true;
382            while let Some(c) = getc() {
383                if c == '\n' {
384                    hit_eof = false;
385                    break;
386                }
387                line.push(c);
388            }
389            body.push_str(&line);
390            if !hit_eof {
391                body.push('\n');
392            }
393            let candidate = if strip_tabs {
394                line.trim_start_matches('\t')
395            } else {
396                line.as_str()
397            };
398            if candidate == delim {
399                break;
400            }
401            if hit_eof {
402                // zsh warns and treats EOF as the terminator; the body ends
403                // here either way.
404                return Some(());
405            }
406        }
407    }
408    Some(())
409}
410
411/// Match an intercept pattern against a command name or full command string.
412/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
413pub(crate) fn intercept_matches(pattern: &str, cmd_name: &str, full_cmd: &str) -> bool {
414    if pattern == "*" || pattern == "all" {
415        return true;
416    }
417    if pattern == cmd_name {
418        return true;
419    }
420    if pattern.contains('*') || pattern.contains('?') {
421        if let Ok(pat) = glob::Pattern::new(pattern) {
422            return pat.matches(cmd_name) || pat.matches(full_cmd);
423        }
424    }
425    false
426}
427
428// ===========================================================
429// Methods moved verbatim from src/ported/vm_helper because their
430// C counterpart's source file maps 1:1 to this Rust module.
431// Phase: drift
432// ===========================================================
433
434// BEGIN moved-from-exec-rs
435impl crate::ported::vm_helper::ShellExecutor {
436    /// Check intercepts for a command. Returns Some(result) if an around
437    /// advice fully handled the command, None to proceed normally.
438    pub(crate) fn run_intercepts(
439        &mut self,
440        cmd_name: &str,
441        full_cmd: &str,
442        args: &[String],
443    ) -> Option<Result<i32, String>> {
444        // Collect matching intercepts (clone to avoid borrow issues)
445        let matching: Vec<Intercept> = self
446            .intercepts
447            .iter()
448            .filter(|i| intercept_matches(&i.pattern, cmd_name, full_cmd))
449            .cloned()
450            .collect();
451
452        if matching.is_empty() {
453            return None;
454        }
455
456        // Set INTERCEPT_NAME and INTERCEPT_ARGS for advice code
457        self.set_scalar("INTERCEPT_NAME".to_string(), cmd_name.to_string());
458        self.set_scalar("INTERCEPT_ARGS".to_string(), args.join(" "));
459        self.set_scalar("INTERCEPT_CMD".to_string(), full_cmd.to_string());
460
461        // Run before advice
462        for advice in matching
463            .iter()
464            .filter(|i| matches!(i.kind, AdviceKind::Before))
465        {
466            let _ = self.execute_advice(&advice.code);
467        }
468
469        // Check for around advice — first match wins
470        let around = matching
471            .iter()
472            .find(|i| matches!(i.kind, AdviceKind::Around));
473
474        let t0 = std::time::Instant::now();
475
476        let result = if let Some(advice) = around {
477            // Around advice: set INTERCEPT_PROCEED flag, run advice code.
478            // If advice calls `intercept_proceed`, the original command runs.
479            self.set_scalar("__intercept_proceed".to_string(), "0".to_string());
480            let advice_result = self.execute_advice(&advice.code);
481
482            // Check if intercept_proceed was called
483            let proceeded = self
484                .scalar("__intercept_proceed")
485                .map(|v| v == "1")
486                .unwrap_or(false);
487
488            if proceeded {
489                // The original command was already executed inside the advice
490                advice_result
491            } else {
492                // Advice didn't call proceed — command was suppressed
493                advice_result
494            }
495        } else {
496            // No around advice — run the original command.
497            // We return None to let the normal dispatch continue.
498            // But we still need after advice to fire, so we can't return None here
499            // if there are after advices. Run the command ourselves.
500            let has_after = matching.iter().any(|i| matches!(i.kind, AdviceKind::After));
501            if !has_after {
502                // Only before advice, no after — let normal dispatch continue
503                return None;
504            }
505
506            // Has after advice — we must run the command and then run after advice
507            self.run_original_command(cmd_name, args)
508        };
509
510        let elapsed = t0.elapsed();
511
512        // Set timing variable for after advice
513        let ms = elapsed.as_secs_f64() * 1000.0;
514        self.set_scalar("INTERCEPT_MS".to_string(), format!("{:.3}", ms));
515        self.set_scalar("INTERCEPT_US".to_string(), format!("{:.0}", ms * 1000.0));
516        // The intercepted command's exit status, for `after` advice that
517        // wants to branch on success. `$?` alone is not enough: the advice
518        // body's own first command overwrites it, and a `before` advice that
519        // ran earlier has already moved it. An advice that failed to run at
520        // all reports 1, matching what the shell would have left behind.
521        self.set_scalar(
522            "INTERCEPT_STATUS".to_string(),
523            match &result {
524                Ok(st) => st.to_string(),
525                Err(_) => "1".to_string(),
526            },
527        );
528
529        // Run after advice
530        for advice in matching
531            .iter()
532            .filter(|i| matches!(i.kind, AdviceKind::After))
533        {
534            let _ = self.execute_advice(&advice.code);
535        }
536
537        // Clean up
538        self.unset_scalar("INTERCEPT_NAME");
539        self.unset_scalar("INTERCEPT_ARGS");
540        self.unset_scalar("INTERCEPT_CMD");
541        self.unset_scalar("INTERCEPT_MS");
542        self.unset_scalar("INTERCEPT_US");
543        self.unset_scalar("INTERCEPT_STATUS");
544        self.unset_scalar("__intercept_proceed");
545
546        Some(result)
547    }
548    /// Execute the original command (used by around/after intercept dispatch).
549    /// Execute advice code — dispatches @ prefix to stryke (fat binary),
550    /// everything else to the shell parser. No fork. Machine code speed.
551    pub(crate) fn execute_advice(&mut self, code: &str) -> Result<i32, String> {
552        let code = code.trim();
553        if code.starts_with('@') {
554            let stryke_code = code.trim_start_matches('@').trim();
555            if let Some(status) = crate::try_stryke_dispatch(stryke_code) {
556                self.set_last_status(status);
557                return Ok(status);
558            }
559            // No stryke handler (thin binary) — fall through to shell
560        }
561        self.execute_script(code)
562    }
563    pub(crate) fn run_original_command(
564        &mut self,
565        cmd_name: &str,
566        args: &[String],
567    ) -> Result<i32, String> {
568        // Function dispatch via the compiled pipeline (functions_compiled
569        // first, falls back to legacy AST recompile if needed).
570        if let Some(status) = self.dispatch_function_call(cmd_name, args) {
571            return Ok(status);
572        }
573        // External command
574        self.execute_external(cmd_name, args, &[])
575    }
576}
577// END moved-from-exec-rs
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn star_matches_anything() {
585        assert!(intercept_matches("*", "anything", "anything --here"));
586        assert!(intercept_matches("*", "", ""));
587    }
588
589    #[test]
590    fn all_matches_anything() {
591        assert!(intercept_matches("all", "ls", "ls -la"));
592        assert!(intercept_matches("all", "git", "git status"));
593    }
594
595    #[test]
596    fn exact_match_on_cmd_name() {
597        assert!(intercept_matches("git", "git", "git push"));
598        assert!(intercept_matches("ls", "ls", "ls -la"));
599    }
600
601    #[test]
602    fn exact_pattern_does_not_match_different_name() {
603        assert!(!intercept_matches("git", "svn", "svn diff"));
604        assert!(!intercept_matches("ls", "lsof", "lsof -p 1"));
605    }
606
607    #[test]
608    fn glob_star_matches_prefix() {
609        // "git *" should match the full command line like "git push origin".
610        assert!(intercept_matches("git *", "git", "git push origin"));
611    }
612
613    #[test]
614    fn glob_star_underscore_prefix_matches_completion_funcs() {
615        // "_*" is the canonical zsh pattern for completion functions.
616        assert!(intercept_matches("_*", "_files", "_files"));
617        assert!(intercept_matches("_*", "_describe", "_describe"));
618    }
619
620    #[test]
621    fn glob_star_does_not_match_non_prefix() {
622        assert!(!intercept_matches("_*", "files", "files"));
623    }
624
625    #[test]
626    fn question_mark_glob_matches_single_char() {
627        assert!(intercept_matches("l?", "ls", "ls"));
628        assert!(!intercept_matches("l?", "lsof", "lsof"));
629    }
630
631    #[test]
632    fn unmatched_pattern_without_glob_chars_returns_false() {
633        assert!(!intercept_matches("nope", "git", "git push"));
634    }
635
636    #[test]
637    fn invalid_glob_pattern_returns_false() {
638        // `[` with no closing bracket is invalid; should not panic and not match.
639        // Pattern with `[` triggers neither the `*` shortcut nor exact match,
640        // but it also contains no `*` or `?`, so we never reach glob parsing.
641        assert!(!intercept_matches("[invalid", "git", "git push"));
642    }
643
644    #[test]
645    fn empty_pattern_does_not_match_non_empty_cmd() {
646        assert!(!intercept_matches("", "ls", "ls -la"));
647    }
648
649    #[test]
650    fn empty_pattern_matches_empty_cmd_exactly() {
651        // Falls through to the `pattern == cmd_name` check.
652        assert!(intercept_matches("", "", ""));
653    }
654
655    #[test]
656    fn advice_kind_variants_round_trip_clone() {
657        let b = AdviceKind::Before;
658        let a = AdviceKind::After;
659        let r = AdviceKind::Around;
660        assert!(matches!(b.clone(), AdviceKind::Before));
661        assert!(matches!(a.clone(), AdviceKind::After));
662        assert!(matches!(r.clone(), AdviceKind::Around));
663    }
664
665    #[test]
666    fn intercept_struct_clone_preserves_fields() {
667        let i = Intercept {
668            pattern: "git *".into(),
669            kind: AdviceKind::Before,
670            code: "echo before".into(),
671            id: 42,
672        };
673        let c = i.clone();
674        assert_eq!(c.pattern, "git *");
675        assert!(matches!(c.kind, AdviceKind::Before));
676        assert_eq!(c.code, "echo before");
677        assert_eq!(c.id, 42);
678    }
679}