#![cfg(feature = "recorder")]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
static ENABLED: AtomicBool = AtomicBool::new(false);
static DAEMON_DISABLED: AtomicBool = AtomicBool::new(false);
static QUIET: AtomicBool = AtomicBool::new(false);
static JSON_SUMMARY: AtomicBool = AtomicBool::new(false);
static OUTPUT_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static SHELL_ID_OVERRIDE: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static IN_RECORDER: AtomicBool = AtomicBool::new(false);
static ORDER_IDX: AtomicU64 = AtomicU64::new(0);
static START_NS: AtomicU64 = AtomicU64::new(0);
static BUFFER: Lazy<Mutex<Vec<RecordEvent>>> = Lazy::new(|| Mutex::new(Vec::with_capacity(4096)));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DefKind {
Alias,
GAlias,
SAlias,
Function,
Assign,
Typeset,
Export,
PathMod,
HashD,
Zstyle,
Bindkey,
Compdef,
Zmodload,
Setopt,
Unsetopt,
Trap,
Sched,
Source,
Unalias,
Unset,
Zle,
Completion,
}
impl DefKind {
pub fn as_str(self) -> &'static str {
match self {
DefKind::Alias => "alias",
DefKind::GAlias => "alias -g",
DefKind::SAlias => "alias -s",
DefKind::Function => "function",
DefKind::Assign => "assign",
DefKind::Typeset => "typeset",
DefKind::Export => "export",
DefKind::PathMod => "path_mod",
DefKind::HashD => "hash -d",
DefKind::Zstyle => "zstyle",
DefKind::Bindkey => "bindkey",
DefKind::Compdef => "compdef",
DefKind::Zmodload => "zmodload",
DefKind::Setopt => "setopt",
DefKind::Unsetopt => "unsetopt",
DefKind::Trap => "trap",
DefKind::Sched => "sched",
DefKind::Source => "source",
DefKind::Unalias => "unalias",
DefKind::Unset => "unset",
DefKind::Zle => "zle",
DefKind::Completion => "completion",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamAttrs(pub u16);
impl ParamAttrs {
pub const NONE: Self = Self(0);
pub const SCALAR: u16 = 1 << 0;
pub const INTEGER: u16 = 1 << 1;
pub const FLOAT: u16 = 1 << 2;
pub const ASSOC: u16 = 1 << 3;
pub const ARRAY: u16 = 1 << 4;
pub const READONLY: u16 = 1 << 5;
pub const EXPORT: u16 = 1 << 6;
pub const GLOBAL: u16 = 1 << 7;
pub const UNIQUE: u16 = 1 << 8;
pub const TIED: u16 = 1 << 9;
pub const HIDE: u16 = 1 << 10;
pub const HIDE_VAL: u16 = 1 << 11;
pub const APPEND: u16 = 1 << 12;
pub fn set(&mut self, mask: u16) {
self.0 |= mask;
}
pub fn has(self, mask: u16) -> bool {
self.0 & mask != 0
}
pub fn from_flag_chars(letters: &str) -> Self {
let mut a = Self::NONE;
for c in letters.chars() {
match c {
'i' => a.set(Self::INTEGER),
'F' | 'E' => a.set(Self::FLOAT),
'A' => a.set(Self::ASSOC),
'a' => a.set(Self::ARRAY),
'r' => a.set(Self::READONLY),
'x' => a.set(Self::EXPORT),
'g' => a.set(Self::GLOBAL),
'U' => a.set(Self::UNIQUE),
'T' => a.set(Self::TIED),
'h' => a.set(Self::HIDE),
'H' => a.set(Self::HIDE_VAL),
_ => {}
}
}
if a.0 & (Self::INTEGER | Self::FLOAT | Self::ASSOC | Self::ARRAY) == 0 {
a.set(Self::SCALAR);
}
a
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordEvent {
pub order_idx: u64,
pub ts_ns: u64,
pub kind: DefKind,
pub name: String,
pub value: Option<String>,
pub file: Option<String>,
pub line: Option<u32>,
pub fn_chain: Option<String>,
#[serde(default, skip_serializing_if = "is_default_attrs")]
pub attrs: ParamAttrs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_array: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_assoc: Option<Vec<(String, String)>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_id: Option<String>,
}
fn is_default_attrs(a: &ParamAttrs) -> bool {
a.0 == 0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecorderBundle {
pub started_at_ns: u64,
pub finished_at_ns: u64,
pub cmdline: String,
pub zdotdir: Option<String>,
pub home: Option<String>,
pub events: Vec<RecordEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_id: Option<String>,
}
#[inline]
pub fn enable() {
ENABLED.store(true, Ordering::Relaxed);
START_NS.store(now_ns(), Ordering::Relaxed);
tracing::info!("recorder: enabled");
}
#[inline]
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub fn recorder_ctx_global() -> RecordCtx {
let line = std::env::var("LINENO").ok().and_then(|s| s.parse::<u32>().ok());
let file = std::env::var("ZSH_SCRIPT").ok()
.or_else(|| std::env::var("ZSH_ARGZERO").ok())
.or_else(|| std::env::var("0").ok());
let fn_chain = std::env::var("funcstack").ok().and_then(|s| {
if s.is_empty() {
None
} else {
let mut parts: Vec<&str> = s.split(':').collect();
parts.reverse();
Some(parts.join(" > "))
}
});
RecordCtx { file, line, fn_chain }
}
#[inline]
pub fn set_daemon_disabled(v: bool) {
DAEMON_DISABLED.store(v, Ordering::Relaxed);
}
#[inline]
fn daemon_disabled() -> bool {
DAEMON_DISABLED.load(Ordering::Relaxed)
}
#[inline]
pub fn set_quiet(v: bool) {
QUIET.store(v, Ordering::Relaxed);
}
#[inline]
fn quiet() -> bool {
QUIET.load(Ordering::Relaxed)
}
#[inline]
pub fn set_json_summary(v: bool) {
JSON_SUMMARY.store(v, Ordering::Relaxed);
}
#[inline]
fn json_summary_enabled() -> bool {
JSON_SUMMARY.load(Ordering::Relaxed)
}
pub fn set_output_path(p: Option<String>) {
if let Ok(mut g) = OUTPUT_PATH.lock() {
*g = p;
}
}
fn output_path() -> Option<String> {
OUTPUT_PATH.lock().ok().and_then(|g| g.clone())
}
pub fn set_shell_id_override(s: Option<String>) {
if let Ok(mut g) = SHELL_ID_OVERRIDE.lock() {
*g = s;
}
}
fn shell_id_override() -> Option<String> {
SHELL_ID_OVERRIDE.lock().ok().and_then(|g| g.clone())
}
fn now_ns() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Default)]
pub struct RecordCtx {
pub file: Option<String>,
pub line: Option<u32>,
pub fn_chain: Option<String>,
}
fn loc_str(file: &Option<String>, line: Option<u32>) -> String {
match (file.as_deref(), line) {
(Some(f), Some(l)) => format!("{}:{}", f, l),
(Some(f), None) => f.to_string(),
_ => "<unknown>".to_string(),
}
}
fn fn_chain_suffix(chain: &Option<String>) -> String {
match chain.as_deref() {
Some(c) if !c.is_empty() => format!(" ({})", c),
_ => String::new(),
}
}
pub fn emit(
kind: DefKind,
name: impl Into<String>,
value: Option<String>,
file: Option<String>,
line: Option<u32>,
fn_chain: Option<String>,
) {
emit_with_attrs(kind, name, value, file, line, fn_chain, ParamAttrs::NONE)
}
pub fn emit_with_attrs(
kind: DefKind,
name: impl Into<String>,
value: Option<String>,
file: Option<String>,
line: Option<u32>,
fn_chain: Option<String>,
attrs: ParamAttrs,
) {
emit_full(kind, name, value, file, line, fn_chain, attrs, None, None)
}
#[allow(clippy::too_many_arguments)]
pub fn emit_full(
kind: DefKind,
name: impl Into<String>,
value: Option<String>,
file: Option<String>,
line: Option<u32>,
fn_chain: Option<String>,
attrs: ParamAttrs,
value_array: Option<Vec<String>>,
value_assoc: Option<Vec<(String, String)>>,
) {
if !is_enabled() {
return;
}
if IN_RECORDER.swap(true, Ordering::Acquire) {
return;
}
let name = name.into();
let value_part = match value.as_deref() {
Some(v) => format!("={}", short_value(v)),
None => String::new(),
};
let loc = loc_str(&file, line);
let chain = fn_chain_suffix(&fn_chain);
let kind_str = kind.as_str();
let attrs_part = if attrs.0 == 0 {
String::new()
} else {
format!(" [{}]", attrs_to_str(attrs))
};
if !quiet() {
eprintln!(
"Captured {} {}{}{}, file: {}{}",
kind_str, name, attrs_part, value_part, loc, chain
);
}
tracing::info!(
kind = kind_str,
%name,
value = value.as_deref().unwrap_or(""),
attrs = attrs.0,
file = file.as_deref().unwrap_or(""),
line = line.unwrap_or(0),
fn_chain = fn_chain.as_deref().unwrap_or(""),
"recorder: captured"
);
let ev = RecordEvent {
order_idx: ORDER_IDX.fetch_add(1, Ordering::Relaxed),
ts_ns: now_ns(),
kind,
name,
value,
file,
line,
fn_chain,
attrs,
value_array,
value_assoc,
shell_id: None,
};
if let Ok(mut buf) = BUFFER.lock() {
buf.push(ev);
}
IN_RECORDER.store(false, Ordering::Release);
}
fn attrs_to_str(a: ParamAttrs) -> String {
let mut parts: Vec<&'static str> = Vec::new();
if a.has(ParamAttrs::INTEGER) {
parts.push("integer");
}
if a.has(ParamAttrs::FLOAT) {
parts.push("float");
}
if a.has(ParamAttrs::ASSOC) {
parts.push("assoc");
}
if a.has(ParamAttrs::ARRAY) {
parts.push("array");
}
if a.has(ParamAttrs::SCALAR) && parts.is_empty() {
parts.push("scalar");
}
if a.has(ParamAttrs::READONLY) {
parts.push("readonly");
}
if a.has(ParamAttrs::EXPORT) {
parts.push("export");
}
if a.has(ParamAttrs::GLOBAL) {
parts.push("global");
}
if a.has(ParamAttrs::UNIQUE) {
parts.push("unique");
}
if a.has(ParamAttrs::TIED) {
parts.push("tied");
}
if a.has(ParamAttrs::HIDE) {
parts.push("hide");
}
if a.has(ParamAttrs::HIDE_VAL) {
parts.push("hideval");
}
if a.has(ParamAttrs::APPEND) {
parts.push("append");
}
parts.join(",")
}
fn short_value(s: &str) -> String {
const MAX: usize = 120;
let single = s.replace('\n', "\\n");
if single.chars().count() <= MAX {
format!("\"{}\"", single)
} else {
let mut clipped: String = single.chars().take(MAX).collect();
clipped.push('…');
format!("\"{}\"", clipped)
}
}
pub fn emit_alias(name: &str, value: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::Alias,
name,
value.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_galias(name: &str, value: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::GAlias,
name,
value.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_salias(name: &str, value: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::SAlias,
name,
value.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_function(name: &str, body: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::Function,
name,
body.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_assign(name: &str, value: &str, ctx: RecordCtx) {
emit(
DefKind::Assign,
name,
Some(value.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_assign_typed(name: &str, value: &str, attrs: ParamAttrs, ctx: RecordCtx) {
emit_with_attrs(
DefKind::Assign,
name,
Some(value.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
attrs,
);
}
pub fn emit_array_assign(
name: &str,
elements: Vec<String>,
mut attrs: ParamAttrs,
is_append: bool,
ctx: RecordCtx,
) {
attrs.set(ParamAttrs::ARRAY);
if is_append {
attrs.set(ParamAttrs::APPEND);
}
let joined = elements.join(" ");
emit_full(
DefKind::Assign,
name,
Some(joined),
ctx.file,
ctx.line,
ctx.fn_chain,
attrs,
Some(elements),
None,
);
}
pub fn emit_assoc_assign(
name: &str,
pairs: Vec<(String, String)>,
mut attrs: ParamAttrs,
is_append: bool,
ctx: RecordCtx,
) {
attrs.set(ParamAttrs::ASSOC);
if is_append {
attrs.set(ParamAttrs::APPEND);
}
let joined = pairs
.iter()
.flat_map(|(k, v)| [k.as_str(), v.as_str()])
.collect::<Vec<_>>()
.join(" ");
emit_full(
DefKind::Assign,
name,
Some(joined),
ctx.file,
ctx.line,
ctx.fn_chain,
attrs,
None,
Some(pairs),
);
}
pub fn emit_typeset(name: &str, flags_value: &str, ctx: RecordCtx) {
let mut letters = String::new();
let mut value_part = String::new();
let mut iter = flags_value.split_whitespace();
while let Some(tok) = iter.next() {
if let Some(rest) = tok.strip_prefix('-') {
letters.push_str(rest);
} else if let Some(rest) = tok.strip_prefix('+') {
letters.push_str(rest);
} else {
if !value_part.is_empty() {
value_part.push(' ');
}
value_part.push_str(tok);
}
}
let attrs = ParamAttrs::from_flag_chars(&letters);
let value_opt = if value_part.is_empty() {
None
} else {
Some(value_part)
};
emit_with_attrs(
DefKind::Typeset,
name,
value_opt,
ctx.file,
ctx.line,
ctx.fn_chain,
attrs,
);
}
pub fn emit_typeset_attrs(name: &str, value: Option<&str>, attrs: ParamAttrs, ctx: RecordCtx) {
emit_with_attrs(
DefKind::Typeset,
name,
value.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
attrs,
);
}
pub fn emit_export(name: &str, value: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::Export,
name,
value.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_path_mod(name: &str, op: &str, ctx: RecordCtx) {
emit(
DefKind::PathMod,
name,
Some(op.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_hash_d(name: &str, path: &str, ctx: RecordCtx) {
emit(
DefKind::HashD,
name,
Some(path.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_zstyle(pattern: &str, rest: &str, ctx: RecordCtx) {
emit(
DefKind::Zstyle,
pattern,
Some(rest.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_bindkey(seq: &str, widget: &str, ctx: RecordCtx) {
emit(
DefKind::Bindkey,
seq,
Some(widget.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_compdef(func: &str, cmds: &str, ctx: RecordCtx) {
emit(
DefKind::Compdef,
func,
Some(cmds.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_zmodload(module: &str, flags: &str, ctx: RecordCtx) {
emit(
DefKind::Zmodload,
module,
Some(flags.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_setopt(opt: &str, ctx: RecordCtx) {
emit(
DefKind::Setopt,
opt,
None,
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_unsetopt(opt: &str, ctx: RecordCtx) {
emit(
DefKind::Unsetopt,
opt,
None,
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_trap(sig: &str, handler: &str, ctx: RecordCtx) {
emit(
DefKind::Trap,
sig,
Some(handler.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_sched(when: &str, cmd: &str, ctx: RecordCtx) {
emit(
DefKind::Sched,
when,
Some(cmd.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_source(path: &str, ctx: RecordCtx) {
emit(
DefKind::Source,
path,
None,
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_unalias(name: &str, ctx: RecordCtx) {
emit(
DefKind::Unalias,
name,
None,
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_unset(name: &str, ctx: RecordCtx) {
emit(
DefKind::Unset,
name,
None,
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_zle(widget: &str, func: Option<&str>, ctx: RecordCtx) {
emit(
DefKind::Zle,
widget,
func.map(str::to_string),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn emit_completion(name: &str, abs_path: &str, ctx: RecordCtx) {
emit(
DefKind::Completion,
name,
Some(abs_path.to_string()),
ctx.file,
ctx.line,
ctx.fn_chain,
);
}
pub fn discover_completions_in_fpath_dir(dir: &str, ctx: &RecordCtx) {
if !is_enabled() {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(?e, dir, "recorder: completion discovery skipped");
return;
}
};
for entry in entries.flatten() {
let name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if !name.starts_with('_') {
continue;
}
if name.ends_with(".zwc") {
continue;
}
let path = entry.path();
let is_file = match entry.file_type() {
Ok(ft) if ft.is_symlink() => std::fs::metadata(&path)
.map(|m| m.is_file())
.unwrap_or(false),
Ok(ft) => ft.is_file(),
Err(_) => false,
};
if !is_file {
continue;
}
let abs = path.to_string_lossy().to_string();
emit_completion(&name, &abs, ctx.clone());
}
}
pub fn print_summary() {
if !is_enabled() {
return;
}
let buf = match BUFFER.try_lock() {
Ok(b) => b,
Err(_) => return,
};
let total = buf.len();
let mut counts: std::collections::BTreeMap<&'static str, usize> =
std::collections::BTreeMap::new();
for ev in buf.iter() {
*counts.entry(ev.kind.as_str()).or_insert(0) += 1;
}
let started = START_NS.load(Ordering::Relaxed);
let elapsed_ms = if started > 0 {
(now_ns().saturating_sub(started)) / 1_000_000
} else {
0
};
if json_summary_enabled() {
let mut counts_pairs = Vec::with_capacity(counts.len());
for (k, v) in &counts {
counts_pairs.push(format!("\"{}\":{}", k, v));
}
println!(
"{{\"total_events\":{},\"elapsed_ms\":{},\"counts\":{{{}}}}}",
total,
elapsed_ms,
counts_pairs.join(",")
);
} else {
eprintln!();
eprintln!("--- zshrs-recorder summary ---");
eprintln!(" total events: {}", total);
for (k, v) in &counts {
eprintln!(" {:<10} {}", k, v);
}
eprintln!(" elapsed: {} ms", elapsed_ms);
}
}
#[cfg(feature = "daemon")]
pub fn flush_to_daemon() -> bool {
if !is_enabled() {
return false;
}
let events = match BUFFER.try_lock() {
Ok(mut b) => std::mem::take(&mut *b),
Err(_) => return false,
};
let events_empty = events.is_empty();
let bundle = RecorderBundle {
started_at_ns: START_NS.load(Ordering::Relaxed),
finished_at_ns: now_ns(),
cmdline: std::env::args().collect::<Vec<_>>().join(" "),
zdotdir: std::env::var("ZDOTDIR").ok(),
home: std::env::var("HOME").ok(),
events,
shell_id: Some(shell_id_override().unwrap_or_else(|| "zshrs".to_string())),
};
if let Some(path) = output_path() {
match serde_json::to_string(&bundle) {
Ok(s) => {
if let Err(e) = std::fs::write(&path, s.as_bytes()) {
eprintln!("recorder: write {path}: {e}");
} else {
eprintln!("recorder: bundle written to {path}");
}
}
Err(e) => eprintln!("recorder: bundle serialize for output failed: {e}"),
}
}
if events_empty {
eprintln!("recorder: no events to flush");
return false;
}
if daemon_disabled() {
return false;
}
let event_count = bundle.events.len();
let payload = match serde_json::to_value(&bundle) {
Ok(v) => v,
Err(e) => {
eprintln!("recorder: bundle serialize failed: {}", e);
return false;
}
};
let t0 = Instant::now();
match crate::daemon::client::call_once("recorder_ingest", payload) {
Ok(_) => {
let took_ms = t0.elapsed().as_millis();
eprintln!(
"recorder: bundled {} events to daemon in {} ms",
event_count, took_ms
);
true
}
Err(e) => {
eprintln!("recorder: daemon ingest failed: {}", e);
false
}
}
}
#[cfg(not(feature = "daemon"))]
pub fn flush_to_daemon() -> bool {
if !is_enabled() {
return false;
}
eprintln!("recorder: daemon feature off — bundle not sent");
false
}
extern "C" fn atexit_finalize() {
let _ = std::panic::catch_unwind(|| {
print_summary();
});
let _ = std::panic::catch_unwind(|| {
flush_to_daemon();
});
}
pub fn install_atexit() {
unsafe {
libc::atexit(atexit_finalize);
}
}