Skip to main content

shellhist_core/
zsh.rs

1//! Zsh history (`.zsh_history`).
2//!
3//! With `EXTENDED_HISTORY` each entry is `: <beginning_time>:<elapsed_seconds>;<command>`
4//! (zsh Options manual). A command containing newlines is written with each
5//! embedded newline escaped as a trailing backslash before the physical newline;
6//! the reader rejoins backslash-continued physical lines (zsh `Src/hist.c`
7//! `readhistline`). Without `EXTENDED_HISTORY` the file is plain one-per-line.
8
9use crate::{HistoryEntry, Shell};
10
11/// True if `line` begins with the `EXTENDED_HISTORY` metadata prefix
12/// `: <digits>:<digits>;`.
13#[must_use]
14pub fn is_extended_line(line: &str) -> bool {
15    parse_extended_prefix(line).is_some()
16}
17
18/// Split `: <start>:<elapsed>;<command>` into `((start, elapsed), command)`.
19fn parse_extended_prefix(line: &str) -> Option<((i64, i64), &str)> {
20    let rest = line.strip_prefix(": ")?;
21    let (meta, command) = rest.split_once(';')?;
22    let (start, elapsed) = meta.split_once(':')?;
23    if start.is_empty() || !start.bytes().all(|b| b.is_ascii_digit()) {
24        return None;
25    }
26    if elapsed.is_empty() || !elapsed.bytes().all(|b| b.is_ascii_digit()) {
27        return None;
28    }
29    Some((
30        start.parse::<i64>().ok().zip(elapsed.parse::<i64>().ok())?,
31        command,
32    ))
33}
34
35/// Does this physical line end with an odd number of backslashes (a continuation)?
36fn ends_with_odd_backslashes(line: &str) -> bool {
37    line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
38}
39
40/// Rejoin backslash-continued physical lines into logical lines, restoring the
41/// embedded newline that the trailing backslash escaped.
42fn logical_lines(text: &str) -> Vec<String> {
43    let mut out = Vec::new();
44    let mut current = String::new();
45    let mut continuing = false;
46    for line in text.split('\n') {
47        if continuing {
48            current.push('\n');
49            current.push_str(line);
50        } else {
51            current = line.to_string();
52        }
53        if ends_with_odd_backslashes(line) {
54            // Drop the trailing escape backslash; the newline is kept by the join.
55            current.pop();
56            continuing = true;
57        } else {
58            out.push(std::mem::take(&mut current));
59            continuing = false;
60        }
61    }
62    if continuing {
63        out.push(current);
64    }
65    out
66}
67
68/// Parse zsh history bytes into entries.
69#[must_use]
70pub fn parse(data: &[u8]) -> Vec<HistoryEntry> {
71    let text = String::from_utf8_lossy(crate::strip_bom(data));
72    let mut entries = Vec::new();
73    for ll in logical_lines(&text) {
74        if ll.is_empty() {
75            continue;
76        }
77        if let Some(((start, elapsed), command)) = parse_extended_prefix(&ll) {
78            entries.push(HistoryEntry {
79                shell: Shell::Zsh,
80                command: command.to_string(),
81                timestamp: Some(start),
82                elapsed: Some(elapsed),
83                paths: Vec::new(),
84            });
85        } else {
86            entries.push(HistoryEntry::plain(Shell::Zsh, ll));
87        }
88    }
89    entries
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn extended_prefix_is_recognized() {
98        assert!(is_extended_line(": 1700000000:3;sleep 3"));
99        assert!(!is_extended_line("plain command"));
100        assert!(!is_extended_line(": notanumber:0;x"));
101    }
102
103    #[test]
104    fn extended_entry_carries_start_and_elapsed() {
105        let e = parse(b": 1700000000:3;sleep 3\n: 1700000010:0;ls\n");
106        assert_eq!(e.len(), 2);
107        assert_eq!(e[0].timestamp, Some(1_700_000_000));
108        assert_eq!(e[0].elapsed, Some(3));
109        assert_eq!(e[0].command, "sleep 3");
110        assert_eq!(e[1].elapsed, Some(0));
111    }
112
113    #[test]
114    fn plain_zsh_history_has_no_timestamps() {
115        let e = parse(b"ls\ncd /tmp\n");
116        assert_eq!(e.len(), 2);
117        assert!(e
118            .iter()
119            .all(|x| x.timestamp.is_none() && x.elapsed.is_none()));
120    }
121
122    #[test]
123    fn backslash_continuation_rejoins_a_multiline_command() {
124        // `echo a\<newline>b` stored as a continued line, restored with a newline.
125        let e = parse(b": 1700000000:0;echo a\\\nb\n: 1700000001:0;ls\n");
126        assert_eq!(e.len(), 2);
127        assert_eq!(e[0].command, "echo a\nb");
128        assert_eq!(e[1].command, "ls");
129    }
130
131    #[test]
132    fn a_command_containing_a_semicolon_keeps_it() {
133        let e = parse(b": 1700000000:0;echo a; echo b\n");
134        assert_eq!(e.len(), 1);
135        assert_eq!(e[0].command, "echo a; echo b");
136    }
137
138    #[test]
139    fn empty_input_yields_no_entries() {
140        assert!(parse(b"").is_empty());
141    }
142}