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
434thread_local! {
435 /// True while advice code runs. Intercepts are not consulted then, so advice
436 /// that calls the command it advises (`intercept before git { git rev-parse … }`)
437 /// does not re-trigger itself forever.
438 static IN_ADVICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
439}
440
441/// Run `f` with the in-advice flag set to `value`, restoring the previous value.
442fn with_advice_flag<T>(value: bool, f: impl FnOnce() -> T) -> T {
443 let prev = IN_ADVICE.with(|c| c.replace(value));
444 let out = f();
445 IN_ADVICE.with(|c| c.set(prev));
446 out
447}
448
449/// Run `f` as the ORIGINAL command of an around advice: commands it runs are
450/// ordinary again and see their own intercepts.
451pub(crate) fn outside_advice<T>(f: impl FnOnce() -> T) -> T {
452 with_advice_flag(false, f)
453}
454
455/// The alias a command word came out of, with the word count of its body, for
456/// `intercept … ALIAS`. `hasalias` is `input_hasalias()` at the command word.
457/// The body is counted by blank-separated spans (no lexer re-entry from the
458/// parser), so a quoted space in an alias body miscounts the user's arguments.
459thread_local! {
460 /// The real command an alias-level advice stands in front of. While it is
461 /// set, `run_original_command` runs this instead of the (alias) name it is
462 /// handed, so `intercept_proceed` and `after` advice run the expanded command.
463 static ORIGINAL_OVERRIDE: std::cell::RefCell<Option<(String, Vec<String>)>> =
464 const { std::cell::RefCell::new(None) };
465}
466
467/// Installs [`ORIGINAL_OVERRIDE`] for one advice call and restores the previous
468/// value after it, so a command run from inside the advice never sees the outer
469/// alias's override.
470struct OriginalOverrideGuard(Option<(String, Vec<String>)>);
471
472impl OriginalOverrideGuard {
473 fn set(original: Option<(String, Vec<String>)>) -> Self {
474 Self(ORIGINAL_OVERRIDE.with(|slot| slot.replace(original)))
475 }
476}
477
478impl Drop for OriginalOverrideGuard {
479 fn drop(&mut self) {
480 ORIGINAL_OVERRIDE.with(|slot| *slot.borrow_mut() = self.0.take());
481 }
482}
483
484pub(crate) fn alias_origin(hasalias: Option<String>) -> Option<(String, usize)> {
485 let name = hasalias?;
486 let text = crate::ported::hashtable::aliastab_lock()
487 .read()
488 .ok()?
489 .get(name.as_str())?
490 .text
491 .clone();
492 let body_words = crate::ported::hist::histsplitwords(&text, false).len();
493 Some((name, body_words))
494}
495
496// BEGIN moved-from-exec-rs
497impl crate::ported::vm_helper::ShellExecutor {
498 /// Unset every `$INTERCEPT_*` parameter the advice machinery bound, so none
499 /// outlives the command it was bound for.
500 fn clear_advice_vars(&mut self) {
501 for name in [
502 "INTERCEPT_NAME",
503 "INTERCEPT_ARGS",
504 "INTERCEPT_CMD",
505 "INTERCEPT_MS",
506 "INTERCEPT_US",
507 "INTERCEPT_STATUS",
508 "__intercept_proceed",
509 ] {
510 self.unset_scalar(name);
511 }
512 }
513
514 /// Check intercepts for a command. Returns Some(result) if an around
515 /// advice fully handled the command, None to proceed normally.
516 pub(crate) fn run_intercepts(
517 &mut self,
518 cmd_name: &str,
519 full_cmd: &str,
520 args: &[String],
521 ) -> Option<Result<i32, String>> {
522 self.run_intercepts_with(cmd_name, full_cmd, args, None)
523 }
524
525 /// [`run_intercepts`](Self::run_intercepts) for a command word that was an
526 /// ALIAS: advice matches the alias `cmd_name` and sees only the arguments the
527 /// user typed, while `original` (the expanded command) is what runs when the
528 /// advice proceeds or an `after` advice needs the command run.
529 pub(crate) fn run_intercepts_with(
530 &mut self,
531 cmd_name: &str,
532 full_cmd: &str,
533 args: &[String],
534 original: Option<(String, Vec<String>)>,
535 ) -> Option<Result<i32, String>> {
536 let _override_guard = OriginalOverrideGuard::set(original);
537 if IN_ADVICE.with(|c| c.get()) {
538 return None;
539 }
540 // Collect matching intercepts (clone to avoid borrow issues)
541 let matching: Vec<Intercept> = self
542 .intercepts
543 .iter()
544 .filter(|i| intercept_matches(&i.pattern, cmd_name, full_cmd))
545 .cloned()
546 .collect();
547
548 if matching.is_empty() {
549 return None;
550 }
551
552 // Set INTERCEPT_NAME and INTERCEPT_ARGS for advice code
553 self.set_scalar("INTERCEPT_NAME".to_string(), cmd_name.to_string());
554 self.set_scalar("INTERCEPT_ARGS".to_string(), args.join(" "));
555 self.set_scalar("INTERCEPT_CMD".to_string(), full_cmd.to_string());
556
557 // Run before advice
558 for advice in matching
559 .iter()
560 .filter(|i| matches!(i.kind, AdviceKind::Before))
561 {
562 let _ = self.execute_advice(&advice.code);
563 }
564
565 // Check for around advice — first match wins
566 let around = matching
567 .iter()
568 .find(|i| matches!(i.kind, AdviceKind::Around));
569
570 let t0 = std::time::Instant::now();
571
572 let result = if let Some(advice) = around {
573 // Around advice: set INTERCEPT_PROCEED flag, run advice code.
574 // If advice calls `intercept_proceed`, the original command runs.
575 self.set_scalar("__intercept_proceed".to_string(), "0".to_string());
576 let advice_result = self.execute_advice(&advice.code);
577
578 // Check if intercept_proceed was called
579 let proceeded = self
580 .scalar("__intercept_proceed")
581 .map(|v| v == "1")
582 .unwrap_or(false);
583
584 if proceeded {
585 // The original command was already executed inside the advice
586 advice_result
587 } else {
588 // Advice didn't call proceed — command was suppressed
589 advice_result
590 }
591 } else {
592 // No around advice — run the original command.
593 // We return None to let the normal dispatch continue.
594 // But we still need after advice to fire, so we can't return None here
595 // if there are after advices. Run the command ourselves.
596 let has_after = matching.iter().any(|i| matches!(i.kind, AdviceKind::After));
597 if !has_after {
598 // Only before advice, no after — let normal dispatch continue
599 self.clear_advice_vars();
600 return None;
601 }
602
603 // Has after advice — we must run the command and then run after advice
604 self.run_original_command(cmd_name, args)
605 };
606
607 let elapsed = t0.elapsed();
608
609 // Set timing variable for after advice
610 let ms = elapsed.as_secs_f64() * 1000.0;
611 self.set_scalar("INTERCEPT_MS".to_string(), format!("{:.3}", ms));
612 self.set_scalar("INTERCEPT_US".to_string(), format!("{:.0}", ms * 1000.0));
613 // The intercepted command's exit status, for `after` advice that
614 // wants to branch on success. `$?` alone is not enough: the advice
615 // body's own first command overwrites it, and a `before` advice that
616 // ran earlier has already moved it. An advice that failed to run at
617 // all reports 1, matching what the shell would have left behind.
618 self.set_scalar(
619 "INTERCEPT_STATUS".to_string(),
620 match &result {
621 Ok(st) => st.to_string(),
622 Err(_) => "1".to_string(),
623 },
624 );
625
626 // Run after advice
627 for advice in matching
628 .iter()
629 .filter(|i| matches!(i.kind, AdviceKind::After))
630 {
631 let _ = self.execute_advice(&advice.code);
632 }
633
634 // Clean up
635 self.clear_advice_vars();
636
637 Some(result)
638 }
639 /// Execute the original command (used by around/after intercept dispatch).
640 /// Execute advice code — dispatches @ prefix to stryke (fat binary),
641 /// everything else to the shell parser. No fork. Machine code speed.
642 pub(crate) fn execute_advice(&mut self, code: &str) -> Result<i32, String> {
643 let code = code.trim();
644 if code.starts_with('@') {
645 let stryke_code = code.trim_start_matches('@').trim();
646 if let Some(status) = crate::try_stryke_dispatch(stryke_code) {
647 self.set_last_status(status);
648 return Ok(status);
649 }
650 // No stryke handler (thin binary) — fall through to shell
651 }
652 with_advice_flag(true, || self.execute_script(code))
653 }
654 pub(crate) fn run_original_command(
655 &mut self,
656 cmd_name: &str,
657 args: &[String],
658 ) -> Result<i32, String> {
659 let (cmd_name, args) = match ORIGINAL_OVERRIDE.with(|o| o.borrow().clone()) {
660 Some((name, real_args)) => (name, real_args),
661 None => (cmd_name.to_string(), args.to_vec()),
662 };
663 let (cmd_name, args) = (cmd_name.as_str(), args.as_slice());
664 // Function dispatch via the compiled pipeline (functions_compiled
665 // first, falls back to legacy AST recompile if needed).
666 if let Some(status) = self.dispatch_function_call(cmd_name, args) {
667 return Ok(status);
668 }
669 // External command
670 self.execute_external(cmd_name, args, &[])
671 }
672}
673// END moved-from-exec-rs
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn star_matches_anything() {
681 assert!(intercept_matches("*", "anything", "anything --here"));
682 assert!(intercept_matches("*", "", ""));
683 }
684
685 #[test]
686 fn all_matches_anything() {
687 assert!(intercept_matches("all", "ls", "ls -la"));
688 assert!(intercept_matches("all", "git", "git status"));
689 }
690
691 #[test]
692 fn exact_match_on_cmd_name() {
693 assert!(intercept_matches("git", "git", "git push"));
694 assert!(intercept_matches("ls", "ls", "ls -la"));
695 }
696
697 #[test]
698 fn exact_pattern_does_not_match_different_name() {
699 assert!(!intercept_matches("git", "svn", "svn diff"));
700 assert!(!intercept_matches("ls", "lsof", "lsof -p 1"));
701 }
702
703 #[test]
704 fn glob_star_matches_prefix() {
705 // "git *" should match the full command line like "git push origin".
706 assert!(intercept_matches("git *", "git", "git push origin"));
707 }
708
709 #[test]
710 fn glob_star_underscore_prefix_matches_completion_funcs() {
711 // "_*" is the canonical zsh pattern for completion functions.
712 assert!(intercept_matches("_*", "_files", "_files"));
713 assert!(intercept_matches("_*", "_describe", "_describe"));
714 }
715
716 #[test]
717 fn glob_star_does_not_match_non_prefix() {
718 assert!(!intercept_matches("_*", "files", "files"));
719 }
720
721 #[test]
722 fn question_mark_glob_matches_single_char() {
723 assert!(intercept_matches("l?", "ls", "ls"));
724 assert!(!intercept_matches("l?", "lsof", "lsof"));
725 }
726
727 #[test]
728 fn unmatched_pattern_without_glob_chars_returns_false() {
729 assert!(!intercept_matches("nope", "git", "git push"));
730 }
731
732 #[test]
733 fn invalid_glob_pattern_returns_false() {
734 // `[` with no closing bracket is invalid; should not panic and not match.
735 // Pattern with `[` triggers neither the `*` shortcut nor exact match,
736 // but it also contains no `*` or `?`, so we never reach glob parsing.
737 assert!(!intercept_matches("[invalid", "git", "git push"));
738 }
739
740 #[test]
741 fn empty_pattern_does_not_match_non_empty_cmd() {
742 assert!(!intercept_matches("", "ls", "ls -la"));
743 }
744
745 #[test]
746 fn empty_pattern_matches_empty_cmd_exactly() {
747 // Falls through to the `pattern == cmd_name` check.
748 assert!(intercept_matches("", "", ""));
749 }
750
751 #[test]
752 fn advice_kind_variants_round_trip_clone() {
753 let b = AdviceKind::Before;
754 let a = AdviceKind::After;
755 let r = AdviceKind::Around;
756 assert!(matches!(b.clone(), AdviceKind::Before));
757 assert!(matches!(a.clone(), AdviceKind::After));
758 assert!(matches!(r.clone(), AdviceKind::Around));
759 }
760
761 #[test]
762 fn intercept_struct_clone_preserves_fields() {
763 let i = Intercept {
764 pattern: "git *".into(),
765 kind: AdviceKind::Before,
766 code: "echo before".into(),
767 id: 42,
768 };
769 let c = i.clone();
770 assert_eq!(c.pattern, "git *");
771 assert!(matches!(c.kind, AdviceKind::Before));
772 assert_eq!(c.code, "echo before");
773 assert_eq!(c.id, 42);
774 }
775}