use rayon::prelude::*;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use super::compinit::{CompFileDef, CompInitResult};
pub fn compdump(
result: &CompInitResult,
dump_path: &Path,
zsh_version: &str,
) -> std::io::Result<PathBuf> {
let host = hostname();
let pid = std::process::id();
let tmp = dump_path.with_file_name(format!(
"{}.{}.{}",
dump_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
host,
pid
));
{
let mut file = BufWriter::new(File::create(&tmp)?);
writeln!(
file,
"#files: {}\tversion: {}",
result.files_scanned, zsh_version
)?;
write_assoc_dump(&mut file, "_comps", &result.comps)?;
write_assoc_dump(&mut file, "_services", &result.services)?;
write_assoc_dump(&mut file, "_patcomps", &result.patcomps)?;
write_assoc_dump(&mut file, "_postpatcomps", &result.postpatcomps)?;
write_assoc_dump(&mut file, "_compautos", &result.compautos)?;
let mut autoload_names: Vec<String> = result
.files
.iter()
.filter_map(|f| match &f.def {
CompFileDef::CompDef(_) => Some(f.name.clone()),
_ => None,
})
.collect();
autoload_names.sort();
autoload_names.dedup();
if !autoload_names.is_empty() {
writeln!(file, "autoload -Uz \\")?;
for (i, name) in autoload_names.iter().enumerate() {
let cont = if i + 1 < autoload_names.len() {
" \\"
} else {
""
};
writeln!(file, " {}{}", name, cont)?;
}
}
let mut compautos_sorted: Vec<(&String, &String)> = result.compautos.iter().collect();
compautos_sorted.sort_by(|a, b| a.0.cmp(b.0));
for (name, opts) in &compautos_sorted {
let opt_str = if opts.is_empty() {
"-Uz".to_string()
} else {
opts.to_string()
};
writeln!(file, "autoload {} {}", opt_str, name)?;
}
file.flush()?;
file.into_inner()
.map_err(|e| std::io::Error::other(e.to_string()))?
.sync_all()?;
}
fs::rename(&tmp, dump_path)?;
Ok(dump_path.to_path_buf())
}
fn write_assoc_dump<W: Write>(
w: &mut W,
name: &str,
entries: &std::collections::HashMap<String, String>,
) -> std::io::Result<()> {
writeln!(w, "typeset -gHA {}", name)?;
if entries.is_empty() {
writeln!(w, "{}=(\n)", name)?;
return Ok(());
}
let mut sorted: Vec<(&String, &String)> = entries.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
writeln!(w, "{}=(", name)?;
for (k, v) in &sorted {
writeln!(w, " {} {}", qq(k), qq(v))?;
}
writeln!(w, ")")?;
Ok(())
}
pub(super) fn qq(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
out.push_str(&s.replace('\'', "'\\''"));
out.push('\'');
out
}
fn hostname() -> String {
std::env::var("HOST")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "localhost".to_string())
}
pub fn check_dump(dump_path: &Path, fpath: &[PathBuf], zsh_version: &str) -> bool {
let file = match File::open(dump_path) {
Ok(f) => f,
Err(_) => return false,
};
let mut reader = BufReader::new(file);
let mut first_line = String::new();
if reader.read_line(&mut first_line).is_err() {
return false;
}
let line = first_line.trim_end_matches('\n');
let stripped = match line.strip_prefix("#files:") {
Some(s) => s.trim_start(),
None => return false,
};
let mut parts = stripped.splitn(2, '\t');
let n_str = parts.next().unwrap_or("").trim();
let version_part = parts.next().unwrap_or("");
let stored_version = match version_part.strip_prefix("version:") {
Some(s) => s.trim(),
None => return false,
};
let stored_count: usize = match n_str.parse() {
Ok(n) => n,
Err(_) => return false,
};
let current_count: usize = fpath
.par_iter()
.filter(|dir| dir.as_os_str() != "." && dir.exists())
.map(|dir| {
fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with('_'))
.count()
})
.unwrap_or(0)
})
.sum();
stored_count == current_count && stored_version == zsh_version
}
pub(super) fn escape_zsh_string(s: &str) -> String {
s.replace('\'', "'\\''")
}
pub fn dump_file_count(fpath: &[PathBuf]) -> usize {
fpath
.iter()
.filter(|d| d.as_os_str() != ".") .map(|dir| {
fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n.starts_with('_') && !n.ends_with('~') && !n.ends_with(".zwc")
})
.count()
})
.unwrap_or(0)
})
.sum()
}
fn widget_dump_lines() -> (Vec<String>, Vec<String>) {
use crate::ported::utils::quotedzputs;
use crate::ported::zle::zle_h::{WidgetImpl, WIDGET_INT};
let mut triples: Vec<(String, String, String)> = Vec::new();
if let Ok(tab) = crate::ported::zle::zle_thingy::thingytab().lock() {
for (name, t) in tab.iter() {
let Some(w) = t.widget.as_ref() else { continue };
if (w.flags & WIDGET_INT) != 0 {
continue;
}
if let WidgetImpl::Comp { wid, func, .. } = &w.u {
if name.starts_with('_') && func.starts_with('_') {
triples.push((name.clone(), wid.clone(), func.clone()));
}
}
}
}
triples.sort();
let mut lines = Vec::with_capacity(triples.len() + 1);
let mut bks = Vec::with_capacity(triples.len());
let mut complist = false; for (name, wid, func) in triples {
if !complist && wid == ".menu-select" {
lines.push("zmodload -i zsh/complist".to_string()); complist = true; }
lines.push(format!(
"zle -C {} {} {}",
quotedzputs(&name),
quotedzputs(&wid),
quotedzputs(&func)
));
bks.push(name); }
(lines, bks)
}
fn bindkey_dump_lines(bks: &[String]) -> Vec<String> {
let Some(km) = crate::ported::zle::zle_keymap::openkeymap("main") else {
return Vec::new();
};
let mut lines = Vec::new();
crate::ported::zle::zle_keymap::scankeymap(&km, 1, &mut |seq, bind, _str| {
let Some(t) = bind else { return };
if !bks.iter().any(|b| b == &t.nam) {
return;
}
let quoted = crate::ported::zle::zle_utils::bindztrdup(seq);
let inner = quoted
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or("ed);
lines.push(format!("bindkey '{}' {}", inner, t.nam));
});
lines
}
fn autoload_dump_names(fpath: &[PathBuf]) -> Vec<String> {
let mut defined: Vec<String> = match crate::ported::hashtable::shfunctab_lock().read() {
Ok(tab) => tab
.iter()
.map(|(k, _)| k)
.filter(|k| k.starts_with('_'))
.cloned()
.collect(),
Err(_) => return Vec::new(),
};
defined.sort(); let mut out = Vec::new();
for dir in fpath {
for name in &defined {
if dir.join(name).is_file() {
out.push(name.clone()); }
}
}
out
}
pub fn compdump_live(
tables: &super::compinit::DumpTables,
dump_path: &Path,
zsh_version: &str,
fpath: &[PathBuf],
) -> std::io::Result<PathBuf> {
let tmp = dump_path.with_file_name(format!(
"{}.{}.{}",
dump_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
hostname(),
std::process::id()
));
let parent = dump_path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(p) = parent {
if !p.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{}: no such directory", p.display()),
));
}
}
{
let mut file = BufWriter::new(File::create(&tmp)?);
writeln!(
file,
"#files: {}\tversion: {}",
dump_file_count(fpath),
zsh_version
)?;
for (name, entries) in [
("_comps", &tables.comps),
("_services", &tables.services),
("_patcomps", &tables.patcomps),
("_postpatcomps", &tables.postpatcomps),
("_compautos", &tables.compautos),
] {
writeln!(file)?;
writeln!(file, "{}=(", name)?;
let mut sorted: Vec<(&String, &String)> = entries.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in sorted {
writeln!(file, "{} {}", qq(k), qq(v))?; }
writeln!(file, ")")?;
}
writeln!(file)?;
let (widget_lines, bks) = widget_dump_lines();
for line in &widget_lines {
writeln!(file, "{}", line)?;
}
for line in bindkey_dump_lines(&bks) {
writeln!(file, "{}", line)?;
}
writeln!(file)?;
let als = autoload_dump_names(fpath);
write!(file, "autoload -Uz")?; let mut i = 5; for (idx, name) in als.iter().enumerate() {
if tables.compautos.contains_key(name) {
continue; }
write!(file, " {}", name)?; i -= 1;
if i == 0 && als.len() - idx > 1 {
i = 5; write!(file, " \\\n ")?; }
}
writeln!(file)?;
let mut compautos_sorted: Vec<(&String, &String)> = tables.compautos.iter().collect();
compautos_sorted.sort_by(|a, b| a.0.cmp(b.0));
for (name, opts) in compautos_sorted {
writeln!(file, "autoload -Uz {} {}", opts, name)?; }
writeln!(file)?;
writeln!(file, "typeset -gUa _comp_assocs")?;
let assocs = crate::ported::params::getaparam("_comp_assocs").unwrap_or_default();
let joined = if assocs.is_empty() {
qq("")
} else {
assocs
.iter()
.map(|s| qq(s))
.collect::<Vec<_>>()
.join(" ")
};
writeln!(file, "_comp_assocs=( {} )", joined)?;
file.flush()?;
file.into_inner()
.map_err(|e| std::io::Error::other(e.to_string()))?
.sync_all()?;
}
fs::rename(&tmp, dump_path)?;
Ok(dump_path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_escape_zsh_string() {
assert_eq!(escape_zsh_string("hello"), "hello");
assert_eq!(escape_zsh_string("it's"), "it'\\''s");
}
#[test]
fn qq_wraps_in_single_quotes_and_escapes() {
assert_eq!(qq("plain"), "'plain'");
assert_eq!(qq("it's"), "'it'\\''s'");
assert_eq!(qq(""), "''");
}
fn empty_result() -> CompInitResult {
CompInitResult {
files_scanned: 3,
dirs_scanned: 0,
scan_time_ms: 0,
files: Vec::new(),
comps: HashMap::new(),
services: HashMap::new(),
patcomps: HashMap::new(),
postpatcomps: HashMap::new(),
compautos: HashMap::new(),
keybindings: Vec::new(),
widgetkeys: Vec::new(),
}
}
#[test]
fn header_matches_upstream_format() {
let mut r = empty_result();
r.files_scanned = 42;
let tmp = std::env::temp_dir().join("zshrs_compdump_header_test");
let _ = std::fs::remove_file(&tmp);
let _ = compdump(&r, &tmp, "5.9").unwrap();
let content = std::fs::read_to_string(&tmp).unwrap();
let first_line = content.lines().next().unwrap();
assert_eq!(first_line, "#files: 42\tversion: 5.9");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn check_dump_accepts_upstream_header() {
let tmp = std::env::temp_dir().join("zshrs_check_dump_upstream");
std::fs::write(
&tmp,
"#files: 0\tversion: 5.9\ntypeset -gHA _comps\n_comps=(\n)\n",
)
.unwrap();
assert!(check_dump(&tmp, &[], "5.9"));
assert!(!check_dump(&tmp, &[], "5.10"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn check_dump_rejects_old_zshrs_format() {
let tmp = std::env::temp_dir().join("zshrs_check_dump_old_format");
std::fs::write(&tmp, "#compdump 0 . 5.9\n").unwrap();
assert!(!check_dump(&tmp, &[], "5.9"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn assoc_dump_sorts_keys_deterministically() {
let mut r = empty_result();
r.comps.insert("zfoo".to_string(), "_z".to_string());
r.comps.insert("alpha".to_string(), "_a".to_string());
r.comps.insert("mike".to_string(), "_m".to_string());
let tmp = std::env::temp_dir().join("zshrs_compdump_sort_test");
let _ = std::fs::remove_file(&tmp);
let _ = compdump(&r, &tmp, "5.9").unwrap();
let content = std::fs::read_to_string(&tmp).unwrap();
let comps_pos = content.find("_comps=(").unwrap();
let after = &content[comps_pos..];
let alpha_pos = after.find("'alpha'").unwrap();
let mike_pos = after.find("'mike'").unwrap();
let zfoo_pos = after.find("'zfoo'").unwrap();
assert!(alpha_pos < mike_pos, "alpha must precede mike");
assert!(mike_pos < zfoo_pos, "mike must precede zfoo");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn rename_replaces_existing_dump_atomically() {
let tmp = std::env::temp_dir().join("zshrs_compdump_atomic_test");
let _ = std::fs::remove_file(&tmp);
let _ = compdump(&empty_result(), &tmp, "5.9").unwrap();
assert!(tmp.exists());
let parent = tmp.parent().unwrap();
let stray: Vec<_> = std::fs::read_dir(parent)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n.starts_with("zshrs_compdump_atomic_test.") && n != "zshrs_compdump_atomic_test"
})
.collect();
assert!(stray.is_empty(), "temp file leaked: {:?}", stray);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn dump_file_count_matches_the_sh26_glob() {
let dir = std::env::temp_dir().join("zshrs_compdump_filecount");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for (name, counted) in [
("_counted", true),
("_also_counted", true),
("notunderscore", false), ("_backup~", false), ("_compiled.zwc", false), ] {
fs::write(dir.join(name), "").unwrap();
let _ = counted;
}
assert_eq!(dump_file_count(&[dir.clone()]), 2);
assert_eq!(dump_file_count(&[PathBuf::from(".")]), 0);
assert_eq!(dump_file_count(&[dir.clone(), dir.clone()]), 4);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn compdump_live_round_trips_through_the_dump_readers() {
use crate::compsys::ported::compinit::{
dump_assoc_tables, dump_autoload_names, DumpTables,
};
let dir = std::env::temp_dir().join("zshrs_compdump_live_roundtrip");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let dump = dir.join("dumpfile");
let mut tables = DumpTables::default();
tables.comps.insert("zzcmd".to_string(), "_zzcmd".to_string());
tables
.comps
.insert("it's".to_string(), "_apostrophe".to_string());
tables
.services
.insert("-redirect-,<,zzz".to_string(), "zzz".to_string());
tables
.patcomps
.insert("zz*".to_string(), "_zzpat".to_string());
tables
.postpatcomps
.insert("*zz".to_string(), "_zzpost".to_string());
tables
.compautos
.insert("_zzauto".to_string(), "+X".to_string());
compdump_live(&tables, &dump, "5.9.2", &[dir.clone()]).unwrap();
let text = fs::read_to_string(&dump).unwrap();
assert!(
text.starts_with("#files: 0\tversion: 5.9.2\n"),
"sh:37 header, got {:?}",
text.lines().next()
);
let read = dump_assoc_tables(&dump).expect("dump must parse");
assert_eq!(read.comps.get("zzcmd").map(String::as_str), Some("_zzcmd"));
assert_eq!(
read.comps.get("it's").map(String::as_str),
Some("_apostrophe"),
"an embedded `'` must round-trip through ${{(qq)}}"
);
assert_eq!(
read.services.get("-redirect-,<,zzz").map(String::as_str),
Some("zzz")
);
assert_eq!(read.patcomps.get("zz*").map(String::as_str), Some("_zzpat"));
assert_eq!(
read.postpatcomps.get("*zz").map(String::as_str),
Some("_zzpost")
);
assert_eq!(read.compautos.get("_zzauto").map(String::as_str), Some("+X"));
assert!(
text.contains("\nautoload -Uz +X _zzauto\n"),
"sh:129 line missing from:\n{}",
text
);
assert_eq!(
dump_autoload_names(&dump)
.iter()
.filter(|n| *n == "_zzauto")
.count(),
1,
"sh:115 must not also list _zzauto in the bulk `autoload -Uz` line"
);
assert!(text.contains("\ntypeset -gUa _comp_assocs\n"));
assert!(text.contains("\n_comp_assocs=( "));
let stray: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("dumpfile."))
.collect();
assert!(stray.is_empty(), "temp file leaked: {:?}", stray);
let _ = fs::remove_dir_all(&dir);
}
}