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}