Skip to main content

zsh/extensions/
plugin_cache.rs

1//! Plugin source cache — stores side effects of `source`/`.` in
2//! SQLite.
3//!
4//! **zshrs-original infrastructure with strong C-zsh ancestry.** C
5//! zsh has `bin_zcompile()` (Src/parse.c) which writes a parsed
6//! AST to a `.zwc` file alongside the source so subsequent reads
7//! skip parsing. zshrs takes the idea further: rather than caching
8//! the AST and still re-running it, we capture the *side effects*
9//! (params/aliases/options/funcs set) and replay those directly —
10//! microseconds instead of milliseconds for plugin startup. The
11//! key/invalidation model (canonical-path + mtime) matches the
12//! `.zwc` invalidation scheme C zsh uses in `try_source_file()`
13//! (Src/init.c:1551).
14//!
15//! First source: execute normally, capture state delta, write
16//! cache on worker thread.
17//! Subsequent sources: check mtime, replay cached side effects in
18//! microseconds.
19//!
20//! Cache key: `(canonical_path, mtime_secs, mtime_nsecs)`.
21//! Cache invalidation: mtime mismatch → re-source, update cache.
22
23use crate::ported::utils::{errflag, ERRFLAG_ERROR};
24#[allow(unused_imports)]
25use crate::ported::vm_helper::ShellExecutor;
26use crate::ported::zsh_h::PM_UNDEFINED;
27use rusqlite::{params, Connection};
28use std::collections::HashMap;
29use std::env;
30use std::os::unix::fs::MetadataExt;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::Ordering;
33use std::sync::OnceLock;
34
35/// State snapshot for plugin delta computation.
36pub(crate) struct PluginSnapshot {
37    pub(crate) functions: std::collections::HashSet<String>,
38    pub(crate) aliases: std::collections::HashSet<String>,
39    pub(crate) global_aliases: std::collections::HashSet<String>,
40    pub(crate) suffix_aliases: std::collections::HashSet<String>,
41    pub(crate) variables: HashMap<String, String>,
42    pub(crate) arrays: std::collections::HashSet<String>,
43    pub(crate) assoc_arrays: std::collections::HashSet<String>,
44    pub(crate) fpath: Vec<PathBuf>,
45    pub(crate) options: HashMap<String, bool>,
46    pub(crate) hooks: HashMap<String, Vec<String>>,
47    pub(crate) autoloads: std::collections::HashSet<String>,
48}
49
50/// `(mtime, len)` of the running zshrs binary — the identity a cached
51/// plugin delta is stamped with. Same helper as
52/// `script_cache::current_binary_identity` and
53/// `autoload_cache::current_binary_identity`; duplicated here so
54/// plugin_cache doesn't take a dep on either, and so the OnceLock is
55/// per-cache (the value is process-global and identical anyway).
56/// Returns None if the executable's metadata can't be read (extremely
57/// rare — usually only if the binary was deleted out from under us
58/// mid-run), in which case nothing can be proven and nothing is used.
59fn current_binary_identity() -> Option<(i64, u64)> {
60    static BIN_ID: OnceLock<Option<(i64, u64)>> = OnceLock::new();
61    *BIN_ID.get_or_init(|| {
62        let exe = std::env::current_exe().ok()?;
63        let meta = std::fs::metadata(&exe).ok()?;
64        Some((meta.mtime(), meta.len()))
65    })
66}
67
68// Script bytecode caching used to live here behind the BYTECODE_VERSION
69// prefix + script_bytecode SQLite table. It now lives in the rkyv shard at
70// ~/.zshrs/scripts.rkyv (see `crate::script_cache`). The header in
71// that shard carries its own version pin (`zshrs_version`) so this prefix
72// byte is no longer needed — a zshrs rebuild silently invalidates all
73// cached entries via `binary_mtime_at_cache`.
74
75/// Side effects captured from sourcing a plugin file.
76#[derive(Debug, Clone, Default)]
77pub struct PluginDelta {
78    pub functions: Vec<(String, Vec<u8>)>, // name → bincode-serialized bytecode
79    pub aliases: Vec<(String, String, AliasKind)>, // name → value, kind
80    /// `global_aliases` field.
81    pub global_aliases: Vec<(String, String)>,
82    /// `suffix_aliases` field.
83    pub suffix_aliases: Vec<(String, String)>,
84    /// `variables` field.
85    pub variables: Vec<(String, String)>,
86    pub exports: Vec<(String, String)>, // also set in env
87    /// `arrays` field.
88    pub arrays: Vec<(String, Vec<String>)>,
89    /// `assoc_arrays` field.
90    pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
91    pub completions: Vec<(String, String)>, // command → function
92    /// `fpath_additions` field.
93    pub fpath_additions: Vec<String>,
94    pub hooks: Vec<(String, String)>, // hook_name → function
95    pub bindkeys: Vec<(String, String, String)>, // keyseq, widget, keymap
96    pub zstyles: Vec<(String, String, String)>, // pattern, style, value
97    pub options_changed: Vec<(String, bool)>, // option → on/off
98    pub autoloads: Vec<(String, String)>, // function → flags
99}
100/// `AliasKind` — see variants.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum AliasKind {
103    /// `Regular` variant.
104    Regular,
105    /// `Global` variant.
106    Global,
107    /// `Suffix` variant.
108    Suffix,
109}
110
111impl AliasKind {
112    fn as_i32(self) -> i32 {
113        match self {
114            AliasKind::Regular => 0,
115            AliasKind::Global => 1,
116            AliasKind::Suffix => 2,
117        }
118    }
119    fn from_i32(v: i32) -> Self {
120        match v {
121            1 => AliasKind::Global,
122            2 => AliasKind::Suffix,
123            _ => AliasKind::Regular,
124        }
125    }
126}
127
128/// SQLite-backed plugin cache.
129pub struct PluginCache {
130    /// `conn` field.
131    conn: Connection,
132}
133
134impl PluginCache {
135    /// `open` — see implementation.
136    pub fn open(path: &Path) -> rusqlite::Result<Self> {
137        // Hold the script's fd range while SQLite opens the database and,
138        // via the WAL pragma below, its `-wal` and `-shm` side files, so
139        // none of the three land on fds 3-9. See `crate::lowfd`.
140        //
141        // This was the one SQLite open that never took the guard, and it
142        // is the FIRST database the shell opens, so it got the lowest
143        // descriptors of all: `plugins.db` at fd 3, `-wal` at 4, `-shm`
144        // at 5 — the descriptors `exec 3>out`, `print -u 3` and
145        // `read -u 4` address by number. `crate::compsys::cache::open`
146        // (cache.rs:144) and `history` (history.rs:74) already did this.
147        let _lowfd = crate::lowfd::LowFdGuard::new();
148        let conn = Connection::open(path)?;
149        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
150        crate::lowfd::register_internal_fds(); // c:Src/utils.c:2009
151        let cache = Self { conn };
152        cache.init_schema()?;
153        Ok(cache)
154    }
155
156    fn init_schema(&self) -> rusqlite::Result<()> {
157        self.conn.execute_batch(
158            r#"
159            CREATE TABLE IF NOT EXISTS plugins (
160                id INTEGER PRIMARY KEY,
161                path TEXT NOT NULL UNIQUE,
162                mtime_secs INTEGER NOT NULL,
163                mtime_nsecs INTEGER NOT NULL,
164                source_time_ms INTEGER NOT NULL,
165                cached_at INTEGER NOT NULL,
166                binary_mtime INTEGER NOT NULL DEFAULT 0,
167                binary_len INTEGER NOT NULL DEFAULT 0
168            );
169
170            CREATE TABLE IF NOT EXISTS plugin_functions (
171                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
172                name TEXT NOT NULL,
173                body BLOB NOT NULL
174            );
175
176            CREATE TABLE IF NOT EXISTS plugin_aliases (
177                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
178                name TEXT NOT NULL,
179                value TEXT NOT NULL,
180                kind INTEGER NOT NULL DEFAULT 0
181            );
182
183            CREATE TABLE IF NOT EXISTS plugin_variables (
184                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
185                name TEXT NOT NULL,
186                value TEXT NOT NULL,
187                is_export INTEGER NOT NULL DEFAULT 0
188            );
189
190            CREATE TABLE IF NOT EXISTS plugin_arrays (
191                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
192                name TEXT NOT NULL,
193                value_json TEXT NOT NULL
194            );
195
196            -- Associative-array deltas (e.g. ZINIT[BIN_DIR]=...). Stored
197            -- as JSON {key: value} so insertion order isn't load-bearing
198            -- (matches HashMap semantics on the Rust side). Direct
199            -- analogue of plugin_arrays for assoc shape.
200            CREATE TABLE IF NOT EXISTS plugin_assoc_arrays (
201                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
202                name TEXT NOT NULL,
203                value_json TEXT NOT NULL
204            );
205
206            CREATE TABLE IF NOT EXISTS plugin_completions (
207                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
208                command TEXT NOT NULL,
209                function TEXT NOT NULL
210            );
211
212            CREATE TABLE IF NOT EXISTS plugin_fpath (
213                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
214                path TEXT NOT NULL
215            );
216
217            CREATE TABLE IF NOT EXISTS plugin_hooks (
218                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
219                hook TEXT NOT NULL,
220                function TEXT NOT NULL
221            );
222
223            CREATE TABLE IF NOT EXISTS plugin_bindkeys (
224                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
225                keyseq TEXT NOT NULL,
226                widget TEXT NOT NULL,
227                keymap TEXT NOT NULL DEFAULT 'main'
228            );
229
230            CREATE TABLE IF NOT EXISTS plugin_zstyles (
231                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
232                pattern TEXT NOT NULL,
233                style TEXT NOT NULL,
234                value TEXT NOT NULL
235            );
236
237            CREATE TABLE IF NOT EXISTS plugin_options (
238                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
239                name TEXT NOT NULL,
240                enabled INTEGER NOT NULL
241            );
242
243            CREATE TABLE IF NOT EXISTS plugin_autoloads (
244                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
245                function TEXT NOT NULL,
246                flags TEXT NOT NULL DEFAULT ''
247            );
248
249            -- compaudit cache: security audit results per fpath directory
250            CREATE TABLE IF NOT EXISTS compaudit_cache (
251                id INTEGER PRIMARY KEY,
252                path TEXT NOT NULL UNIQUE,
253                mtime_secs INTEGER NOT NULL,
254                mtime_nsecs INTEGER NOT NULL,
255                uid INTEGER NOT NULL,
256                mode INTEGER NOT NULL,
257                is_secure INTEGER NOT NULL,
258                checked_at INTEGER NOT NULL
259            );
260
261            CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
262            CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
263
264            -- Migration: legacy script_bytecode table (bytecode now lives in
265            -- the rkyv shard at ~/.zshrs/scripts.rkyv). Drop on open so
266            -- existing DBs reclaim the space and don't carry stale bytecode.
267            DROP INDEX IF EXISTS idx_script_bytecode_path;
268            DROP TABLE IF EXISTS script_bytecode;
269        "#,
270        )?;
271        // Migrate pre-binary_mtime DBs (column added 2026-05): the
272        // CREATE-IF-NOT-EXISTS above only adds the column for fresh
273        // dbs. ALTER TABLE on an existing db is a one-time no-op
274        // wrapped in an ignored-if-already-applied check. Mirrors the
275        // C analogue of zsh's $ZSH_VERSION-keyed compdump rebuild —
276        // any binary change invalidates the plugin replay shard so
277        // we don't replay deltas captured under the old runtime
278        // semantics. Without this, fixes to paramsubst / option
279        // handling don't take effect until the user manually
280        // `rm ~/.zshrs/plugins.db`.
281        let _ = self.conn.execute(
282            "ALTER TABLE plugins ADD COLUMN binary_mtime INTEGER NOT NULL DEFAULT 0",
283            [],
284        );
285        // Same one-time migration for the binary LENGTH (column added
286        // 2026-08). A row written before this column existed defaults to
287        // 0, which can never equal a real binary length, so pre-migration
288        // rows are treated as belonging to some other build and are
289        // recompiled — the safe direction.
290        let _ = self.conn.execute(
291            "ALTER TABLE plugins ADD COLUMN binary_len INTEGER NOT NULL DEFAULT 0",
292            [],
293        );
294        Ok(())
295    }
296
297    /// Check if a cached entry exists with matching source mtime AND the
298    /// entry was captured by the exact binary that is running now. Direct port of script_cache.rs's invalidation
299    /// logic (lines 188-194): any zshrs rebuild silently invalidates
300    /// plugin-cached deltas because runtime semantics may have
301    /// shifted (paramsubst flags, option aliases, builtin
302    /// resolution, …). Without this guard, a new build reads stale
303    /// deltas and replays them with the new engine — visible
304    /// regression where `zinit.zsh`'s `${ZINIT[BIN_DIR]}` returned
305    /// empty after re-source until the cache was manually cleared.
306    pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
307        let row: Option<(i64, i64, i64)> = self
308            .conn
309            .query_row(
310                "SELECT id, binary_mtime, binary_len FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
311                params![path, mtime_secs, mtime_nsecs],
312                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
313            )
314            .ok();
315        let (id, cached_bin_mtime, cached_bin_len) = row?;
316        // EXACT equality on (mtime, len), the same test `autoload_cache`
317        // applies to its chunks. The previous `cached < running`
318        // comparison only rejected a delta captured by an OLDER build, so
319        // a binary whose mtime went BACKWARDS — an earlier build restored
320        // over a later one, a `cp -p`, a checkout of a previously-built
321        // target dir — silently replayed a newer build's deltas. The
322        // stored mtime also has one-second granularity, so a rebuild
323        // landing in the same second as the cache write compared equal
324        // and passed; the length rules that out too.
325        let (bin_mtime, bin_len) = current_binary_identity()?;
326        if cached_bin_mtime != bin_mtime || cached_bin_len != bin_len as i64 {
327            return None;
328        }
329        Some(id)
330    }
331
332    /// Load cached delta for a plugin by id.
333    pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
334        let mut delta = PluginDelta::default();
335
336        // Functions (bincode-serialized AST blobs)
337        let mut stmt = self
338            .conn
339            .prepare("SELECT name, body FROM plugin_functions WHERE plugin_id = ?1")?;
340        let rows = stmt.query_map(params![plugin_id], |row| {
341            Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
342        })?;
343        for r in rows {
344            delta.functions.push(r?);
345        }
346
347        // Aliases
348        let mut stmt = self
349            .conn
350            .prepare("SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1")?;
351        let rows = stmt.query_map(params![plugin_id], |row| {
352            Ok((
353                row.get::<_, String>(0)?,
354                row.get::<_, String>(1)?,
355                AliasKind::from_i32(row.get::<_, i32>(2)?),
356            ))
357        })?;
358        for r in rows {
359            delta.aliases.push(r?);
360        }
361
362        // Variables
363        let mut stmt = self
364            .conn
365            .prepare("SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1")?;
366        let rows = stmt.query_map(params![plugin_id], |row| {
367            Ok((
368                row.get::<_, String>(0)?,
369                row.get::<_, String>(1)?,
370                row.get::<_, bool>(2)?,
371            ))
372        })?;
373        for r in rows {
374            let (name, value, is_export) = r?;
375            if is_export {
376                delta.exports.push((name, value));
377            } else {
378                delta.variables.push((name, value));
379            }
380        }
381
382        // Arrays
383        let mut stmt = self
384            .conn
385            .prepare("SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1")?;
386        let rows = stmt.query_map(params![plugin_id], |row| {
387            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
388        })?;
389        for r in rows {
390            let (name, json) = r?;
391            // Simple JSON array: ["a","b","c"]
392            let vals: Vec<String> = json
393                .trim_matches(|c| c == '[' || c == ']')
394                .split(',')
395                .map(|s| s.trim().trim_matches('"').to_string())
396                .filter(|s| !s.is_empty())
397                .collect();
398            delta.arrays.push((name, vals));
399        }
400
401        // Associative arrays (key→value JSON object). Falls back to
402        // an empty map on parse failure rather than a load error so
403        // a malformed row doesn't break the whole replay path.
404        let mut stmt = self
405            .conn
406            .prepare("SELECT name, value_json FROM plugin_assoc_arrays WHERE plugin_id = ?1")?;
407        let rows = stmt.query_map(params![plugin_id], |row| {
408            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
409        })?;
410        for r in rows {
411            let (name, json) = r?;
412            let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or_default();
413            delta.assoc_arrays.push((name, map));
414        }
415
416        // Completions
417        let mut stmt = self
418            .conn
419            .prepare("SELECT command, function FROM plugin_completions WHERE plugin_id = ?1")?;
420        let rows = stmt.query_map(params![plugin_id], |row| {
421            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
422        })?;
423        for r in rows {
424            delta.completions.push(r?);
425        }
426
427        // Fpath
428        let mut stmt = self
429            .conn
430            .prepare("SELECT path FROM plugin_fpath WHERE plugin_id = ?1")?;
431        let rows = stmt.query_map(params![plugin_id], |row| row.get::<_, String>(0))?;
432        for r in rows {
433            delta.fpath_additions.push(r?);
434        }
435
436        // Hooks
437        let mut stmt = self
438            .conn
439            .prepare("SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1")?;
440        let rows = stmt.query_map(params![plugin_id], |row| {
441            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
442        })?;
443        for r in rows {
444            delta.hooks.push(r?);
445        }
446
447        // Bindkeys
448        let mut stmt = self
449            .conn
450            .prepare("SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1")?;
451        let rows = stmt.query_map(params![plugin_id], |row| {
452            Ok((
453                row.get::<_, String>(0)?,
454                row.get::<_, String>(1)?,
455                row.get::<_, String>(2)?,
456            ))
457        })?;
458        for r in rows {
459            delta.bindkeys.push(r?);
460        }
461
462        // Zstyles
463        let mut stmt = self
464            .conn
465            .prepare("SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1")?;
466        let rows = stmt.query_map(params![plugin_id], |row| {
467            Ok((
468                row.get::<_, String>(0)?,
469                row.get::<_, String>(1)?,
470                row.get::<_, String>(2)?,
471            ))
472        })?;
473        for r in rows {
474            delta.zstyles.push(r?);
475        }
476
477        // Options
478        let mut stmt = self
479            .conn
480            .prepare("SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1")?;
481        let rows = stmt.query_map(params![plugin_id], |row| {
482            Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
483        })?;
484        for r in rows {
485            delta.options_changed.push(r?);
486        }
487
488        // Autoloads
489        let mut stmt = self
490            .conn
491            .prepare("SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1")?;
492        let rows = stmt.query_map(params![plugin_id], |row| {
493            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
494        })?;
495        for r in rows {
496            delta.autoloads.push(r?);
497        }
498
499        Ok(delta)
500    }
501
502    /// Store a plugin delta. Replaces any existing entry for this path.
503    pub fn store(
504        &self,
505        path: &str,
506        mtime_secs: i64,
507        mtime_nsecs: i64,
508        source_time_ms: u64,
509        delta: &PluginDelta,
510    ) -> rusqlite::Result<()> {
511        let now = std::time::SystemTime::now()
512            .duration_since(std::time::UNIX_EPOCH)
513            .map(|d| d.as_secs() as i64)
514            .unwrap_or(0);
515
516        // Delete old entry if exists
517        self.conn
518            .execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
519
520        let (bin_mtime, bin_len) = current_binary_identity().unwrap_or((0, 0));
521        self.conn.execute(
522            "INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at, binary_mtime, binary_len) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
523            params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now, bin_mtime, bin_len as i64],
524        )?;
525        let plugin_id = self.conn.last_insert_rowid();
526
527        // Functions
528        for (name, body) in &delta.functions {
529            self.conn.execute(
530                "INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
531                params![plugin_id, name, body],
532            )?;
533        }
534
535        // Aliases
536        for (name, value, kind) in &delta.aliases {
537            self.conn.execute(
538                "INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
539                params![plugin_id, name, value, kind.as_i32()],
540            )?;
541        }
542
543        // Variables + exports
544        for (name, value) in &delta.variables {
545            self.conn.execute(
546                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
547                params![plugin_id, name, value],
548            )?;
549        }
550        for (name, value) in &delta.exports {
551            self.conn.execute(
552                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
553                params![plugin_id, name, value],
554            )?;
555        }
556
557        // Arrays
558        for (name, vals) in &delta.arrays {
559            let json = format!(
560                "[{}]",
561                vals.iter()
562                    .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
563                    .collect::<Vec<_>>()
564                    .join(",")
565            );
566            self.conn.execute(
567                "INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
568                params![plugin_id, name, json],
569            )?;
570        }
571
572        // Associative arrays — JSON-encode the key/value map. Use
573        // serde_json so quotes / backslashes / unicode round-trip
574        // correctly through the cache (the simple `["a","b"]`
575        // hand-format used for indexed arrays above doesn't escape
576        // properly for arbitrary-content keys/values).
577        for (name, map) in &delta.assoc_arrays {
578            let json = serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string());
579            self.conn.execute(
580                "INSERT INTO plugin_assoc_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
581                params![plugin_id, name, json],
582            )?;
583        }
584
585        // Completions
586        for (cmd, func) in &delta.completions {
587            self.conn.execute(
588                "INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
589                params![plugin_id, cmd, func],
590            )?;
591        }
592
593        // Fpath
594        for p in &delta.fpath_additions {
595            self.conn.execute(
596                "INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
597                params![plugin_id, p],
598            )?;
599        }
600
601        // Hooks
602        for (hook, func) in &delta.hooks {
603            self.conn.execute(
604                "INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
605                params![plugin_id, hook, func],
606            )?;
607        }
608
609        // Bindkeys
610        for (keyseq, widget, keymap) in &delta.bindkeys {
611            self.conn.execute(
612                "INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
613                params![plugin_id, keyseq, widget, keymap],
614            )?;
615        }
616
617        // Zstyles
618        for (pattern, style, value) in &delta.zstyles {
619            self.conn.execute(
620                "INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
621                params![plugin_id, pattern, style, value],
622            )?;
623        }
624
625        // Options
626        for (name, enabled) in &delta.options_changed {
627            self.conn.execute(
628                "INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
629                params![plugin_id, name, *enabled],
630            )?;
631        }
632
633        // Autoloads
634        for (func, flags) in &delta.autoloads {
635            self.conn.execute(
636                "INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
637                params![plugin_id, func, flags],
638            )?;
639        }
640
641        Ok(())
642    }
643
644    /// Stats for logging.
645    pub fn stats(&self) -> (i64, i64) {
646        let plugins: i64 = self
647            .conn
648            .query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))
649            .unwrap_or(0);
650        let functions: i64 = self
651            .conn
652            .query_row("SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0))
653            .unwrap_or(0);
654        (plugins, functions)
655    }
656
657    /// Count plugins whose file mtime no longer matches the cache.
658    pub fn count_stale(&self) -> usize {
659        let mut stmt = match self
660            .conn
661            .prepare("SELECT path, mtime_secs, mtime_nsecs FROM plugins")
662        {
663            Ok(s) => s,
664            Err(_) => return 0,
665        };
666        let rows = match stmt.query_map([], |row| {
667            Ok((
668                row.get::<_, String>(0)?,
669                row.get::<_, i64>(1)?,
670                row.get::<_, i64>(2)?,
671            ))
672        }) {
673            Ok(r) => r,
674            Err(_) => return 0,
675        };
676        let mut count = 0;
677        for (path, cached_s, cached_ns) in rows.flatten() {
678            match file_mtime(std::path::Path::new(&path)) {
679                Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
680                None => count += 1, // file deleted
681                _ => {}
682            }
683        }
684        count
685    }
686
687    // -----------------------------------------------------------------
688    // compaudit cache — security audit results per fpath directory
689    // -----------------------------------------------------------------
690
691    /// Check if a directory's security audit result is cached and still valid.
692    /// Returns Some(is_secure) if cache hit, None if miss or stale.
693    pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
694        self.conn.query_row(
695            "SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
696            params![dir, mtime_secs, mtime_nsecs],
697            |row| row.get::<_, bool>(0),
698        ).ok()
699    }
700
701    /// Store a compaudit result for a directory.
702    pub fn store_compaudit(
703        &self,
704        dir: &str,
705        mtime_secs: i64,
706        mtime_nsecs: i64,
707        uid: u32,
708        mode: u32,
709        is_secure: bool,
710    ) -> rusqlite::Result<()> {
711        let now = std::time::SystemTime::now()
712            .duration_since(std::time::UNIX_EPOCH)
713            .map(|d| d.as_secs() as i64)
714            .unwrap_or(0);
715
716        self.conn.execute(
717            "INSERT OR REPLACE INTO compaudit_cache (path, mtime_secs, mtime_nsecs, uid, mode, is_secure, checked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
718            params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
719        )?;
720        Ok(())
721    }
722
723    /// Run a full compaudit against fpath directories, using cache where valid.
724    /// Returns list of insecure directories (empty = all secure).
725    pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
726        let euid = unsafe { libc::geteuid() };
727        let mut insecure = Vec::new();
728
729        for dir in fpath {
730            let dir_str = dir.to_string_lossy().to_string();
731            let meta = match std::fs::metadata(dir) {
732                Ok(m) => m,
733                Err(_) => continue, // dir doesn't exist, skip
734            };
735            let mt_s = meta.mtime();
736            let mt_ns = meta.mtime_nsec();
737
738            // Check cache first
739            if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
740                if !is_secure {
741                    insecure.push(dir_str);
742                }
743                continue;
744            }
745
746            // Cache miss — do the actual security check
747            let mode = meta.mode();
748            let uid = meta.uid();
749            let is_secure = Self::check_dir_security(&meta, euid);
750
751            // Also check parent directory
752            let parent_secure = dir
753                .parent()
754                .and_then(|p| std::fs::metadata(p).ok())
755                .map(|pm| Self::check_dir_security(&pm, euid))
756                .unwrap_or(true);
757
758            let secure = is_secure && parent_secure;
759
760            // Cache the result
761            let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
762
763            if !secure {
764                insecure.push(dir_str);
765            }
766        }
767
768        if insecure.is_empty() {
769            tracing::debug!(
770                dirs = fpath.len(),
771                "compaudit: all directories secure (cached)"
772            );
773        } else {
774            tracing::warn!(
775                insecure_count = insecure.len(),
776                dirs = fpath.len(),
777                "compaudit: insecure directories found"
778            );
779        }
780
781        insecure
782    }
783
784    /// Enumerate every plugin currently in the `plugins` table.
785    /// Returns `(path, mtime_secs)` tuples in insertion order.
786    /// Used by `zshrs --dump-plugins` to feed the IntelliJ
787    /// External Libraries view.
788    pub fn list_plugin_paths(&self) -> Vec<(String, i64)> {
789        let mut stmt = match self
790            .conn
791            .prepare("SELECT path, mtime_secs FROM plugins ORDER BY id")
792        {
793            Ok(s) => s,
794            Err(_) => return Vec::new(),
795        };
796        let rows = match stmt.query_map([], |row| {
797            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
798        }) {
799            Ok(r) => r,
800            Err(_) => return Vec::new(),
801        };
802        rows.flatten().collect()
803    }
804
805    /// Check if a directory's permissions are secure.
806    /// Insecure = world-writable or group-writable AND not owned by root or EUID.
807    fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
808        let mode = meta.mode();
809        let uid = meta.uid();
810
811        // Owned by root or the current user — always OK
812        if uid == 0 || uid == euid {
813            return true;
814        }
815
816        // Not owned by us — check if world/group writable
817        let group_writable = mode & 0o020 != 0;
818        let world_writable = mode & 0o002 != 0;
819
820        !group_writable && !world_writable
821    }
822}
823
824/// Get mtime from file metadata as (secs, nsecs).
825pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
826    let meta = std::fs::metadata(path).ok()?;
827    Some((meta.mtime(), meta.mtime_nsec()))
828}
829
830/// One plugin entry as exposed to the IntelliJ External Libraries view.
831/// `manager` is the inferred plugin manager (zinit / oh-my-zsh / prezto /
832/// antidote / antigen / zplug / zsh-more-completions / zpwr / loose).
833/// `name` is the human-readable plugin identifier (`zsh-users/zsh-autosuggestions`,
834/// `git`, etc.). `root` is the absolute directory that holds the plugin's files.
835#[derive(Debug, Clone)]
836pub struct PluginEntry {
837    pub manager: String,
838    pub name: String,
839    pub root: PathBuf,
840}
841
842/// Classify a sourced plugin file path into `(manager, name, root_dir)`.
843/// The match order matters — first hit wins so `~/.oh-my-zsh/custom/plugins/foo`
844/// is "oh-my-zsh" not "loose".
845fn classify_plugin_path(path: &Path) -> PluginEntry {
846    let s = path.to_string_lossy();
847
848    // zinit: `<root>/plugins/<user>---<repo>/<file>` where root is either
849    // `~/.zinit` (legacy) or `$XDG_DATA_HOME/zinit` / `~/.local/share/zinit`.
850    for marker in ["/.zinit/plugins/", "/zinit/plugins/"] {
851        if let Some(start) = s.find(marker) {
852            let after = &s[start + marker.len()..];
853            if let Some(end) = after.find('/') {
854                let dir = &after[..end];
855                let name = dir.replacen("---", "/", 1);
856                let root: PathBuf = s[..start + marker.len() + end].into();
857                return PluginEntry {
858                    manager: "zinit".into(),
859                    name,
860                    root,
861                };
862            }
863        }
864    }
865
866    // oh-my-zsh: `~/.oh-my-zsh/{plugins,custom/plugins,themes,custom/themes}/<name>/<file>`
867    for (marker, kind) in [
868        ("/.oh-my-zsh/custom/plugins/", "plugin"),
869        ("/.oh-my-zsh/plugins/", "plugin"),
870        ("/.oh-my-zsh/custom/themes/", "theme"),
871        ("/.oh-my-zsh/themes/", "theme"),
872    ] {
873        if let Some(start) = s.find(marker) {
874            let after = &s[start + marker.len()..];
875            let end = after.find('/').unwrap_or(after.len());
876            let leaf = &after[..end];
877            let name = if kind == "theme" {
878                format!("{}.theme", leaf)
879            } else {
880                leaf.to_string()
881            };
882            let root: PathBuf = s[..start + marker.len() + end].into();
883            return PluginEntry {
884                manager: "oh-my-zsh".into(),
885                name,
886                root,
887            };
888        }
889    }
890
891    // prezto: `~/.zprezto/modules/<name>/init.zsh`.
892    if let Some(start) = s.find("/.zprezto/modules/") {
893        let after = &s[start + "/.zprezto/modules/".len()..];
894        let end = after.find('/').unwrap_or(after.len());
895        let name = after[..end].to_string();
896        let root: PathBuf = s[..start + "/.zprezto/modules/".len() + end].into();
897        return PluginEntry {
898            manager: "prezto".into(),
899            name,
900            root,
901        };
902    }
903
904    // antidote: `~/.cache/antidote/<user>/<repo>/<file>` or
905    // `~/.local/share/antidote/repos/<user>/<repo>/<file>`.
906    for marker in ["/antidote/repos/", "/.cache/antidote/"] {
907        if let Some(start) = s.find(marker) {
908            let after = &s[start + marker.len()..];
909            // user/repo — two path components.
910            let mut split = after.splitn(3, '/');
911            if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
912                let name = format!("{}/{}", user, repo);
913                let root: PathBuf =
914                    format!("{}{}/{}", &s[..start + marker.len()], user, repo).into();
915                return PluginEntry {
916                    manager: "antidote".into(),
917                    name,
918                    root,
919                };
920            }
921        }
922    }
923
924    // antigen: `~/.antigen/bundles/<user>/<repo>/<file>`.
925    if let Some(start) = s.find("/.antigen/bundles/") {
926        let after = &s[start + "/.antigen/bundles/".len()..];
927        let mut split = after.splitn(3, '/');
928        if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
929            let name = format!("{}/{}", user, repo);
930            let root: PathBuf = format!(
931                "{}/{}/{}",
932                &s[..start + "/.antigen/bundles".len()],
933                user,
934                repo
935            )
936            .into();
937            return PluginEntry {
938                manager: "antigen".into(),
939                name,
940                root,
941            };
942        }
943    }
944
945    // zplug: `~/.zplug/repos/<user>/<repo>/<file>`.
946    if let Some(start) = s.find("/.zplug/repos/") {
947        let after = &s[start + "/.zplug/repos/".len()..];
948        let mut split = after.splitn(3, '/');
949        if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
950            let name = format!("{}/{}", user, repo);
951            let root: PathBuf =
952                format!("{}/{}/{}", &s[..start + "/.zplug/repos".len()], user, repo).into();
953            return PluginEntry {
954                manager: "zplug".into(),
955                name,
956                root,
957            };
958        }
959    }
960
961    // zsh-more-completions: the user's own 16k-file corpus. Group every
962    // file under one logical library so the IDE doesn't render 16k leaves.
963    if let Some(start) = s.find("/zsh-more-completions/") {
964        let root: PathBuf = s[..start + "/zsh-more-completions".len()].into();
965        return PluginEntry {
966            manager: "zsh-more-completions".into(),
967            name: "zsh-more-completions".into(),
968            root,
969        };
970    }
971
972    // zpwr: the user's CLI suite. One library, root = `$ZPWR` or `~/.zpwr`.
973    for marker in ["/.zpwr/", "/zpwr/"] {
974        if let Some(start) = s.find(marker) {
975            let root: PathBuf = s[..start + marker.len() - 1].into();
976            return PluginEntry {
977                manager: "zpwr".into(),
978                name: "zpwr".into(),
979                root,
980            };
981        }
982    }
983
984    // Loose: the file's parent directory is the root, basename is the name.
985    let root = path
986        .parent()
987        .map(PathBuf::from)
988        .unwrap_or_else(|| path.into());
989    let name = root
990        .file_name()
991        .map(|n| n.to_string_lossy().into_owned())
992        .unwrap_or_else(|| "(loose)".into());
993    PluginEntry {
994        manager: "loose".into(),
995        name,
996        root,
997    }
998}
999
1000/// Read every entry in `plugins` and group by `(manager, name, root)`.
1001/// Returns one `PluginEntry` per unique plugin (de-duplicated across the
1002/// many files a single plugin typically sources).
1003pub fn list_plugins(cache_path: &Path) -> Vec<PluginEntry> {
1004    let cache = match PluginCache::open(cache_path) {
1005        Ok(c) => c,
1006        Err(_) => return Vec::new(),
1007    };
1008    let mut seen: std::collections::BTreeMap<(String, String, PathBuf), PluginEntry> =
1009        std::collections::BTreeMap::new();
1010    for (path, _mtime) in cache.list_plugin_paths() {
1011        let entry = classify_plugin_path(Path::new(&path));
1012        seen.entry((
1013            entry.manager.clone(),
1014            entry.name.clone(),
1015            entry.root.clone(),
1016        ))
1017        .or_insert(entry);
1018    }
1019    seen.into_values().collect()
1020}
1021
1022/// JSON consumed by the IntelliJ `AdditionalLibraryRootsProvider`.
1023/// Schema:
1024/// ```json
1025/// {
1026///   "schema": 1,
1027///   "plugins": [
1028///     {"manager": "zinit", "name": "zsh-users/zsh-autosuggestions",
1029///      "root": "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions"}
1030///   ]
1031/// }
1032/// ```
1033/// Manager+name uniquely identify a plugin; root is the directory to
1034/// expose as a synthetic library root.
1035pub fn dump_plugins_json() -> String {
1036    let entries = list_plugins(&default_cache_path());
1037    let mut s = String::from("{\"schema\":1,\"plugins\":[");
1038    for (i, e) in entries.iter().enumerate() {
1039        if i > 0 {
1040            s.push(',');
1041        }
1042        s.push_str(&format!(
1043            "{{\"manager\":{},\"name\":{},\"root\":{}}}",
1044            json_str(&e.manager),
1045            json_str(&e.name),
1046            json_str(&e.root.to_string_lossy())
1047        ));
1048    }
1049    s.push_str("]}");
1050    s
1051}
1052
1053fn json_str(s: &str) -> String {
1054    let mut out = String::with_capacity(s.len() + 2);
1055    out.push('"');
1056    for c in s.chars() {
1057        match c {
1058            '"' => out.push_str("\\\""),
1059            '\\' => out.push_str("\\\\"),
1060            '\n' => out.push_str("\\n"),
1061            '\r' => out.push_str("\\r"),
1062            '\t' => out.push_str("\\t"),
1063            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1064            c => out.push(c),
1065        }
1066    }
1067    out.push('"');
1068    out
1069}
1070
1071/// Default path for the plugin cache db. Honors $ZSHRS_HOME so the
1072/// shell agrees with the daemon on where state lives.
1073pub fn default_cache_path() -> PathBuf {
1074    if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
1075        return PathBuf::from(custom).join("plugins.db");
1076    }
1077    dirs::home_dir()
1078        .unwrap_or_else(|| PathBuf::from("/tmp"))
1079        .join(".zshrs/plugins.db")
1080}
1081
1082#[cfg(test)]
1083mod migration_tests {
1084    use super::*;
1085
1086    #[test]
1087    fn opening_an_existing_db_drops_legacy_script_bytecode_table() {
1088        let _g = crate::test_util::global_state_lock();
1089        // Simulate a pre-migration DB: open with an old schema that still
1090        // had script_bytecode, insert a row, close, then re-open via the
1091        // current `PluginCache::open` path. The migration in `init_schema`
1092        // must leave the table gone so SQLite holds zero bytecode bytes.
1093        let tmp = tempfile::tempdir().unwrap();
1094        let db_path = tmp.path().join("legacy.db");
1095
1096        // Hand-build the legacy table.
1097        let pre = Connection::open(&db_path).unwrap();
1098        pre.execute_batch(
1099            r#"
1100            CREATE TABLE script_bytecode (
1101                id INTEGER PRIMARY KEY,
1102                path TEXT NOT NULL UNIQUE,
1103                mtime_secs INTEGER NOT NULL,
1104                mtime_nsecs INTEGER NOT NULL,
1105                bytecode BLOB NOT NULL,
1106                cached_at INTEGER NOT NULL
1107            );
1108            CREATE INDEX idx_script_bytecode_path ON script_bytecode(path);
1109            INSERT INTO script_bytecode (id, path, mtime_secs, mtime_nsecs, bytecode, cached_at)
1110                VALUES (1, '/fake/legacy.zsh', 0, 0, x'00deadbeef', 0);
1111            "#,
1112        )
1113        .unwrap();
1114        drop(pre);
1115
1116        // Re-open via the production path — migration runs.
1117        let _cache = PluginCache::open(&db_path).expect("open after migration");
1118
1119        // Confirm script_bytecode is gone.
1120        let post = Connection::open(&db_path).unwrap();
1121        let exists: i64 = post
1122            .query_row(
1123                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='script_bytecode'",
1124                [],
1125                |row| row.get(0),
1126            )
1127            .unwrap();
1128        assert_eq!(exists, 0, "legacy script_bytecode must be dropped");
1129    }
1130
1131    // ========================================================
1132    // default_cache_path — ZSHRS_HOME precedence
1133    // ========================================================
1134
1135    fn with_zshrs_home<F: FnOnce()>(value: Option<&str>, f: F) {
1136        let prev = std::env::var_os("ZSHRS_HOME");
1137        match value {
1138            Some(v) => std::env::set_var("ZSHRS_HOME", v),
1139            None => std::env::remove_var("ZSHRS_HOME"),
1140        }
1141        f();
1142        match prev {
1143            Some(v) => std::env::set_var("ZSHRS_HOME", v),
1144            None => std::env::remove_var("ZSHRS_HOME"),
1145        }
1146    }
1147
1148    #[test]
1149    fn default_cache_path_honors_zshrs_home() {
1150        let _g = crate::test_util::global_state_lock();
1151        with_zshrs_home(Some("/tmp/zshrs-plugin-cache-home"), || {
1152            assert_eq!(
1153                default_cache_path(),
1154                PathBuf::from("/tmp/zshrs-plugin-cache-home/plugins.db")
1155            );
1156        });
1157    }
1158
1159    #[test]
1160    fn default_cache_path_filename_is_plugins_db() {
1161        let _g = crate::test_util::global_state_lock();
1162        with_zshrs_home(Some("/tmp/zshrs-plugin-fname"), || {
1163            assert_eq!(
1164                default_cache_path().file_name().and_then(|s| s.to_str()),
1165                Some("plugins.db")
1166            );
1167        });
1168    }
1169
1170    #[test]
1171    fn default_cache_path_falls_back_to_home_dot_zshrs() {
1172        let _g = crate::test_util::global_state_lock();
1173        with_zshrs_home(None, || {
1174            let p = default_cache_path();
1175            // Path must end in `.zshrs/plugins.db` regardless of HOME.
1176            let s = p.to_string_lossy();
1177            assert!(
1178                s.ends_with(".zshrs/plugins.db"),
1179                "expected .zshrs/plugins.db tail, got: {}",
1180                s
1181            );
1182        });
1183    }
1184
1185    #[test]
1186    fn default_cache_path_uses_distinct_dir_per_zshrs_home_change() {
1187        let _g = crate::test_util::global_state_lock();
1188        with_zshrs_home(Some("/tmp/zshrs-plugin-a"), || {
1189            let a = default_cache_path();
1190            with_zshrs_home(Some("/tmp/zshrs-plugin-b"), || {
1191                let b = default_cache_path();
1192                assert_ne!(a, b, "different ZSHRS_HOME must yield different paths");
1193            });
1194        });
1195    }
1196
1197    // ========================================================
1198    // file_mtime — metadata sniff
1199    // ========================================================
1200
1201    #[test]
1202    fn file_mtime_returns_some_for_existing_file() {
1203        let _g = crate::test_util::global_state_lock();
1204        let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime.txt");
1205        std::fs::write(&tmp, b"x").unwrap();
1206        let mt = file_mtime(&tmp);
1207        assert!(mt.is_some(), "existing file should produce mtime");
1208        // Seconds since epoch is positive on any sane system clock.
1209        let (secs, _ns) = mt.unwrap();
1210        assert!(secs > 0, "mtime secs must be positive: {}", secs);
1211        let _ = std::fs::remove_file(&tmp);
1212    }
1213
1214    #[test]
1215    fn file_mtime_returns_none_for_missing_path() {
1216        let _g = crate::test_util::global_state_lock();
1217        assert!(file_mtime(Path::new("/nonexistent/zshrs/missing.bin")).is_none());
1218    }
1219
1220    #[test]
1221    fn file_mtime_secs_monotonic_after_rewrite() {
1222        let _g = crate::test_util::global_state_lock();
1223        let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime_two.txt");
1224        std::fs::write(&tmp, b"a").unwrap();
1225        let first = file_mtime(&tmp).unwrap();
1226        // Sleep slightly to ensure mtime resolution boundary.
1227        std::thread::sleep(std::time::Duration::from_millis(1100));
1228        std::fs::write(&tmp, b"b").unwrap();
1229        let second = file_mtime(&tmp).unwrap();
1230        // Second mtime >= first mtime (clocks don't go backwards
1231        // on any unit-test host we care about).
1232        assert!(
1233            second >= first,
1234            "mtime regressed: first={:?} second={:?}",
1235            first,
1236            second
1237        );
1238        let _ = std::fs::remove_file(&tmp);
1239    }
1240
1241    #[test]
1242    fn file_mtime_path_with_special_chars_resolves() {
1243        let _g = crate::test_util::global_state_lock();
1244        let tmp = std::env::temp_dir().join("zshrs plugin cache (space).bin");
1245        std::fs::write(&tmp, b"x").unwrap();
1246        let mt = file_mtime(&tmp);
1247        assert!(mt.is_some(), "spaces in filename must not block resolution");
1248        let _ = std::fs::remove_file(&tmp);
1249    }
1250
1251    #[test]
1252    fn default_cache_path_relative_zshrs_home_taken_verbatim() {
1253        let _g = crate::test_util::global_state_lock();
1254        with_zshrs_home(Some("relative-dir"), || {
1255            assert_eq!(
1256                default_cache_path(),
1257                PathBuf::from("relative-dir/plugins.db")
1258            );
1259        });
1260    }
1261
1262    #[test]
1263    fn default_cache_path_empty_zshrs_home_is_empty_dir_plus_db() {
1264        let _g = crate::test_util::global_state_lock();
1265        with_zshrs_home(Some(""), || {
1266            // env::var_os("") returns Some("") — code takes the
1267            // override branch and joins "" + "plugins.db" = "plugins.db".
1268            assert_eq!(default_cache_path(), PathBuf::from("plugins.db"));
1269        });
1270    }
1271}
1272
1273#[cfg(test)]
1274mod classify_tests {
1275    use super::*;
1276    use std::path::Path;
1277
1278    fn classify(p: &str) -> (String, String, String) {
1279        let e = classify_plugin_path(Path::new(p));
1280        (e.manager, e.name, e.root.to_string_lossy().into_owned())
1281    }
1282
1283    #[test]
1284    fn zinit_legacy_dir_user_repo() {
1285        let (m, n, r) = classify(
1286            "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions/zsh-autosuggestions.plugin.zsh",
1287        );
1288        assert_eq!(m, "zinit");
1289        assert_eq!(n, "zsh-users/zsh-autosuggestions");
1290        assert_eq!(
1291            r,
1292            "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions"
1293        );
1294    }
1295
1296    #[test]
1297    fn zinit_xdg_dir_user_repo() {
1298        let (m, n, _) =
1299            classify("/home/u/.local/share/zinit/plugins/romkatv---powerlevel10k/p10k.zsh");
1300        assert_eq!(m, "zinit");
1301        assert_eq!(n, "romkatv/powerlevel10k");
1302    }
1303
1304    #[test]
1305    fn oh_my_zsh_core_plugin() {
1306        let (m, n, r) = classify("/Users/wizard/.oh-my-zsh/plugins/git/git.plugin.zsh");
1307        assert_eq!(m, "oh-my-zsh");
1308        assert_eq!(n, "git");
1309        assert_eq!(r, "/Users/wizard/.oh-my-zsh/plugins/git");
1310    }
1311
1312    #[test]
1313    fn oh_my_zsh_custom_plugin() {
1314        let (m, n, _) = classify(
1315            "/Users/wizard/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh",
1316        );
1317        assert_eq!(m, "oh-my-zsh");
1318        assert_eq!(n, "zsh-syntax-highlighting");
1319    }
1320
1321    #[test]
1322    fn oh_my_zsh_theme_tagged_with_theme_suffix() {
1323        let (m, n, _) = classify("/Users/wizard/.oh-my-zsh/themes/agnoster.zsh-theme");
1324        assert_eq!(m, "oh-my-zsh");
1325        assert_eq!(n, "agnoster.zsh-theme.theme");
1326    }
1327
1328    #[test]
1329    fn prezto_module() {
1330        let (m, n, _) = classify("/Users/wizard/.zprezto/modules/git/init.zsh");
1331        assert_eq!(m, "prezto");
1332        assert_eq!(n, "git");
1333    }
1334
1335    #[test]
1336    fn antidote_repo() {
1337        let (m, n, _) = classify(
1338            "/Users/wizard/.cache/antidote/zsh-users/zsh-autosuggestions/zsh-autosuggestions.zsh",
1339        );
1340        assert_eq!(m, "antidote");
1341        assert_eq!(n, "zsh-users/zsh-autosuggestions");
1342    }
1343
1344    #[test]
1345    fn antigen_bundle() {
1346        let (m, n, _) = classify(
1347            "/Users/wizard/.antigen/bundles/zsh-users/zsh-completions/zsh-completions.plugin.zsh",
1348        );
1349        assert_eq!(m, "antigen");
1350        assert_eq!(n, "zsh-users/zsh-completions");
1351    }
1352
1353    #[test]
1354    fn zplug_repo() {
1355        let (m, n, _) = classify(
1356            "/Users/wizard/.zplug/repos/zsh-users/zsh-history-substring-search/zsh-history-substring-search.zsh",
1357        );
1358        assert_eq!(m, "zplug");
1359        assert_eq!(n, "zsh-users/zsh-history-substring-search");
1360    }
1361
1362    #[test]
1363    fn zsh_more_completions_groups_into_one() {
1364        let (m, n, _) =
1365            classify("/Users/wizard/forkedRepos/zsh-more-completions/src/_some_long_completion");
1366        assert_eq!(m, "zsh-more-completions");
1367        assert_eq!(n, "zsh-more-completions");
1368    }
1369
1370    #[test]
1371    fn zpwr_root_recognized() {
1372        let (m, n, _) = classify("/Users/wizard/.zpwr/local/.aliases.sh");
1373        assert_eq!(m, "zpwr");
1374        assert_eq!(n, "zpwr");
1375    }
1376
1377    #[test]
1378    fn loose_plugin_uses_parent_dir_as_name() {
1379        let (m, n, r) = classify("/opt/local/share/zsh/something/init.zsh");
1380        assert_eq!(m, "loose");
1381        assert_eq!(n, "something");
1382        assert_eq!(r, "/opt/local/share/zsh/something");
1383    }
1384}
1385
1386// ===========================================================
1387// Methods moved verbatim from src/ported/vm_helper because their
1388// C counterpart's source file maps 1:1 to this Rust module.
1389// Phase: drift
1390// ===========================================================
1391
1392// BEGIN moved-from-exec-rs
1393impl crate::ported::vm_helper::ShellExecutor {
1394    /// Snapshot executor state before sourcing a plugin (for delta computation).
1395    pub(crate) fn snapshot_state(&self) -> PluginSnapshot {
1396        PluginSnapshot {
1397            functions: self.function_names().into_iter().collect(),
1398            aliases: self.alias_entries().into_iter().map(|(k, _)| k).collect(),
1399            global_aliases: self
1400                .global_alias_entries()
1401                .into_iter()
1402                .map(|(k, _)| k)
1403                .collect(),
1404            suffix_aliases: self
1405                .suffix_alias_entries()
1406                .into_iter()
1407                .map(|(k, _)| k)
1408                .collect(),
1409            variables: if let Ok(tab) = crate::ported::params::paramtab().read() {
1410                tab.iter()
1411                    .filter(|(_, pm)| pm.u_arr.is_none())
1412                    .map(|(k, pm)| (k.clone(), pm.u_str.clone().unwrap_or_default()))
1413                    .collect()
1414            } else {
1415                std::collections::HashMap::new()
1416            },
1417            arrays: if let Ok(tab) = crate::ported::params::paramtab().read() {
1418                tab.iter()
1419                    .filter(|(_, pm)| pm.u_arr.is_some())
1420                    .map(|(k, _)| k.clone())
1421                    .collect()
1422            } else {
1423                std::collections::HashSet::new()
1424            },
1425            assoc_arrays: if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
1426                m.keys().cloned().collect()
1427            } else {
1428                std::collections::HashSet::new()
1429            },
1430            fpath: self.fpath.clone(),
1431            options: crate::ported::options::opt_state_snapshot(),
1432            hooks: {
1433                // Snapshot `<hook>_functions` arrays from canonical paramtab.
1434                let names = [
1435                    "chpwd",
1436                    "precmd",
1437                    "preexec",
1438                    "periodic",
1439                    "zshexit",
1440                    "zshaddhistory",
1441                ];
1442                let mut m = std::collections::HashMap::new();
1443                for h in &names {
1444                    let arr_name = format!("{}_functions", h);
1445                    if let Some(arr) = self.array(&arr_name) {
1446                        if !arr.is_empty() {
1447                            m.insert(h.to_string(), arr);
1448                        }
1449                    }
1450                }
1451                m
1452            },
1453            autoloads: {
1454                // Walk canonical shfunctab for autoload-pending entries
1455                // (PM_UNDEFINED set). Snapshot is keyed by function name.
1456                crate::ported::hashtable::shfunctab_lock()
1457                    .read()
1458                    .ok()
1459                    .map(|t| {
1460                        t.iter()
1461                            .filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
1462                            .map(|(name, _)| name.clone())
1463                            .collect()
1464                    })
1465                    .unwrap_or_default()
1466            },
1467        }
1468    }
1469    /// Compute the delta between current state and a previous snapshot.
1470    pub(crate) fn diff_state(&self, snap: &PluginSnapshot) -> crate::plugin_cache::PluginDelta {
1471        let mut delta = PluginDelta::default();
1472
1473        // Walk every HashMap in sorted-key order so the resulting
1474        // PluginDelta serializes byte-identically across runs of an
1475        // identical state. Without sorting, rkyv-encoded delta blobs
1476        // differ run-to-run, defeating cache reuse and tripping
1477        // diff-based snapshot tests.
1478
1479        // New functions — serialize canonical source text (UTF-8 bytes)
1480        // for instant replay. Replay parses + compiles via the new pipeline.
1481        let mut fn_keys: Vec<&String> = self.function_source.keys().collect();
1482        fn_keys.sort();
1483        for name in fn_keys {
1484            if !snap.functions.contains(name) {
1485                let source = self.function_source.get(name).unwrap();
1486                delta
1487                    .functions
1488                    .push((name.clone(), source.as_bytes().to_vec()));
1489            }
1490        }
1491
1492        let push_alias = |delta: &mut PluginDelta,
1493                          entries: Vec<(String, String)>,
1494                          snap_set: &std::collections::HashSet<String>,
1495                          kind: AliasKind| {
1496            let mut entries = entries;
1497            entries.sort_by(|a, b| a.0.cmp(&b.0));
1498            for (name, value) in entries {
1499                if !snap_set.contains(&name) {
1500                    delta.aliases.push((name, value, kind));
1501                }
1502            }
1503        };
1504        push_alias(
1505            &mut delta,
1506            self.alias_entries(),
1507            &snap.aliases,
1508            AliasKind::Regular,
1509        );
1510        push_alias(
1511            &mut delta,
1512            self.global_alias_entries(),
1513            &snap.global_aliases,
1514            AliasKind::Global,
1515        );
1516        push_alias(
1517            &mut delta,
1518            self.suffix_alias_entries(),
1519            &snap.suffix_aliases,
1520            AliasKind::Suffix,
1521        );
1522
1523        // New/changed variables. Skip shell-special parameters whose
1524        // values are runtime-state, not script-state — replaying them
1525        // poisons subsequent shells with values frozen from the
1526        // capture run. C zsh maintains these per-process and never
1527        // serializes them: `_` (last argv of last command, Src/init.c
1528        // special_params; gets `/tmp/foo` from a prior bash test then
1529        // gets fed into `(( $_ ))` math in a user's .zshrc), `?`
1530        // (last exit), `$`/`!`/`PPID` (process IDs), `RANDOM`,
1531        // `SECONDS`, `EPOCHSECONDS`, `LINENO`, `OLDPWD`, `PWD`
1532        // (volatile; cwd is re-read on replay anyway), `STATUS`,
1533        // `OPTIND`, `IFS` (must default to whitespace at shell
1534        // startup unless user explicitly sets it). Direct port of
1535        // the C analogue's PM_SPECIAL flag — those params don't
1536        // round-trip through the parameter-table dump path.
1537        const NON_REPLAYABLE_VARS: &[&str] = &[
1538            "0",
1539            "_",
1540            "?",
1541            "$",
1542            "!",
1543            "PPID",
1544            "RANDOM",
1545            "SECONDS",
1546            "EPOCHSECONDS",
1547            "EPOCHREALTIME",
1548            "LINENO",
1549            "OLDPWD",
1550            "PWD",
1551            "STATUS",
1552            "OPTIND",
1553            "OPTARG",
1554            "IFS",
1555            "FUNCNAME",
1556            "BASHPID",
1557            "BASH_LINENO",
1558            "BASH_SOURCE",
1559            "ZSH_ARGZERO",
1560            "ZSH_EVAL_CONTEXT",
1561            "ZSH_SUBSHELL",
1562            "HISTCMD",
1563            "MATCH",
1564            "MBEGIN",
1565            "MEND",
1566        ];
1567        let mut var_keys: Vec<String> = if let Ok(tab) = crate::ported::params::paramtab().read() {
1568            tab.iter()
1569                .filter(|(_, pm)| pm.u_arr.is_none())
1570                .map(|(k, _)| k.clone())
1571                .collect()
1572        } else {
1573            Vec::new()
1574        };
1575        var_keys.sort();
1576        for name in &var_keys {
1577            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1578                continue;
1579            }
1580            let value = crate::ported::params::getsparam(name).unwrap_or_default();
1581            match snap.variables.get(name) {
1582                Some(old) if old == &value => {} // unchanged
1583                _ => {
1584                    // Check if it's also exported
1585                    if env::var(name).ok().as_ref() == Some(&value) {
1586                        delta.exports.push((name.clone(), value.clone()));
1587                    } else {
1588                        delta.variables.push((name.clone(), value.clone()));
1589                    }
1590                }
1591            }
1592        }
1593
1594        // New arrays — iterate paramtab for PM_ARRAY entries.
1595        let arr_entries: Vec<(String, Vec<String>)> =
1596            if let Ok(tab) = crate::ported::params::paramtab().read() {
1597                let mut v: Vec<(String, Vec<String>)> = tab
1598                    .iter()
1599                    .filter_map(|(k, pm)| pm.u_arr.clone().map(|a| (k.clone(), a)))
1600                    .collect();
1601                v.sort_by(|a, b| a.0.cmp(&b.0));
1602                v
1603            } else {
1604                Vec::new()
1605            };
1606        for (name, values) in arr_entries {
1607            if !snap.arrays.contains(&name) {
1608                delta.arrays.push((name, values));
1609            }
1610        }
1611
1612        // New / changed associative arrays. zinit creates `ZINIT[…]`
1613        // entries during sourcing; without this capture, the cache
1614        // replay path saw an empty ZINIT and `${ZINIT[BIN_DIR]}`
1615        // returned "" on every subsequent shell start. Direct port of
1616        // zsh's plugin-replay model — assoc deltas are first-class
1617        // captures alongside scalars and arrays.
1618        let assoc_entries: Vec<(String, indexmap::IndexMap<String, String>)> =
1619            if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
1620                let mut v: Vec<(String, indexmap::IndexMap<String, String>)> =
1621                    m.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1622                v.sort_by(|a, b| a.0.cmp(&b.0));
1623                v
1624            } else {
1625                Vec::new()
1626            };
1627        for (name, map) in assoc_entries {
1628            if !snap.assoc_arrays.contains(&name) {
1629                // Executor's assoc storage is IndexMap (insertion-
1630                // ordered, required by `(kv)` etc.). The plugin_cache
1631                // delta uses a plain HashMap since the cache replay
1632                // reseeds the assoc and order is reconstructed by
1633                // the script's own typeset ordering. Convert here.
1634                let plain: HashMap<String, String> =
1635                    map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1636                delta.assoc_arrays.push((name, plain));
1637            }
1638        }
1639
1640        // New fpath entries
1641        for p in &self.fpath {
1642            if !snap.fpath.contains(p) {
1643                delta.fpath_additions.push(p.to_string_lossy().to_string());
1644            }
1645        }
1646
1647        // Changed options — diff against canonical OPTS_LIVE snapshot.
1648        let current = crate::ported::options::opt_state_snapshot();
1649        let mut opt_keys: Vec<&String> = current.keys().collect();
1650        opt_keys.sort();
1651        for name in opt_keys {
1652            let value = current.get(name).unwrap();
1653            match snap.options.get(name) {
1654                Some(old) if old == value => {}
1655                _ => delta.options_changed.push((name.clone(), *value)),
1656            }
1657        }
1658
1659        // New hooks — read from canonical `<hook>_functions` arrays.
1660        let names = [
1661            "chpwd",
1662            "precmd",
1663            "preexec",
1664            "periodic",
1665            "zshexit",
1666            "zshaddhistory",
1667        ];
1668        let mut hook_names: Vec<&&str> = names.iter().collect();
1669        hook_names.sort();
1670        for &h in hook_names {
1671            let arr_name = format!("{}_functions", h);
1672            let funcs = self.array(&arr_name).unwrap_or_default();
1673            let old_funcs = snap.hooks.get(h);
1674            for f in &funcs {
1675                let is_new = old_funcs.is_none_or(|old| !old.contains(f));
1676                if is_new {
1677                    delta.hooks.push((h.to_string(), f.clone()));
1678                }
1679            }
1680        }
1681
1682        // New autoloads — read PM_UNDEFINED entries from canonical shfunctab.
1683        let current_autoloads: Vec<String> = crate::ported::hashtable::shfunctab_lock()
1684            .read()
1685            .ok()
1686            .map(|t| {
1687                t.iter()
1688                    .filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
1689                    .map(|(name, _)| name.clone())
1690                    .collect()
1691            })
1692            .unwrap_or_default();
1693        let mut autoload_keys: Vec<&String> = current_autoloads.iter().collect();
1694        autoload_keys.sort();
1695        for name in autoload_keys {
1696            if !snap.autoloads.contains(name) {
1697                // Flags stub-string: canonical autoload sets only PM_UNDEFINED
1698                // (the -U/-z/-k/-t/-d details were never consumed by replay).
1699                delta.autoloads.push((name.clone(), String::new()));
1700            }
1701        }
1702
1703        delta
1704    }
1705    /// Replay a cached plugin delta into the executor state.
1706    pub(crate) fn replay_plugin_delta(&mut self, delta: &crate::plugin_cache::PluginDelta) {
1707        // Aliases
1708        for (name, value, kind) in &delta.aliases {
1709            match kind {
1710                AliasKind::Regular => {
1711                    self.set_alias(name.clone(), value.clone());
1712                }
1713                AliasKind::Global => {
1714                    self.set_global_alias(name.clone(), value.clone());
1715                }
1716                AliasKind::Suffix => {
1717                    self.set_suffix_alias(name.clone(), value.clone());
1718                }
1719            }
1720        }
1721
1722        // Variables. Drop shell-special parameters even on the
1723        // replay side — pre-existing caches from before the
1724        // diff_state filter was added still contain entries for
1725        // `_`, `PPID`, etc.; replaying them poisons the new shell.
1726        // Keeping the same exclusion list as `diff_state` so old
1727        // caches self-heal on next read.
1728        const NON_REPLAYABLE_VARS: &[&str] = &[
1729            "0",
1730            "_",
1731            "?",
1732            "$",
1733            "!",
1734            "PPID",
1735            "RANDOM",
1736            "SECONDS",
1737            "EPOCHSECONDS",
1738            "EPOCHREALTIME",
1739            "LINENO",
1740            "OLDPWD",
1741            "PWD",
1742            "STATUS",
1743            "OPTIND",
1744            "OPTARG",
1745            "IFS",
1746            "FUNCNAME",
1747            "BASHPID",
1748            "BASH_LINENO",
1749            "BASH_SOURCE",
1750            "ZSH_ARGZERO",
1751            "ZSH_EVAL_CONTEXT",
1752            "ZSH_SUBSHELL",
1753            "HISTCMD",
1754            "MATCH",
1755            "MBEGIN",
1756            "MEND",
1757        ];
1758        for (name, value) in &delta.variables {
1759            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1760                continue;
1761            }
1762            self.set_scalar(name.clone(), value.clone());
1763        }
1764
1765        // Exports (set in both variables and process env)
1766        for (name, value) in &delta.exports {
1767            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1768                continue;
1769            }
1770            self.set_scalar(name.clone(), value.clone());
1771            env::set_var(name, value);
1772        }
1773
1774        // Arrays
1775        for (name, values) in &delta.arrays {
1776            self.set_array(name.clone(), values.clone());
1777        }
1778
1779        // Associative arrays — restore plugin-defined assocs (e.g.
1780        // ZINIT, ZINIT_SNIPPETS, ZINIT_REPORTS) so subsequent shells
1781        // see the same `${ZINIT[BIN_DIR]}` etc. that the original
1782        // sourcing established. Mirrors the diff_state capture above.
1783        for (name, map) in &delta.assoc_arrays {
1784            // Plugin cache uses HashMap; executor uses IndexMap.
1785            // Reseed by inserting key-by-key so the IndexMap variant
1786            // is constructed without needing a HashMap→IndexMap
1787            // From impl that may not be available.
1788            let mut idx_map: indexmap::IndexMap<String, String> =
1789                indexmap::IndexMap::with_capacity(map.len());
1790            // Sort for deterministic order (the diff_state stored
1791            // a HashMap which has no defined order; the original
1792            // insertion order was lost). Sort is the simplest
1793            // reproducible choice — matches `(o)`-flag default.
1794            let mut entries: Vec<(&String, &String)> = map.iter().collect();
1795            entries.sort_by(|a, b| a.0.cmp(b.0));
1796            for (k, v) in entries {
1797                idx_map.insert(k.clone(), v.clone());
1798            }
1799            self.set_assoc(name.clone(), idx_map);
1800        }
1801
1802        // Fpath additions
1803        for p in &delta.fpath_additions {
1804            let pb = PathBuf::from(p);
1805            if !self.fpath.contains(&pb) {
1806                self.fpath.push(pb);
1807            }
1808        }
1809
1810        // Completions
1811        if !delta.completions.is_empty() {
1812            let mut comps = self.assoc("_comps").unwrap_or_default();
1813            for (cmd, func) in &delta.completions {
1814                comps.insert(cmd.clone(), func.clone());
1815            }
1816            self.set_assoc("_comps".to_string(), comps);
1817        }
1818
1819        // Options — write into canonical OPTS_LIVE.
1820        for (name, enabled) in &delta.options_changed {
1821            crate::ported::options::opt_state_set(name, *enabled);
1822        }
1823
1824        // Hooks — append into the canonical `<hook>_functions`
1825        // paramtab array (port of `Src/Functions/Misc/add-zsh-hook`
1826        // shell-function idiom). NOT the C-module HOOKTAB
1827        // (src/ported/module.rs `addhookfunc`), which stores
1828        // Hookfn fn pointers for C-internal hookdefs.
1829        for (hook, func) in &delta.hooks {
1830            let array_name = format!("{}_functions", hook);
1831            let mut arr = self.array(&array_name).unwrap_or_default();
1832            if !arr.iter().any(|f| f == func) {
1833                arr.push(func.clone());
1834                crate::ported::params::setaparam(&array_name, arr);
1835            }
1836        }
1837
1838        // Plugin cache replay: each bincode blob is a ShellCommand AST.
1839        // Replay each function's source text through parse_init + parse + ZshCompiler.
1840        // Delta format: name → UTF-8 source bytes (no AST round-trip needed).
1841        for (name, bytes) in &delta.functions {
1842            let Ok(source) = std::str::from_utf8(bytes) else {
1843                continue;
1844            };
1845            // Mirror Src/init.c errflag save/clear/check around parse.
1846            let saved_errflag = errflag.load(Ordering::Relaxed);
1847            errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1848            crate::ported::parse::parse_init(source);
1849            let program = crate::ported::parse::parse();
1850            let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1851            errflag.store(saved_errflag, Ordering::Relaxed);
1852            if parse_failed || program.lists.is_empty() {
1853                continue;
1854            }
1855            let chunk = crate::compile_zsh::ZshCompiler::new().compile(&program);
1856            self.functions_compiled.insert(name.clone(), chunk);
1857            self.function_source
1858                .insert(name.clone(), source.to_string());
1859        }
1860    }
1861}
1862// END moved-from-exec-rs