Skip to main content

zsh/extensions/
pat_cache.rs

1//! Global compiled-pattern cache — a zshrs-only optimization with NO C
2//! counterpart, so it lives here in `src/extensions` rather than in
3//! `src/ported`.
4//!
5//! Why it exists: C zsh recompiles a pattern on every match too, but its C
6//! `patcompile` is ~40-75x faster than this Rust port. Shell code re-matches
7//! identical patterns constantly — `[[ x == pat ]]`, `${(M)a:#pat}`, `case`,
8//! `${x//pat/…}` — and p10k's `_p9k_param` re-matches the SAME
9//! `(#b)prompt_([a-z0-9_]#)(*)` thousands of times per precmd. At the port's
10//! compile cost that dominated interactive startup (the "hang before prompt").
11//! Caching the compiled program turns those repeats into a hash lookup + a
12//! cheap clone of the bytecode, and lets zshrs actually beat zsh here.
13//!
14//! Safety: the compiled `Patprog` is self-contained — matching (`pattry*`)
15//! reads `prog.0.flags` / `prog.0.globflags` (never the global compile
16//! statics) and borrows the prog by `&` (read-only). So a cached clone is
17//! interchangeable with a freshly-compiled one.
18//!
19//! Correctness of the key: the compiled output depends on the pattern text,
20//! `inflags`, and exactly six options — EXTENDEDGLOB/KSHGLOB/SHGLOB (which
21//! shape `zpc_special` in `patcompcharsset`) and CASEGLOB/CASEPATHS/MULTIBYTE
22//! (which shape `patglobflags` in `patcompstart`). All are folded into the
23//! key, so any option change yields a distinct key rather than a stale hit.
24//!
25//! GLOBAL (not thread-local): the VM dispatches `[[ … ]]`/pattern work across
26//! zshrs's worker threads, so a per-thread cache would miss on every worker.
27//! A single `RwLock<HashMap>` is shared: reads (the common case, a hit) take
28//! the read lock and run concurrently; only a miss's store takes the write
29//! lock. The check runs BEFORE `patcompile`'s own compile mutex, so hits never
30//! serialise on the compiler.
31
32use crate::ported::pattern::Patprog;
33use crate::ported::zsh_h::{isset, CASEGLOB, CASEPATHS, EXTENDEDGLOB, KSHGLOB, MULTIBYTE, SHGLOB};
34use std::cell::RefCell;
35use std::collections::HashMap;
36use std::sync::{LazyLock, RwLock};
37
38/// (pattern text, inflags, option fingerprint, `disable -p` bitmask).
39///
40/// The last field is `savepatterndisables()` (c:Src/pattern.c:4220): the
41/// per-token disables also shape `zpc_special` in `patcompcharsset`
42/// (c:471-476), so `[[ x = f*g ]]` compiled before `disable -p '*'` must not
43/// be served after it.
44type Key = (String, i32, u8, u32);
45
46// Two-level cache. L1 is thread-local and LOCK-FREE — the common hit takes
47// no lock, so a hot loop (`repeat 20000 { [[ x == pat ]] }`) doesn't pay
48// RwLock read-contention across zshrs's worker threads (which otherwise
49// dominated the profile). L2 is the shared global that lets a pattern
50// compiled on one worker be reused by another; an L2 hit is promoted into
51// the reader's L1 so subsequent hits stay lock-free.
52thread_local! {
53    static L1: RefCell<HashMap<Key, Patprog>> = RefCell::new(HashMap::new());
54}
55
56static L2: LazyLock<RwLock<HashMap<Key, Patprog>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
57
58/// Bound the cache so a pathological workload (unique patterns forever)
59/// can't grow it without limit. On overflow we clear wholesale rather than
60/// track LRU — pattern working sets are small and stable (a shell reuses a
61/// handful of patterns), so a rare full flush costs one recompile burst,
62/// not a steady eviction tax.
63const MAX_ENTRIES: usize = 8192;
64
65/// Fold the six compilation-affecting options into a fingerprint byte.
66#[inline]
67fn opt_fingerprint() -> u8 {
68    (isset(EXTENDEDGLOB) as u8)
69        | ((isset(KSHGLOB) as u8) << 1)
70        | ((isset(SHGLOB) as u8) << 2)
71        | ((isset(CASEGLOB) as u8) << 3)
72        | ((isset(CASEPATHS) as u8) << 4)
73        | ((isset(MULTIBYTE) as u8) << 5)
74}
75
76/// Look up a compiled pattern. Returns a clone of the cached `Patprog` on a
77/// hit (interchangeable with a fresh compile), `None` on a miss. Checks the
78/// lock-free thread-local L1 first, then the shared L2 (promoting an L2 hit
79/// into L1).
80pub fn get(exp: &str, inflags: i32) -> Option<Patprog> {
81    let key = (
82        exp.to_string(),
83        inflags,
84        opt_fingerprint(),
85        crate::ported::pattern::savepatterndisables(),
86    );
87    if let Some(p) = L1.with(|c| c.borrow().get(&key).cloned()) {
88        return Some(p);
89    }
90    let hit = L2.read().ok()?.get(&key).cloned();
91    if let Some(ref p) = hit {
92        promote_l1(key, p);
93    }
94    hit
95}
96
97/// Store a freshly-compiled pattern in both cache levels.
98pub fn put(exp: &str, inflags: i32, prog: &Patprog) {
99    let key = (
100        exp.to_string(),
101        inflags,
102        opt_fingerprint(),
103        crate::ported::pattern::savepatterndisables(),
104    );
105    if let Ok(mut m) = L2.write() {
106        if m.len() >= MAX_ENTRIES {
107            m.clear();
108        }
109        m.insert(key.clone(), prog.clone());
110    }
111    promote_l1(key, prog);
112}
113
114/// Insert into the thread-local L1 (bounded, wholesale-flush on overflow).
115#[inline]
116fn promote_l1(key: Key, prog: &Patprog) {
117    L1.with(|c| {
118        let mut m = c.borrow_mut();
119        if m.len() >= MAX_ENTRIES {
120            m.clear();
121        }
122        m.insert(key, prog.clone());
123    });
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// A stored pattern is retrievable, and a distinct option state (encoded
131    /// in the fingerprint) does not return the wrong cached program.
132    #[test]
133    fn get_after_put_roundtrips_and_key_is_option_sensitive() {
134        // Compile a trivial pattern and cache it.
135        let prog = crate::ported::pattern::patcompile("abc", 0, None).expect("compile abc");
136        put("zzz_test_pat_abc", 0, &prog);
137        assert!(
138            get("zzz_test_pat_abc", 0).is_some(),
139            "just-put pattern must be retrievable"
140        );
141        // A never-stored pattern misses.
142        assert!(
143            get("zzz_test_pat_never_stored_xyz", 0).is_none(),
144            "unseen pattern must miss"
145        );
146    }
147}