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