use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
println!("cargo:rerun-if-changed=src/ported");
println!("cargo:rerun-if-changed=tests/data/zsh_c_fn_names.txt");
println!("cargo:rerun-if-changed=tests/data/fake_fn_allowlist.txt");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=vendor/zsh");
println!("cargo:rerun-if-changed=man/man1");
println!("cargo:rerun-if-changed=functions");
println!("cargo:rerun-if-changed=completions");
println!("cargo:rerun-if-changed=../arb/completions");
println!("cargo:rerun-if-changed=../strykelang/completions");
bundle_zsh_functions();
bundle_zsh_docs();
println!("cargo:rerun-if-changed=src/zsh/Config/version.mk");
let version_mk = manifest_dir.join("src/zsh/Config/version.mk");
let (zsh_version, zsh_version_date) = parse_version_mk(&version_mk);
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let dest = out_dir.join("zsh_version.rs");
let generated = format!(
"/// Vendored zsh source `Config/version.mk` `VERSION=...` value.\n\
/// `ZSH_VERSION` constant.
pub const ZSH_VERSION: &str = {:?};\n\
/// Vendored zsh source `Config/version.mk` `VERSION_DATE=...` value.\n\
pub const ZSH_VERSION_DATE: &str = {:?};\n\
/// `$ZSH_PATCHLEVEL` default — C `Src/params.c:43` sets `\"unknown\"`\n\
/// when no custom value is configured. The vendored zsh tarball\n\
/// doesn't ship a patchlevel hash; emit `\"unknown\"` to match\n\
/// upstream's no-CUSTOM_PATCHLEVEL build.\n\
pub const ZSH_PATCHLEVEL: &str = \"unknown\";\n",
zsh_version, zsh_version_date
);
fs::write(&dest, generated).expect("write zsh_version.rs");
#[cfg(target_os = "macos")]
println!("cargo:rustc-link-arg-bins=-Wl,-stack_size,0x10000000");
emit_config_h_env();
let ported_root = manifest_dir.join("src/ported");
let c_index_path = manifest_dir.join("tests/data/zsh_c_fn_names.txt");
let allowlist_path = manifest_dir.join("tests/data/fake_fn_allowlist.txt");
if !ported_root.exists() || !c_index_path.exists() {
return;
}
let c_names = match load_c_fn_index(&c_index_path) {
Ok(n) => n,
Err(e) => {
println!(
"cargo:warning=PORT.md drift: cannot read {} ({})",
c_index_path.display(),
e
);
return;
}
};
let allowlist: HashSet<String> = fs::read_to_string(&allowlist_path)
.unwrap_or_default()
.lines()
.map(|l| {
let l = match l.find('#') {
Some(i) => &l[..i],
None => l,
};
l.trim()
})
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
let mut rust_files: Vec<PathBuf> = Vec::new();
collect_rust_files(&ported_root, &mut rust_files);
let mut violations: Vec<String> = Vec::new();
for path in &rust_files {
let src = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => continue,
};
let rel = path
.strip_prefix(&manifest_dir)
.unwrap_or(path)
.display()
.to_string();
for (name, lineno) in collect_free_fns(&src) {
if !allowlist.contains(&name) && !c_names.contains_key(&name) {
violations.push(format!(
" {}:{} fn {} — no C counterpart in zsh source",
rel, lineno, name,
));
}
}
}
if !violations.is_empty() {
violations.sort();
for v in &violations {
println!("cargo:warning={}", v.trim());
}
panic!(
"\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
src/ported/ IS A PORT — NO NEW FUNCTIONS ALLOWED\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\
Every `pub fn` / `fn` under src/ported/ MUST be a faithful\n\
port of a function that exists in zsh's upstream C source\n\
(~/forkedRepos/zsh/Src/, snapshotted at\n\
tests/data/zsh_c_fn_names.txt). Rust-original helpers,\n\
refactored extractions, convenience wrappers, and\n\
ad-hoc abstractions DO NOT BELONG HERE — they create\n\
drift between the port and the spec.\n\n\
{} fn(s) violate this rule:\n\n\
{}\n\n\
To fix:\n\n\
1. Preferred: inline the body at every call site\n\
(it isn't a real port, it shouldn't be a function).\n\
2. Or: rename to match the actual C function it ports.\n\
Cite Src/<file>.c:<line> in the doc comment.\n\
3. Last resort: add the name to\n\
tests/data/fake_fn_allowlist.txt with a comment\n\
explaining why no C analog exists (architectural\n\
Rust-only helpers like singleton accessors only).\n\n\
Enforced by build.rs on every `cargo build` / `cargo test`\n\
/ `cargo check` whenever src/ported/** changes. Cannot be\n\
bypassed by `cargo test --test X`.\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
violations.len(),
violations.join("\n")
);
}
}
fn emit_config_h_env() {
let target = env::var("TARGET").unwrap_or_default();
let host = env::var("HOST").unwrap_or_default();
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
println!(
"cargo:rustc-env=ZSHRS_CONFIG_OSTYPE={}",
config_guess_host_os(&target, &host, &target_os, &target_env)
);
println!(
"cargo:rustc-env=ZSHRS_CONFIG_VENDOR={}",
config_guess_host_vendor(&target_os, &target_arch)
);
println!("cargo:rustc-env=ZSHRS_CONFIG_DEFAULT_PATH={}", cs_path());
println!(
"cargo:rustc-env=ZSHRS_CONFIG_PATH_DEV_FD={}",
if target_os == "linux" || target_os == "android" {
"/proc/self/fd"
} else {
"/dev/fd"
}
);
}
fn config_guess_host_os(target: &str, host: &str, target_os: &str, target_env: &str) -> String {
match target_os {
"macos" => {
if let Some(rel) = target
.rsplit('-')
.next()
.and_then(|f| f.strip_prefix("darwin"))
{
if !rel.is_empty() {
return format!("darwin{rel}");
}
}
if host.contains("-darwin") {
if let Some(rel) = uname_release() {
return format!("darwin{rel}");
}
}
"darwin".to_string()
}
"linux" => format!(
"linux-{}",
match target_env {
"" => "gnu", other => other,
}
),
"android" => "linux-android".to_string(),
"" => "unknown".to_string(),
other => other.to_string(),
}
}
fn config_guess_host_vendor(target_os: &str, target_arch: &str) -> &'static str {
match (target_os, target_arch) {
("macos" | "ios" | "tvos" | "watchos" | "visionos", _) => "apple",
("linux" | "android", "x86_64" | "x86") => "pc",
("linux" | "android", "s390x") => "ibm",
_ => "unknown",
}
}
fn cs_path() -> String {
for name in ["_CS_PATH", "CS_PATH", "PATH"] {
if let Ok(out) = std::process::Command::new("getconf").arg(name).output() {
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return s;
}
}
}
}
"/bin:/usr/bin".to_string()
}
fn uname_release() -> Option<String> {
let out = std::process::Command::new("uname")
.arg("-r")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn parse_version_mk(path: &Path) -> (String, String) {
let s = fs::read_to_string(path).unwrap_or_else(|_| String::new());
let mut version = String::new();
let mut version_date = String::new();
for line in s.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("VERSION=") {
version = v.trim().trim_matches('\'').trim_matches('"').to_string();
} else if let Some(v) = line.strip_prefix("VERSION_DATE=") {
version_date = v.trim().trim_matches('\'').trim_matches('"').to_string();
}
}
if version.is_empty() {
version = "unknown".to_string();
}
(version, version_date)
}
fn collect_rust_files(root: &Path, out: &mut Vec<PathBuf>) {
let entries = match fs::read_dir(root) {
Ok(e) => e,
Err(_) => return,
};
for ent in entries.flatten() {
let path = ent.path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn collect_free_fns(src: &str) -> Vec<(String, usize)> {
let mut fns: Vec<(String, usize)> = Vec::new();
let mut depth: i32 = 0;
let mut in_test_mod = false;
let mut test_mod_depth: i32 = 0;
let mut in_block_comment: i32 = 0;
for (lineno, line) in src.lines().enumerate() {
let lineno = lineno + 1;
let trimmed = line.trim_start();
if depth == 0 && (trimmed.starts_with("mod tests {") || trimmed.starts_with("mod test {")) {
in_test_mod = true;
test_mod_depth = depth + 1;
}
let bytes = line.as_bytes();
let mut i = 0;
let mut delta: i32 = 0;
while i < bytes.len() {
let b = bytes[i];
if in_block_comment > 0 {
if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
in_block_comment -= 1;
i += 2;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
in_block_comment += 1;
i += 2;
continue;
}
i += 1;
continue;
}
match b {
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => break,
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
in_block_comment += 1;
i += 2;
}
b'"' => {
i += 1;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
i += 1;
break;
}
i += 1;
}
}
b'r' if i + 1 < bytes.len() && (bytes[i + 1] == b'"' || bytes[i + 1] == b'#') => {
let mut hashes = 0;
let mut j = i + 1;
while j < bytes.len() && bytes[j] == b'#' {
hashes += 1;
j += 1;
}
if j < bytes.len() && bytes[j] == b'"' {
i = j + 1;
loop {
if i >= bytes.len() {
break;
}
if bytes[i] == b'"' {
let mut closed = 0;
let mut k = i + 1;
while k < bytes.len() && bytes[k] == b'#' && closed < hashes {
closed += 1;
k += 1;
}
if closed >= hashes {
i = k;
break;
}
}
i += 1;
}
} else {
i += 1;
}
}
b'\'' => {
let mut j = i + 1;
let mut found_close = false;
let mut escape = false;
while j < bytes.len() && j - i < 12 {
if !escape && bytes[j] == b'\'' {
found_close = true;
break;
}
escape = bytes[j] == b'\\' && !escape;
j += 1;
}
if found_close {
i = j + 1;
} else {
i += 1;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_')
{
i += 1;
}
}
}
b'{' => {
delta += 1;
i += 1;
}
b'}' => {
delta -= 1;
i += 1;
}
_ => i += 1,
}
}
let pre_depth = depth;
depth += delta;
if in_test_mod && depth < test_mod_depth {
in_test_mod = false;
}
if in_test_mod {
continue;
}
if pre_depth != 0 {
continue;
}
let stripped = trimmed
.strip_prefix("pub(crate) ")
.or_else(|| trimmed.strip_prefix("pub(super) "))
.unwrap_or_else(|| trimmed.strip_prefix("pub ").unwrap_or(trimmed));
let stripped = stripped.strip_prefix("unsafe ").unwrap_or(stripped);
let stripped = stripped.strip_prefix("async ").unwrap_or(stripped);
let stripped = stripped.strip_prefix(r#"extern "C" "#).unwrap_or(stripped);
if let Some(rest) = stripped.strip_prefix("fn ") {
let name_end = rest
.find(|c: char| c == '(' || c == '<' || c.is_whitespace())
.unwrap_or(0);
if name_end > 0 {
let name = rest[..name_end].to_string();
if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
fns.push((name, lineno));
}
}
}
}
fns
}
fn load_c_fn_index(path: &Path) -> Result<HashMap<String, HashSet<String>>, std::io::Error> {
let src = fs::read_to_string(path)?;
let mut index: HashMap<String, HashSet<String>> = HashMap::new();
for line in src.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((file, name)) = line.split_once(':') {
index
.entry(name.to_string())
.or_default()
.insert(file.to_string());
}
}
Ok(index)
}
fn bundle_zsh_functions() {
use std::io::Write;
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut per_root: Vec<String> = Vec::new();
const OPTIONAL_ROOTS: &[&str] = &["../arb/completions", "../strykelang/completions"];
for root in ["functions", "completions", "vendor/zsh/functions"]
.iter()
.chain(OPTIONAL_ROOTS)
.copied()
{
let optional = OPTIONAL_ROOTS.contains(&root);
if optional && !PathBuf::from(root).is_dir() {
continue;
}
let before = files.len();
let mut stack = vec![PathBuf::from(root)];
while let Some(dir) = stack.pop() {
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let name = match p.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('.')
|| name.starts_with("README")
|| name == "LICENCE"
|| name == "Makefile"
|| name.ends_with(".md")
|| name.ends_with(".txt")
|| name.ends_with(".in")
|| name.ends_with(".mdd")
|| name.ends_with(".pro")
{
continue;
}
if !seen.insert(name.clone()) {
continue;
}
if let Ok(body) = fs::read(&p) {
files.push((name, body));
}
}
}
per_root.push(format!("{root} {}", files.len() - before));
if files.len() == before && !optional {
panic!(
"{root} contributed no functions -- the tree must be present \
and non-empty for the bundle to be built."
);
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut raw: Vec<u8> = Vec::new();
for (name, body) in &files {
raw.extend_from_slice(&(name.len() as u32).to_le_bytes());
raw.extend_from_slice(name.as_bytes());
raw.extend_from_slice(&(body.len() as u32).to_le_bytes());
raw.extend_from_slice(body);
}
let packed = zstd::encode_all(&raw[..], 19).expect("zstd encode zsh function bundle");
let dest = out_dir.join("zsh_functions.zst");
let mut f = fs::File::create(&dest).expect("create zsh_functions.zst");
f.write_all(&packed).expect("write zsh_functions.zst");
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in &packed {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
fs::write(
out_dir.join("zsh_functions_id.rs"),
format!(
"/// FNV-1a of the packed function bundle, from build.rs.\n\
/// Stamped into `~/.zshrs/functions` so a changed bundle is\n\
/// rewritten even when the crate version did not move.\n\
pub const BUNDLE_ID: &str = \"{hash:016x}\";\n"
),
)
.expect("write zsh_functions_id.rs");
println!(
"cargo:warning=bundled {} zsh functions ({} KiB packed; {})",
files.len(),
packed.len() / 1024,
per_root.join(", ")
);
}
fn splice_zshrsall_into_zshall(files: &mut [(String, Vec<u8>)]) {
const LAST_ZSH_PAGE: &str = ".so man1/zshcontrib.1\n";
const OVERVIEW_ANCHOR: &str = "\\fIzshcontrib\\fP";
let Some((_, body)) = files.iter_mut().find(|(n, _)| n == "man/man1/zshall.1") else {
return;
};
let text = String::from_utf8_lossy(body).into_owned();
if text.contains("zshrsall") {
return;
}
let mut out = text.replacen(
LAST_ZSH_PAGE,
&format!("{LAST_ZSH_PAGE}.so man1/zshrsall.1\n"),
1,
);
if let Some(at) = out.find(OVERVIEW_ANCHOR) {
if let Some(nl) = out[at..].find('\n') {
let end = at + nl + 1;
out.insert_str(
end,
".TP\n\\fIzshrsall\\fP Everything zshrs adds on top of zsh\n",
);
}
}
*body = out.into_bytes();
}
fn bundle_zsh_docs() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let root = PathBuf::from("vendor/zsh");
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
let trees = [
("man1", "man/man1", false),
("info", "info", false),
("help", "help", true),
("scripts", "scripts", true),
];
for (src, installed, required) in trees {
let dir = root.join(src);
let before = files.len();
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
continue;
}
let base = match p.file_name().and_then(|n| n.to_str()) {
Some(n) if !n.starts_with('.') => n.to_string(),
_ => continue,
};
if let Ok(body) = fs::read(&p) {
files.push((format!("{installed}/{base}"), body));
}
}
if required && files.len() == before {
panic!("vendor/zsh/{src} is missing or empty -- zsh's {src} pages must be vendored");
}
}
if let Ok(rd) = fs::read_dir("man/man1") {
for e in rd.flatten() {
let p = e.path();
let Some(base) = p.file_name().and_then(|n| n.to_str()) else { continue };
if base.starts_with('.') || !p.is_file() {
continue;
}
if let Ok(body) = fs::read(&p) {
files.push((format!("man/man1/{base}"), body));
}
}
}
splice_zshrsall_into_zshall(&mut files);
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut raw: Vec<u8> = Vec::new();
for (name, body) in &files {
raw.extend_from_slice(&(name.len() as u32).to_le_bytes());
raw.extend_from_slice(name.as_bytes());
raw.extend_from_slice(&(body.len() as u32).to_le_bytes());
raw.extend_from_slice(body);
}
let packed = zstd::encode_all(&raw[..], 19).expect("zstd encode zsh doc bundle");
fs::write(out_dir.join("zsh_docs.zst"), &packed).expect("write zsh_docs.zst");
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in &packed {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
fs::write(
out_dir.join("zsh_docs_id.rs"),
format!(
"/// FNV-1a of the packed man/info bundle, from build.rs.\n\
pub const DOCS_ID: &str = \"{hash:016x}\";\n"
),
)
.expect("write zsh_docs_id.rs");
println!(
"cargo:warning=bundled {} zsh doc files ({} KiB packed)",
files.len(),
packed.len() / 1024
);
}