Skip to main content

zsh/
hist.rs

1//! History management for zshrs
2//!
3//! Port from zsh/Src/hist.c
4//!
5//! Provides history expansion, history file management, and history ring.
6
7use std::collections::HashMap;
8use std::fs::{File, OpenOptions};
9use std::io::{self, BufRead, BufReader, Write};
10use std::path::Path;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// History entry
14#[derive(Clone, Debug)]
15pub struct HistEntry {
16    pub histnum: i64,               // History event number
17    pub text: String,               // Command text
18    pub words: Vec<(usize, usize)>, // Word boundaries
19    pub stim: i64,                  // Start time
20    pub ftim: i64,                  // Finish time
21    pub flags: u32,                 // Entry flags
22}
23
24/// History entry flags
25pub mod hist_flags {
26    pub const OLD: u32 = 1; // From history file
27    pub const DUP: u32 = 2; // Duplicate
28    pub const FOREIGN: u32 = 4; // From other session
29    pub const TMPSTORE: u32 = 8; // Temporary storage
30    pub const NOWRITE: u32 = 16; // Don't save to file
31}
32
33impl HistEntry {
34    pub fn new(histnum: i64, text: String) -> Self {
35        let now = SystemTime::now()
36            .duration_since(UNIX_EPOCH)
37            .map(|d| d.as_secs() as i64)
38            .unwrap_or(0);
39
40        HistEntry {
41            histnum,
42            text,
43            words: Vec::new(),
44            stim: now,
45            ftim: now,
46            flags: 0,
47        }
48    }
49
50    /// Get a specific word from the entry
51    pub fn get_word(&self, index: usize) -> Option<&str> {
52        self.words
53            .get(index)
54            .map(|(start, end)| &self.text[*start..*end])
55    }
56
57    /// Get number of words
58    pub fn num_words(&self) -> usize {
59        self.words.len()
60    }
61}
62
63/// History active bits
64pub const HA_ACTIVE: u32 = 1; // History mechanism is active
65pub const HA_NOINC: u32 = 2; // Don't store, curhist not incremented
66pub const HA_INWORD: u32 = 4; // We're inside a word
67
68/// History state
69pub struct History {
70    /// History entries indexed by event number
71    entries: HashMap<i64, HistEntry>,
72    /// Ring buffer order (newest first)
73    ring: Vec<i64>,
74    /// Current history number
75    pub curhist: i64,
76    /// History line count
77    pub histlinect: i64,
78    /// History size limit
79    pub histsiz: i64,
80    /// Save history size
81    pub savehistsiz: i64,
82    /// History active state
83    pub histactive: u32,
84    /// Stop history flag
85    pub stophist: i32,
86    /// History done flags
87    pub histdone: i32,
88    /// History skip flags
89    pub hist_skip_flags: i32,
90    /// Ignore all dups
91    pub hist_ignore_all_dups: bool,
92    /// Current line being edited
93    pub curline: Option<HistEntry>,
94    /// History substitution patterns
95    pub hsubl: Option<String>,
96    pub hsubr: Option<String>,
97    /// Bang character
98    pub bangchar: char,
99    /// History file path
100    pub histfile: Option<String>,
101}
102
103impl Default for History {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl History {
110    pub fn new() -> Self {
111        History {
112            entries: HashMap::new(),
113            ring: Vec::new(),
114            curhist: 0,
115            histlinect: 0,
116            histsiz: 1000,
117            savehistsiz: 1000,
118            histactive: 0,
119            stophist: 0,
120            histdone: 0,
121            hist_skip_flags: 0,
122            hist_ignore_all_dups: false,
123            curline: None,
124            hsubl: None,
125            hsubr: None,
126            bangchar: '!',
127            histfile: None,
128        }
129    }
130
131    /// Initialize history
132    pub fn init(&mut self) {
133        self.curhist = 0;
134        self.histlinect = 0;
135    }
136
137    /// Begin history for a new command
138    pub fn hbegin(&mut self, interactive: bool) {
139        if (self.histactive & HA_ACTIVE) != 0 {
140            return;
141        }
142
143        self.histactive = HA_ACTIVE;
144        self.histdone = 0;
145
146        if interactive {
147            self.curhist += 1;
148            self.curline = Some(HistEntry::new(self.curhist, String::new()));
149        }
150    }
151
152    /// End history for current command
153    pub fn hend(&mut self, text: Option<String>) -> bool {
154        if (self.histactive & HA_ACTIVE) == 0 {
155            return false;
156        }
157
158        self.histactive = 0;
159
160        if let Some(mut entry) = self.curline.take() {
161            if let Some(t) = text {
162                entry.text = t;
163            }
164
165            // Skip empty entries
166            if entry.text.trim().is_empty() {
167                self.curhist -= 1;
168                return false;
169            }
170
171            // Check for duplicates
172            if self.hist_ignore_all_dups {
173                let dup = self.entries.values().any(|e| e.text == entry.text);
174                if dup {
175                    self.curhist -= 1;
176                    return false;
177                }
178            }
179
180            // Add to history
181            self.add_entry(entry);
182            return true;
183        }
184
185        false
186    }
187
188    /// Add an entry to history
189    fn add_entry(&mut self, entry: HistEntry) {
190        let num = entry.histnum;
191
192        // Remove old entry if at capacity
193        while self.histlinect >= self.histsiz && !self.ring.is_empty() {
194            let oldest = self.ring.pop().unwrap();
195            self.entries.remove(&oldest);
196            self.histlinect -= 1;
197        }
198
199        self.entries.insert(num, entry);
200        self.ring.insert(0, num);
201        self.histlinect += 1;
202    }
203
204    /// Get entry by history number
205    pub fn get(&self, num: i64) -> Option<&HistEntry> {
206        self.entries.get(&num)
207    }
208
209    /// Get the most recent entry
210    pub fn latest(&self) -> Option<&HistEntry> {
211        self.ring.first().and_then(|n| self.entries.get(n))
212    }
213
214    /// Get the n-th most recent entry (0 = latest)
215    pub fn recent(&self, n: usize) -> Option<&HistEntry> {
216        self.ring.get(n).and_then(|num| self.entries.get(num))
217    }
218
219    /// Search history backwards for a pattern
220    pub fn search_back(&self, pattern: &str, start: i64) -> Option<&HistEntry> {
221        for num in self.ring.iter() {
222            if *num >= start {
223                continue;
224            }
225            if let Some(entry) = self.entries.get(num) {
226                if entry.text.contains(pattern) {
227                    return Some(entry);
228                }
229            }
230        }
231        None
232    }
233
234    /// Search history forwards for a pattern
235    pub fn search_forward(&self, pattern: &str, start: i64) -> Option<&HistEntry> {
236        for num in self.ring.iter().rev() {
237            if *num <= start {
238                continue;
239            }
240            if let Some(entry) = self.entries.get(num) {
241                if entry.text.contains(pattern) {
242                    return Some(entry);
243                }
244            }
245        }
246        None
247    }
248
249    /// Perform history substitution
250    pub fn expand(&mut self, line: &str) -> Result<String, String> {
251        let mut result = String::new();
252        let mut chars = line.chars().peekable();
253        let bang = self.bangchar;
254
255        while let Some(c) = chars.next() {
256            if c == bang {
257                match chars.peek() {
258                    Some(&'!') => {
259                        // !! - last command
260                        chars.next();
261                        if let Some(entry) = self.latest() {
262                            result.push_str(&entry.text);
263                        } else {
264                            return Err("No previous command".to_string());
265                        }
266                    }
267                    Some(&'-') | Some(&('0'..='9')) => {
268                        // !n or !-n
269                        let mut numstr = String::new();
270                        if chars.peek() == Some(&'-') {
271                            numstr.push(chars.next().unwrap());
272                        }
273                        while let Some(&c) = chars.peek() {
274                            if c.is_ascii_digit() {
275                                numstr.push(chars.next().unwrap());
276                            } else {
277                                break;
278                            }
279                        }
280                        if let Ok(n) = numstr.parse::<i64>() {
281                            let target = if n < 0 { self.curhist + n } else { n };
282                            if let Some(entry) = self.get(target) {
283                                result.push_str(&entry.text);
284                            } else {
285                                return Err(format!("!{}: event not found", numstr));
286                            }
287                        }
288                    }
289                    Some(&'?') => {
290                        // !?string - search
291                        chars.next();
292                        let mut pattern = String::new();
293                        while let Some(&c) = chars.peek() {
294                            if c == '?' {
295                                chars.next();
296                                break;
297                            }
298                            pattern.push(chars.next().unwrap());
299                        }
300                        if let Some(entry) = self.search_back(&pattern, self.curhist) {
301                            result.push_str(&entry.text);
302                        } else {
303                            return Err(format!("!?{}: event not found", pattern));
304                        }
305                    }
306                    Some(&'^') | Some(&'$') | Some(&'*') | Some(&':') => {
307                        // Word designators on last command
308                        if let Some(entry) = self.latest() {
309                            let words: Vec<&str> = entry.text.split_whitespace().collect();
310                            match chars.next() {
311                                Some('^') => {
312                                    if let Some(w) = words.get(1) {
313                                        result.push_str(w);
314                                    }
315                                }
316                                Some('$') => {
317                                    if let Some(w) = words.last() {
318                                        result.push_str(w);
319                                    }
320                                }
321                                Some('*') => {
322                                    result.push_str(&words[1..].join(" "));
323                                }
324                                _ => {}
325                            }
326                        }
327                    }
328                    Some(c) if c.is_alphabetic() => {
329                        // !string - search prefix
330                        let mut pattern = String::new();
331                        while let Some(&c) = chars.peek() {
332                            if c.is_alphanumeric() || c == '_' {
333                                pattern.push(chars.next().unwrap());
334                            } else {
335                                break;
336                            }
337                        }
338                        let found = self.ring.iter().find_map(|num| {
339                            self.entries
340                                .get(num)
341                                .filter(|e| e.text.starts_with(&pattern))
342                        });
343                        if let Some(entry) = found {
344                            result.push_str(&entry.text);
345                        } else {
346                            return Err(format!("!{}: event not found", pattern));
347                        }
348                    }
349                    _ => result.push(bang),
350                }
351            } else if c == '^' && result.is_empty() {
352                // ^old^new - quick substitution
353                let mut old = String::new();
354                let mut new = String::new();
355                let mut in_new = false;
356
357                while let Some(c) = chars.next() {
358                    if c == '^' {
359                        if in_new {
360                            break;
361                        }
362                        in_new = true;
363                    } else if in_new {
364                        new.push(c);
365                    } else {
366                        old.push(c);
367                    }
368                }
369
370                if let Some(entry) = self.latest() {
371                    result = entry.text.replacen(&old, &new, 1);
372                    self.hsubl = Some(old);
373                    self.hsubr = Some(new);
374                } else {
375                    return Err("No previous command".to_string());
376                }
377            } else {
378                result.push(c);
379            }
380        }
381
382        Ok(result)
383    }
384
385    /// Read history file
386    pub fn read_file(&mut self, path: &Path) -> io::Result<()> {
387        let file = File::open(path)?;
388        let reader = BufReader::new(file);
389
390        for line in reader.lines() {
391            let line = line?;
392
393            // Parse extended history format
394            if line.starts_with(':') {
395                // Extended format: : timestamp:0;command
396                let parts: Vec<&str> = line.splitn(2, ';').collect();
397                if parts.len() == 2 {
398                    let text = parts[1].to_string();
399                    let mut entry = HistEntry::new(self.curhist + 1, text);
400
401                    // Parse timestamp
402                    if let Some(ts_part) = parts[0].strip_prefix(": ") {
403                        if let Some(ts_str) = ts_part.split(':').next() {
404                            if let Ok(ts) = ts_str.parse::<i64>() {
405                                entry.stim = ts;
406                                entry.ftim = ts;
407                            }
408                        }
409                    }
410
411                    entry.flags |= hist_flags::OLD;
412                    self.curhist += 1;
413                    self.add_entry(entry);
414                }
415            } else if !line.is_empty() {
416                // Simple format
417                self.curhist += 1;
418                let mut entry = HistEntry::new(self.curhist, line);
419                entry.flags |= hist_flags::OLD;
420                self.add_entry(entry);
421            }
422        }
423
424        Ok(())
425    }
426
427    /// Write history file
428    pub fn write_file(&self, path: &Path, append: bool) -> io::Result<()> {
429        let mut file = OpenOptions::new()
430            .write(true)
431            .create(true)
432            .truncate(!append)
433            .append(append)
434            .open(path)?;
435
436        for num in self.ring.iter().rev() {
437            if let Some(entry) = self.entries.get(num) {
438                if (entry.flags & hist_flags::NOWRITE) != 0 {
439                    continue;
440                }
441                // Write extended format
442                writeln!(file, ": {}:0;{}", entry.stim, entry.text)?;
443            }
444        }
445
446        Ok(())
447    }
448
449    /// Clear all history
450    pub fn clear(&mut self) {
451        self.entries.clear();
452        self.ring.clear();
453        self.histlinect = 0;
454    }
455
456    /// Get all entries in order
457    pub fn all_entries(&self) -> Vec<&HistEntry> {
458        self.ring
459            .iter()
460            .filter_map(|n| self.entries.get(n))
461            .collect()
462    }
463
464    /// Number of entries
465    pub fn len(&self) -> usize {
466        self.entries.len()
467    }
468
469    /// Check if empty
470    pub fn is_empty(&self) -> bool {
471        self.entries.is_empty()
472    }
473}
474
475/// Save history context (from hist.c hist_context_save/restore)
476#[derive(Clone)]
477pub struct HistStack {
478    pub histactive: u32,
479    pub histdone: i32,
480    pub stophist: i32,
481    pub chline: Option<String>,
482    pub hptr: usize,
483    pub chwords: Vec<(usize, usize)>,
484    pub hlinesz: usize,
485    pub defev: i64,
486    pub hist_keep_comment: bool,
487}
488
489impl Default for HistStack {
490    fn default() -> Self {
491        HistStack {
492            histactive: 0,
493            histdone: 0,
494            stophist: 0,
495            chline: None,
496            hptr: 0,
497            chwords: Vec::new(),
498            hlinesz: 0,
499            defev: 0,
500            hist_keep_comment: false,
501        }
502    }
503}
504
505/// History done flags (from hist.c)
506pub const HISTFLAG_DONE: i32 = 1;
507pub const HISTFLAG_NOEXEC: i32 = 2;
508pub const HISTFLAG_RECALL: i32 = 4;
509pub const HISTFLAG_SETTY: i32 = 8;
510
511/// Case modification types (from hist.c casemodify)
512#[derive(Clone, Copy, Debug, PartialEq)]
513pub enum CaseMod {
514    Lower,
515    Upper,
516    Caps,
517}
518
519/// Case modify a string (from hist.c casemodify lines 2194-2323)
520pub fn casemodify(s: &str, how: CaseMod) -> String {
521    let mut result = String::with_capacity(s.len());
522    let mut nextupper = true;
523
524    for c in s.chars() {
525        let modified = match how {
526            CaseMod::Lower => c.to_lowercase().collect::<String>(),
527            CaseMod::Upper => c.to_uppercase().collect::<String>(),
528            CaseMod::Caps => {
529                if !c.is_alphanumeric() {
530                    nextupper = true;
531                    c.to_string()
532                } else if nextupper {
533                    nextupper = false;
534                    c.to_uppercase().collect::<String>()
535                } else {
536                    c.to_lowercase().collect::<String>()
537                }
538            }
539        };
540        result.push_str(&modified);
541    }
542
543    result
544}
545
546/// Remove trailing path component (from hist.c remtpath lines 2056-2117)
547pub fn remtpath(s: &str, count: i32) -> String {
548    let s = s.trim_end_matches('/');
549
550    if s.is_empty() {
551        return "/".to_string();
552    }
553
554    if count == 0 {
555        if let Some(pos) = s.rfind('/') {
556            if pos == 0 {
557                return "/".to_string();
558            }
559            return s[..pos].trim_end_matches('/').to_string();
560        }
561        return ".".to_string();
562    }
563
564    let parts: Vec<&str> = s.split('/').filter(|p| !p.is_empty()).collect();
565    if count as usize >= parts.len() {
566        return s.to_string();
567    }
568
569    let leading_slash = s.starts_with('/');
570    let result: String = parts
571        .iter()
572        .take(count as usize)
573        .map(|s| *s)
574        .collect::<Vec<&str>>()
575        .join("/");
576
577    if leading_slash {
578        format!("/{}", result)
579    } else {
580        result
581    }
582}
583
584/// Remove leading path components (from hist.c remlpaths lines 2151-2186)
585pub fn remlpaths(s: &str, count: i32) -> String {
586    let s = s.trim_end_matches('/');
587
588    if s.is_empty() {
589        return String::new();
590    }
591
592    let parts: Vec<&str> = s.split('/').filter(|p| !p.is_empty()).collect();
593
594    if count == 0 {
595        if let Some(last) = parts.last() {
596            return last.to_string();
597        }
598        return String::new();
599    }
600
601    if count as usize >= parts.len() {
602        return s.to_string();
603    }
604
605    parts
606        .iter()
607        .rev()
608        .take(count as usize)
609        .rev()
610        .map(|s| *s)
611        .collect::<Vec<&str>>()
612        .join("/")
613}
614
615/// Remove extension (from hist.c remtext lines 2122-2131)
616pub fn remtext(s: &str) -> String {
617    if let Some(slash_pos) = s.rfind('/') {
618        let after_slash = &s[slash_pos + 1..];
619        if let Some(dot_pos) = after_slash.rfind('.') {
620            if dot_pos > 0 {
621                return format!("{}/{}", &s[..slash_pos], &after_slash[..dot_pos]);
622            }
623        }
624        return s.to_string();
625    }
626
627    if let Some(dot_pos) = s.rfind('.') {
628        if dot_pos > 0 {
629            return s[..dot_pos].to_string();
630        }
631    }
632    s.to_string()
633}
634
635/// Get extension (from hist.c rembutext lines 2136-2148)
636pub fn rembutext(s: &str) -> String {
637    if let Some(slash_pos) = s.rfind('/') {
638        let after_slash = &s[slash_pos + 1..];
639        if let Some(dot_pos) = after_slash.rfind('.') {
640            return after_slash[dot_pos + 1..].to_string();
641        }
642        return String::new();
643    }
644
645    if let Some(dot_pos) = s.rfind('.') {
646        return s[dot_pos + 1..].to_string();
647    }
648    String::new()
649}
650
651/// Convert to absolute path (from hist.c chabspath lines 1877-1955)
652pub fn chabspath(s: &str) -> std::io::Result<String> {
653    if s.is_empty() {
654        return Ok(String::new());
655    }
656
657    let path = if !s.starts_with('/') {
658        let cwd = std::env::current_dir()?;
659        format!("{}/{}", cwd.display(), s)
660    } else {
661        s.to_string()
662    };
663
664    let mut result = Vec::new();
665    for component in path.split('/') {
666        match component {
667            "" | "." => continue,
668            ".." => {
669                if !result.is_empty() && result.last() != Some(&"..") {
670                    result.pop();
671                } else if result.is_empty() && !path.starts_with('/') {
672                    result.push("..");
673                }
674            }
675            c => result.push(c),
676        }
677    }
678
679    if path.starts_with('/') {
680        Ok(format!("/{}", result.join("/")))
681    } else if result.is_empty() {
682        Ok(".".to_string())
683    } else {
684        Ok(result.join("/"))
685    }
686}
687
688/// Quote a string for shell (from hist.c quote lines 2486-2523)
689pub fn quote(s: &str) -> String {
690    let mut result = String::with_capacity(s.len() + 10);
691    result.push('\'');
692
693    for c in s.chars() {
694        if c == '\'' {
695            result.push_str("'\\''");
696        } else {
697            result.push(c);
698        }
699    }
700
701    result.push('\'');
702    result
703}
704
705/// Quote with word breaking (from hist.c quotebreak lines 2527-2556)
706pub fn quotebreak(s: &str) -> String {
707    let mut result = String::with_capacity(s.len() + 10);
708    result.push('\'');
709
710    for c in s.chars() {
711        if c == '\'' {
712            result.push_str("'\\''");
713        } else if c.is_whitespace() {
714            result.push('\'');
715            result.push(c);
716            result.push('\'');
717        } else {
718            result.push(c);
719        }
720    }
721
722    result.push('\'');
723    result
724}
725
726/// Perform history substitution (from hist.c subst lines 2336-2391)
727pub fn subst(s: &str, in_pattern: &str, out_pattern: &str, global: bool) -> String {
728    if in_pattern.is_empty() {
729        return s.to_string();
730    }
731
732    let out_expanded = convamps(out_pattern, in_pattern);
733
734    if global {
735        s.replace(in_pattern, &out_expanded)
736    } else {
737        s.replacen(in_pattern, &out_expanded, 1)
738    }
739}
740
741/// Convert & to matched pattern (from hist.c convamps lines 2394-2418)
742fn convamps(out: &str, in_pattern: &str) -> String {
743    let mut result = String::with_capacity(out.len());
744    let mut chars = out.chars().peekable();
745
746    while let Some(c) = chars.next() {
747        if c == '\\' {
748            if let Some(&next) = chars.peek() {
749                result.push(next);
750                chars.next();
751            }
752        } else if c == '&' {
753            result.push_str(in_pattern);
754        } else {
755            result.push(c);
756        }
757    }
758
759    result
760}
761
762/// Get argument specification (from hist.c getargspec lines 1792-1829)
763pub fn getargspec(argc: usize, c: char, marg: Option<usize>, evset: bool) -> Option<usize> {
764    match c {
765        '0' => Some(0),
766        '1'..='9' => Some(c.to_digit(10).unwrap() as usize),
767        '^' => Some(1),
768        '$' => Some(argc),
769        '%' => {
770            if evset {
771                return None;
772            }
773            marg
774        }
775        _ => None,
776    }
777}
778
779/// History search containing pattern (from hist.c hconsearch lines 1836-1854)
780impl History {
781    pub fn hconsearch(&self, pattern: &str) -> Option<(i64, usize)> {
782        for num in &self.ring {
783            if let Some(entry) = self.entries.get(num) {
784                if let Some(pos) = entry.text.find(pattern) {
785                    let words: Vec<&str> = entry.text.split_whitespace().collect();
786                    let mut word_idx = 0;
787                    let mut char_count = 0;
788                    for (i, word) in words.iter().enumerate() {
789                        if char_count + word.len() > pos {
790                            word_idx = i;
791                            break;
792                        }
793                        char_count += word.len() + 1;
794                    }
795                    return Some((entry.histnum, word_idx));
796                }
797            }
798        }
799        None
800    }
801
802    /// History search by prefix (from hist.c hcomsearch lines 1859-1872)
803    pub fn hcomsearch(&self, prefix: &str) -> Option<i64> {
804        for num in &self.ring {
805            if let Some(entry) = self.entries.get(num) {
806                if entry.text.starts_with(prefix) {
807                    return Some(entry.histnum);
808                }
809            }
810        }
811        None
812    }
813
814    /// Get arguments from history entry (from hist.c getargs lines 2453-2482)
815    pub fn getargs(&self, ev: i64, arg1: usize, arg2: usize) -> Option<String> {
816        let entry = self.entries.get(&ev)?;
817        let words: Vec<&str> = entry.text.split_whitespace().collect();
818
819        if arg2 < arg1 || arg1 >= words.len() || arg2 >= words.len() {
820            return None;
821        }
822
823        if arg1 == 0 && arg2 == words.len() - 1 {
824            return Some(entry.text.clone());
825        }
826
827        Some(words[arg1..=arg2].join(" "))
828    }
829
830    /// Save history context (from hist.c hist_context_save lines 248-290)
831    pub fn save_context(&self) -> HistStack {
832        HistStack {
833            histactive: self.histactive,
834            histdone: self.histdone,
835            stophist: self.stophist,
836            chline: self.curline.as_ref().map(|e| e.text.clone()),
837            hptr: 0,
838            chwords: Vec::new(),
839            hlinesz: 0,
840            defev: self.curhist - 1,
841            hist_keep_comment: false,
842        }
843    }
844
845    /// Restore history context (from hist.c hist_context_restore lines 296-325)
846    pub fn restore_context(&mut self, ctx: &HistStack) {
847        self.histactive = ctx.histactive;
848        self.histdone = ctx.histdone;
849        self.stophist = ctx.stophist;
850    }
851
852    /// Set history in-word state (from hist.c hist_in_word lines 339-345)
853    pub fn hist_in_word(&mut self, yesno: bool) {
854        if yesno {
855            self.histactive |= HA_INWORD;
856        } else {
857            self.histactive &= !HA_INWORD;
858        }
859    }
860
861    /// Check if in word (from hist.c hist_is_in_word lines 348-352)
862    pub fn hist_is_in_word(&self) -> bool {
863        (self.histactive & HA_INWORD) != 0
864    }
865
866    /// Add history number with offset (from hist.c addhistnum lines 1265-1280)
867    pub fn addhistnum(&self, hl: i64, n: i64) -> i64 {
868        let target = hl + n;
869        if target < 1 {
870            0
871        } else if target > self.curhist {
872            self.curhist + 1
873        } else {
874            target
875        }
876    }
877
878    /// Reduce blanks in history line (from hist.c histreduceblanks lines 1199-1250)
879    pub fn histreduceblanks(line: &str, words: &[(usize, usize)]) -> String {
880        if words.is_empty() {
881            return line.to_string();
882        }
883
884        let mut result = String::new();
885        let chars: Vec<char> = line.chars().collect();
886
887        for (i, (start, end)) in words.iter().enumerate() {
888            if i > 0 {
889                result.push(' ');
890            }
891            for j in *start..*end {
892                if j < chars.len() {
893                    result.push(chars[j]);
894                }
895            }
896        }
897
898        result
899    }
900
901    /// Resize history entries to fit histsiz (from hist.c resizehistents lines 2620-2632)
902    pub fn resizehistents(&mut self) {
903        while self.histlinect > self.histsiz {
904            if let Some(oldest) = self.ring.pop() {
905                self.entries.remove(&oldest);
906                self.histlinect -= 1;
907            } else {
908                break;
909            }
910        }
911    }
912
913    /// Read history file (from hist.c readhistfile lines 2675-2920)
914    pub fn readhistfile(&mut self, filename: &str, err: bool) -> io::Result<usize> {
915        let file = File::open(filename)?;
916        let reader = BufReader::new(file);
917        let mut count = 0;
918
919        for line in reader.lines() {
920            let line = line?;
921            if line.is_empty() {
922                continue;
923            }
924
925            // Check for extended history format: : <timestamp>:0;<command>
926            if line.starts_with(": ") {
927                let rest = &line[2..];
928                if let Some(semi) = rest.find(';') {
929                    let time_part = &rest[..semi];
930                    let cmd_part = &rest[semi + 1..];
931
932                    let stim = if let Some(colon) = time_part.find(':') {
933                        time_part[..colon].parse::<i64>().unwrap_or(0)
934                    } else {
935                        time_part.parse::<i64>().unwrap_or(0)
936                    };
937
938                    if !cmd_part.trim().is_empty() {
939                        self.curhist += 1;
940                        let mut entry = HistEntry::new(self.curhist, cmd_part.to_string());
941                        entry.stim = stim;
942                        entry.flags = hist_flags::OLD;
943                        self.add_entry(entry);
944                        count += 1;
945                    }
946                }
947            } else {
948                // Plain history line
949                if !line.trim().is_empty() {
950                    self.curhist += 1;
951                    let mut entry = HistEntry::new(self.curhist, line);
952                    entry.flags = hist_flags::OLD;
953                    self.add_entry(entry);
954                    count += 1;
955                }
956            }
957        }
958
959        if err && count == 0 {
960            return Err(io::Error::new(
961                io::ErrorKind::InvalidData,
962                "No history entries",
963            ));
964        }
965
966        Ok(count)
967    }
968
969    /// Write history file (from hist.c savehistfile lines 2925-3155)
970    pub fn savehistfile(&self, filename: &str, mode: WriteMode) -> io::Result<usize> {
971        let file = match mode {
972            WriteMode::Overwrite => File::create(filename)?,
973            WriteMode::Append => OpenOptions::new()
974                .create(true)
975                .append(true)
976                .open(filename)?,
977        };
978        let mut writer = io::BufWriter::new(file);
979        let mut count = 0;
980
981        for num in self.ring.iter().rev() {
982            if let Some(entry) = self.entries.get(num) {
983                if (entry.flags & hist_flags::NOWRITE) != 0 {
984                    continue;
985                }
986
987                // Write in extended format
988                writeln!(writer, ": {}:0;{}", entry.stim, entry.text)?;
989                count += 1;
990            }
991        }
992
993        writer.flush()?;
994        Ok(count)
995    }
996
997    /// Lock history file (from hist.c lockhistfile lines 2961-2998)
998    pub fn lockhistfile(&self, filename: &str, _excl: bool) -> io::Result<()> {
999        let lockfile = format!("{}.lock", filename);
1000        File::create(&lockfile)?;
1001        Ok(())
1002    }
1003
1004    /// Unlock history file (from hist.c unlockhistfile lines 3001-3018)
1005    pub fn unlockhistfile(&self, filename: &str) -> io::Result<()> {
1006        let lockfile = format!("{}.lock", filename);
1007        std::fs::remove_file(&lockfile).ok();
1008        Ok(())
1009    }
1010
1011    /// Quote string for history (from hist.c quotestring lines 2483-2523)
1012    pub fn quotestring(s: &str) -> String {
1013        let mut result = String::with_capacity(s.len() + 10);
1014        result.push('\'');
1015
1016        for c in s.chars() {
1017            if c == '\'' {
1018                result.push_str("'\\''");
1019            } else {
1020                result.push(c);
1021            }
1022        }
1023
1024        result.push('\'');
1025        result
1026    }
1027
1028    /// History word split (from hist.c get_history_word)
1029    pub fn get_history_word(line: &str, idx: usize) -> Option<&str> {
1030        line.split_whitespace().nth(idx)
1031    }
1032
1033    /// Count words in history line
1034    pub fn histword_count(line: &str) -> usize {
1035        line.split_whitespace().count()
1036    }
1037}
1038
1039/// History file write mode
1040pub enum WriteMode {
1041    Overwrite,
1042    Append,
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Missing functions from hist.c
1047// ---------------------------------------------------------------------------
1048
1049/// Apply history word designator and modifiers to an event
1050/// (from hist.c histsubchar - the inline expansion engine)
1051///
1052/// Full syntax: !event:word_designator:modifier1:modifier2...
1053///
1054/// Word designators: 0 (command), ^ (first arg), $ (last), * (all args),
1055///   n (nth word), n-m (range), n* (nth to last), n- (nth to second-to-last)
1056///
1057/// Modifiers: h (head/dirname), t (tail/basename), r (remove ext), e (ext only),
1058///   l (lowercase), u (uppercase), s/old/new/ (substitute), & (repeat subst),
1059///   g (global modifier), p (print, don't execute), q (quote), Q (unquote),
1060///   x (quote words), a (absolute path)
1061pub fn apply_word_designator(text: &str, designator: &str) -> Option<String> {
1062    let words: Vec<&str> = text.split_whitespace().collect();
1063    if words.is_empty() {
1064        return None;
1065    }
1066
1067    match designator {
1068        "0" => Some(words[0].to_string()),
1069        "^" => words.get(1).map(|s| s.to_string()),
1070        "$" => words.last().map(|s| s.to_string()),
1071        "*" => {
1072            if words.len() > 1 {
1073                Some(words[1..].join(" "))
1074            } else {
1075                Some(String::new())
1076            }
1077        }
1078        s if s.contains('-') => {
1079            let parts: Vec<&str> = s.splitn(2, '-').collect();
1080            let start: usize = if parts[0].is_empty() {
1081                0
1082            } else {
1083                parts[0].parse().ok()?
1084            };
1085            let end: usize = if parts[1].is_empty() {
1086                words.len() - 2 // n- means up to but not including last
1087            } else {
1088                parts[1].parse().ok()?
1089            };
1090            if start <= end && end < words.len() {
1091                Some(words[start..=end].join(" "))
1092            } else {
1093                None
1094            }
1095        }
1096        s if s.ends_with('*') => {
1097            let start: usize = s[..s.len() - 1].parse().ok()?;
1098            if start < words.len() {
1099                Some(words[start..].join(" "))
1100            } else {
1101                None
1102            }
1103        }
1104        s => {
1105            let idx: usize = s.parse().ok()?;
1106            words.get(idx).map(|s| s.to_string())
1107        }
1108    }
1109}
1110
1111/// Apply a single history modifier to text
1112pub fn apply_hist_modifier(
1113    text: &str,
1114    modifier: char,
1115    global: bool,
1116    subst_state: &mut (String, String),
1117) -> String {
1118    match modifier {
1119        'h' => {
1120            // Head (dirname) - remove trailing path component
1121            if let Some(pos) = text.rfind('/') {
1122                if pos == 0 {
1123                    "/".to_string()
1124                } else {
1125                    text[..pos].to_string()
1126                }
1127            } else {
1128                ".".to_string()
1129            }
1130        }
1131        't' => {
1132            // Tail (basename) - remove leading path components
1133            text.rsplit('/').next().unwrap_or(text).to_string()
1134        }
1135        'r' => {
1136            // Remove extension
1137            if let Some(dot) = text.rfind('.') {
1138                if dot > text.rfind('/').unwrap_or(0) {
1139                    return text[..dot].to_string();
1140                }
1141            }
1142            text.to_string()
1143        }
1144        'e' => {
1145            // Extension only
1146            if let Some(dot) = text.rfind('.') {
1147                if dot > text.rfind('/').unwrap_or(0) {
1148                    return text[dot + 1..].to_string();
1149                }
1150            }
1151            String::new()
1152        }
1153        'l' => text.to_lowercase(),
1154        'u' => text.to_uppercase(),
1155        'q' => {
1156            // Quote - single-quote the text
1157            format!("'{}'", text.replace('\'', "'\\''"))
1158        }
1159        'Q' => {
1160            // Unquote - remove one level of quoting
1161            let s = text.strip_prefix('\'').and_then(|s| s.strip_suffix('\''));
1162            match s {
1163                Some(inner) => inner.replace("'\\''", "'"),
1164                None => {
1165                    let s = text.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
1166                    match s {
1167                        Some(inner) => inner.to_string(),
1168                        None => text.to_string(),
1169                    }
1170                }
1171            }
1172        }
1173        'x' => {
1174            // Quote words individually
1175            text.split_whitespace()
1176                .map(|w| format!("'{}'", w.replace('\'', "'\\''")))
1177                .collect::<Vec<_>>()
1178                .join(" ")
1179        }
1180        'a' => {
1181            // Make absolute path
1182            if text.starts_with('/') {
1183                text.to_string()
1184            } else if let Ok(cwd) = std::env::current_dir() {
1185                cwd.join(text).to_string_lossy().to_string()
1186            } else {
1187                text.to_string()
1188            }
1189        }
1190        's' | '&' => {
1191            // Substitution (handled by caller with subst_state)
1192            if modifier == '&' {
1193                // Repeat last substitution
1194                let (ref old, ref new) = *subst_state;
1195                if old.is_empty() {
1196                    return text.to_string();
1197                }
1198                if global {
1199                    text.replace(old.as_str(), new.as_str())
1200                } else {
1201                    text.replacen(old.as_str(), new.as_str(), 1)
1202                }
1203            } else {
1204                text.to_string() // 's' is handled externally
1205            }
1206        }
1207        'p' => text.to_string(), // Print only - handled by caller
1208        _ => text.to_string(),
1209    }
1210}
1211
1212/// Remove duplicate history entries (from hist.c histremovedups)
1213pub fn histremovedups(entries: &mut Vec<HistEntry>) {
1214    let mut seen = std::collections::HashSet::new();
1215    entries.retain(|e| seen.insert(e.text.clone()));
1216}
1217
1218/// Reduce blanks in history text (from hist.c histreduceblanks)
1219pub fn histreduceblanks(text: &str) -> String {
1220    let mut result = String::with_capacity(text.len());
1221    let mut prev_space = false;
1222    for c in text.chars() {
1223        if c.is_whitespace() {
1224            if !prev_space {
1225                result.push(' ');
1226                prev_space = true;
1227            }
1228        } else {
1229            result.push(c);
1230            prev_space = false;
1231        }
1232    }
1233    result.trim().to_string()
1234}
1235
1236/// Get a history line as a complete string (from hist.c hgetline)
1237pub fn hgetline(entry: &HistEntry) -> String {
1238    entry.text.clone()
1239}
1240
1241/// History word replacement (from hist.c hwrep)
1242pub fn hwrep(entry: &HistEntry, replacement: &str, word_idx: usize) -> String {
1243    let words: Vec<&str> = entry.text.split_whitespace().collect();
1244    if word_idx >= words.len() {
1245        return entry.text.clone();
1246    }
1247    let mut new_words: Vec<String> = words.iter().map(|s| s.to_string()).collect();
1248    new_words[word_idx] = replacement.to_string();
1249    new_words.join(" ")
1250}
1251
1252/// Move forward in history (from hist.c addhistnum)
1253pub fn addhistnum(base: i64, n: i64) -> i64 {
1254    base + n
1255}
1256
1257/// Check if history line should be ignored (starts with space, duplicate, etc.)
1258pub fn should_ignore_line(
1259    text: &str,
1260    ignorespace: bool,
1261    ignoredups: bool,
1262    last: Option<&str>,
1263) -> bool {
1264    if ignorespace && text.starts_with(' ') {
1265        return true;
1266    }
1267    if ignoredups {
1268        if let Some(prev) = last {
1269            if prev == text {
1270                return true;
1271            }
1272        }
1273    }
1274    false
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280
1281    #[test]
1282    fn test_history_add() {
1283        let mut hist = History::new();
1284        hist.hbegin(true);
1285        hist.hend(Some("echo hello".to_string()));
1286
1287        assert_eq!(hist.len(), 1);
1288        assert_eq!(hist.latest().unwrap().text, "echo hello");
1289    }
1290
1291    #[test]
1292    fn test_history_expand_bang_bang() {
1293        let mut hist = History::new();
1294        hist.hbegin(true);
1295        hist.hend(Some("ls -la".to_string()));
1296
1297        let result = hist.expand("!! | grep foo").unwrap();
1298        assert_eq!(result, "ls -la | grep foo");
1299    }
1300
1301    #[test]
1302    fn test_history_expand_caret() {
1303        let mut hist = History::new();
1304        hist.hbegin(true);
1305        hist.hend(Some("echo hello".to_string()));
1306
1307        let result = hist.expand("^hello^world").unwrap();
1308        assert_eq!(result, "echo world");
1309    }
1310
1311    #[test]
1312    fn test_history_search() {
1313        let mut hist = History::new();
1314
1315        hist.hbegin(true);
1316        hist.hend(Some("cd /tmp".to_string()));
1317
1318        hist.hbegin(true);
1319        hist.hend(Some("echo hello".to_string()));
1320
1321        hist.hbegin(true);
1322        hist.hend(Some("ls -la".to_string()));
1323
1324        let result = hist.search_back("echo", hist.curhist + 1);
1325        assert!(result.is_some());
1326        assert_eq!(result.unwrap().text, "echo hello");
1327    }
1328
1329    #[test]
1330    fn test_history_capacity() {
1331        let mut hist = History::new();
1332        hist.histsiz = 3;
1333
1334        for i in 0..5 {
1335            hist.hbegin(true);
1336            hist.hend(Some(format!("cmd{}", i)));
1337        }
1338
1339        assert_eq!(hist.len(), 3);
1340        assert!(hist.get(1).is_none());
1341        assert!(hist.get(2).is_none());
1342    }
1343}
1344
1345// ---------------------------------------------------------------------------
1346// Additional missing functions from hist.c (lexer integration layer)
1347// ---------------------------------------------------------------------------
1348
1349/// Input stack management for history (from hist.c strinbeg/strinend)
1350pub struct HistInputStack {
1351    stack: Vec<HistInputState>,
1352}
1353
1354struct HistInputState {
1355    dohist: bool,
1356}
1357
1358impl Default for HistInputStack {
1359    fn default() -> Self {
1360        Self::new()
1361    }
1362}
1363
1364impl HistInputStack {
1365    pub fn new() -> Self {
1366        HistInputStack { stack: Vec::new() }
1367    }
1368
1369    /// Begin string input (from hist.c strinbeg)
1370    pub fn strinbeg(&mut self, dohist: bool) {
1371        self.stack.push(HistInputState { dohist });
1372    }
1373
1374    /// End string input (from hist.c strinend)
1375    pub fn strinend(&mut self) {
1376        self.stack.pop();
1377    }
1378
1379    /// Check if currently doing history
1380    pub fn doing_hist(&self) -> bool {
1381        self.stack.last().map(|s| s.dohist).unwrap_or(false)
1382    }
1383}
1384
1385/// History line linkage (from hist.c linkcurline/unlinkcurline)
1386pub struct HistLineLink {
1387    pub linked: bool,
1388    pub line: String,
1389}
1390
1391impl HistLineLink {
1392    pub fn new() -> Self {
1393        HistLineLink {
1394            linked: false,
1395            line: String::new(),
1396        }
1397    }
1398
1399    /// Link current line to history (from hist.c linkcurline)
1400    pub fn linkcurline(&mut self, line: &str) {
1401        self.line = line.to_string();
1402        self.linked = true;
1403    }
1404
1405    /// Unlink current line from history (from hist.c unlinkcurline)
1406    pub fn unlinkcurline(&mut self) {
1407        self.linked = false;
1408        self.line.clear();
1409    }
1410}
1411
1412impl Default for HistLineLink {
1413    fn default() -> Self {
1414        Self::new()
1415    }
1416}
1417
1418/// History entry navigation (from hist.c movehistent/up_histent/down_histent)
1419impl History {
1420    /// Move n entries in history (from hist.c movehistent)
1421    pub fn movehistent(&self, start: i64, n: i64) -> Option<&HistEntry> {
1422        let target = start + n;
1423        self.get(target)
1424    }
1425
1426    /// Move up one entry (from hist.c up_histent)
1427    pub fn up_histent(&self, current: i64) -> Option<&HistEntry> {
1428        self.get(current - 1)
1429    }
1430
1431    /// Move down one entry (from hist.c down_histent)
1432    pub fn down_histent(&self, current: i64) -> Option<&HistEntry> {
1433        self.get(current + 1)
1434    }
1435
1436    /// Get history entry by event number with near-match (from hist.c gethistent)
1437    pub fn gethistent(&self, ev: i64, near_match: bool) -> Option<&HistEntry> {
1438        if let Some(entry) = self.get(ev) {
1439            return Some(entry);
1440        }
1441        if !near_match {
1442            return None;
1443        }
1444        // Try nearest
1445        let mut best = None;
1446        let mut best_dist = i64::MAX;
1447        for (num, entry) in &self.entries {
1448            let dist = (*num - ev).abs();
1449            if dist < best_dist {
1450                best_dist = dist;
1451                best = Some(entry);
1452            }
1453        }
1454        best
1455    }
1456
1457    /// Prepare next history entry (from hist.c prepnexthistent)
1458    pub fn prepnexthistent(&mut self) -> i64 {
1459        self.curhist + 1
1460    }
1461}
1462
1463/// History word buffer operations (from hist.c ihwbegin/ihwabort/ihwend)
1464pub struct HistWordBuffer {
1465    buf: String,
1466    active: bool,
1467}
1468
1469impl Default for HistWordBuffer {
1470    fn default() -> Self {
1471        Self::new()
1472    }
1473}
1474
1475impl HistWordBuffer {
1476    pub fn new() -> Self {
1477        HistWordBuffer {
1478            buf: String::new(),
1479            active: false,
1480        }
1481    }
1482
1483    /// Begin collecting a history word (from hist.c ihwbegin)
1484    pub fn ihwbegin(&mut self) {
1485        self.buf.clear();
1486        self.active = true;
1487    }
1488
1489    /// Abort history word collection (from hist.c ihwabort)
1490    pub fn ihwabort(&mut self) {
1491        self.active = false;
1492        self.buf.clear();
1493    }
1494
1495    /// End history word collection (from hist.c ihwend)
1496    pub fn ihwend(&mut self) -> Option<String> {
1497        if self.active {
1498            self.active = false;
1499            Some(std::mem::take(&mut self.buf))
1500        } else {
1501            None
1502        }
1503    }
1504
1505    /// Add character to word buffer
1506    pub fn add(&mut self, c: char) {
1507        if self.active {
1508            self.buf.push(c);
1509        }
1510    }
1511
1512    /// Get current buffer content (from hist.c hwget)
1513    pub fn hwget(&self) -> &str {
1514        &self.buf
1515    }
1516}
1517
1518/// History backward word scan (from hist.c histbackword)
1519pub fn histbackword(line: &str, pos: usize) -> usize {
1520    if pos == 0 {
1521        return 0;
1522    }
1523    let bytes = line.as_bytes();
1524    let mut p = pos.min(bytes.len());
1525
1526    // Skip whitespace
1527    while p > 0 && bytes[p - 1].is_ascii_whitespace() {
1528        p -= 1;
1529    }
1530    // Skip word chars
1531    while p > 0 && !bytes[p - 1].is_ascii_whitespace() {
1532        p -= 1;
1533    }
1534    p
1535}
1536
1537/// Unget character for history (from hist.c ihungetc)
1538pub struct HistUnget {
1539    chars: Vec<char>,
1540}
1541
1542impl Default for HistUnget {
1543    fn default() -> Self {
1544        Self::new()
1545    }
1546}
1547
1548impl HistUnget {
1549    pub fn new() -> Self {
1550        HistUnget { chars: Vec::new() }
1551    }
1552
1553    /// Push back a character (from hist.c ihungetc)
1554    pub fn ihungetc(&mut self, c: char) {
1555        self.chars.push(c);
1556    }
1557
1558    /// Get a pushed-back character
1559    pub fn ihgetc(&mut self) -> Option<char> {
1560        self.chars.pop()
1561    }
1562
1563    pub fn has_chars(&self) -> bool {
1564        !self.chars.is_empty()
1565    }
1566}
1567
1568// ---------------------------------------------------------------------------
1569// Remaining 23 missing hist.c functions
1570// ---------------------------------------------------------------------------
1571
1572/// Add character to history word during lexing (from hist.c ihwaddc)
1573pub fn ihwaddc(hwbuf: &mut HistWordBuffer, c: char) {
1574    hwbuf.add(c);
1575}
1576
1577/// Add character to current line during lexing (from hist.c iaddtoline)
1578pub fn iaddtoline(line: &mut String, c: char) {
1579    line.push(c);
1580}
1581
1582/// Safe version of inungetc for history (from hist.c safeinungetc)
1583pub fn safeinungetc(unget: &mut HistUnget, c: char) {
1584    unget.ihungetc(c);
1585}
1586
1587/// Flush history error state (from hist.c herrflush)
1588pub fn herrflush() {
1589    // Reset history error flags - in Rust this is handled by the parser state
1590}
1591
1592/// Get substitution arguments from history (from hist.c getsubsargs)
1593/// Parses s/old/new/ syntax
1594pub fn getsubsargs(line: &str) -> Option<(String, String, bool)> {
1595    if line.len() < 2 {
1596        return None;
1597    }
1598    let sep = line.chars().next()?;
1599    let rest = &line[sep.len_utf8()..];
1600
1601    let mut old = String::new();
1602    let mut new = String::new();
1603    let mut in_new = false;
1604    let mut global = false;
1605
1606    for c in rest.chars() {
1607        if c == sep {
1608            if in_new {
1609                break;
1610            }
1611            in_new = true;
1612            continue;
1613        }
1614        if in_new {
1615            new.push(c);
1616        } else {
1617            old.push(c);
1618        }
1619    }
1620
1621    // Check for trailing 'g' flag
1622    if rest.ends_with('g') && rest.len() > old.len() + new.len() + 2 {
1623        global = true;
1624    }
1625
1626    if old.is_empty() {
1627        None
1628    } else {
1629        Some((old, new, global))
1630    }
1631}
1632
1633/// Get argument count from history entry (from hist.c getargc)
1634pub fn getargc(entry: &HistEntry) -> usize {
1635    entry.num_words()
1636}
1637
1638/// Report substitution failure (from hist.c substfailed)
1639pub fn substfailed() -> String {
1640    "substitution failed".to_string()
1641}
1642
1643/// Count digits in a string prefix (from hist.c digitcount)
1644pub fn digitcount(s: &str) -> usize {
1645    s.chars().take_while(|c| c.is_ascii_digit()).count()
1646}
1647
1648/// No-op history word handler (from hist.c nohw)
1649pub fn nohw(_c: char) {}
1650
1651/// No-op history word abort (from hist.c nohwabort)
1652pub fn nohwabort() {}
1653
1654/// No-op history word end (from hist.c nohwe)
1655pub fn nohwe() {}
1656
1657/// Put old history entry on top of ring (from hist.c putoldhistentryontop)
1658pub fn putoldhistentryontop(hist: &mut History) -> bool {
1659    // Move the oldest entry to the newest position for reuse
1660    if let Some(oldest_num) = hist.ring.first().copied() {
1661        if let Some(entry) = hist.entries.remove(&oldest_num) {
1662            hist.ring.remove(0);
1663            let new_num = hist.curhist + 1;
1664            hist.entries.insert(new_num, entry);
1665            hist.ring.push(new_num);
1666            return true;
1667        }
1668    }
1669    false
1670}
1671
1672/// Check if current line matches history entry (from hist.c checkcurline)
1673pub fn checkcurline(hist: &History, line: &str) -> bool {
1674    hist.latest().map(|e| e.text == line).unwrap_or(false)
1675}
1676
1677/// Quietly get history entry without error (from hist.c quietgethist)
1678pub fn quietgethist(hist: &History, ev: i64) -> Option<&HistEntry> {
1679    hist.get(ev)
1680}
1681
1682/// Dynamic history read during expansion (from hist.c hdynread)
1683pub fn hdynread(_hist: &History) -> Option<String> {
1684    // This is used for dynamic history reading during !{...} expansion
1685    // In Rust, this is handled inline during expand()
1686    None
1687}
1688
1689/// Initialize history subsystem (from hist.c inithist)
1690pub fn inithist() -> History {
1691    History::new()
1692}
1693
1694/// Read a single history line from file (from hist.c readhistline)
1695pub fn readhistline(line: &str) -> Option<HistEntry> {
1696    let line = line.trim();
1697    if line.is_empty() {
1698        return None;
1699    }
1700    // Extended history format: ": timestamp:duration;command"
1701    if line.starts_with(": ") {
1702        let rest = &line[2..];
1703        if let Some(semi) = rest.find(';') {
1704            let meta = &rest[..semi];
1705            let cmd = &rest[semi + 1..];
1706            let parts: Vec<&str> = meta.splitn(2, ':').collect();
1707            let timestamp = parts
1708                .first()
1709                .and_then(|s| s.parse::<i64>().ok())
1710                .unwrap_or(0);
1711            let mut entry = HistEntry::new(0, cmd.to_string());
1712            entry.stim = timestamp;
1713            return Some(entry);
1714        }
1715    }
1716    Some(HistEntry::new(0, line.to_string()))
1717}
1718
1719/// Lock history file with flock (from hist.c flockhistfile)
1720pub fn flockhistfile(path: &str) -> bool {
1721    #[cfg(unix)]
1722    {
1723        use std::os::unix::io::AsRawFd;
1724        if let Ok(file) = std::fs::OpenOptions::new()
1725            .write(true)
1726            .create(true)
1727            .open(format!("{}.lock", path))
1728        {
1729            let fd = file.as_raw_fd();
1730            unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) == 0 }
1731        } else {
1732            false
1733        }
1734    }
1735    #[cfg(not(unix))]
1736    {
1737        true
1738    }
1739}
1740
1741/// Check age of lock file (from hist.c checklocktime)
1742pub fn checklocktime(path: &str, max_age_secs: u64) -> bool {
1743    let lockfile = format!("{}.lock", path);
1744    if let Ok(meta) = std::fs::metadata(&lockfile) {
1745        if let Ok(modified) = meta.modified() {
1746            if let Ok(age) = modified.elapsed() {
1747                return age.as_secs() < max_age_secs;
1748            }
1749        }
1750    }
1751    false
1752}
1753
1754/// Split history line into words (from hist.c histsplitwords)
1755pub fn histsplitwords(line: &str) -> Vec<(usize, usize)> {
1756    let mut words = Vec::new();
1757    let mut in_word = false;
1758    let mut word_start = 0;
1759    let mut in_quote = false;
1760    let mut quote_char = '\0';
1761
1762    for (i, c) in line.char_indices() {
1763        if in_quote {
1764            if c == quote_char {
1765                in_quote = false;
1766            }
1767            continue;
1768        }
1769        if c == '\'' || c == '"' {
1770            in_quote = true;
1771            quote_char = c;
1772            if !in_word {
1773                word_start = i;
1774                in_word = true;
1775            }
1776            continue;
1777        }
1778        if c.is_ascii_whitespace() {
1779            if in_word {
1780                words.push((word_start, i));
1781                in_word = false;
1782            }
1783        } else if !in_word {
1784            word_start = i;
1785            in_word = true;
1786        }
1787    }
1788    if in_word {
1789        words.push((word_start, line.len()));
1790    }
1791    words
1792}
1793
1794/// History stack operations for nested parsing (from hist.c pushhiststack/pophiststack)
1795pub struct HistStackManager {
1796    stack: Vec<HistStackFrame>,
1797}
1798
1799struct HistStackFrame {
1800    curhist: i64,
1801    histsiz: usize,
1802    histactive: u32,
1803}
1804
1805impl Default for HistStackManager {
1806    fn default() -> Self {
1807        Self::new()
1808    }
1809}
1810
1811impl HistStackManager {
1812    pub fn new() -> Self {
1813        HistStackManager { stack: Vec::new() }
1814    }
1815
1816    /// Push current history state (from hist.c pushhiststack)
1817    pub fn pushhiststack(&mut self, hist: &History) {
1818        self.stack.push(HistStackFrame {
1819            curhist: hist.curhist,
1820            histsiz: hist.histsiz as usize,
1821            histactive: hist.histactive,
1822        });
1823    }
1824
1825    /// Pop and restore history state (from hist.c pophiststack)
1826    pub fn pophiststack(&mut self, hist: &mut History) {
1827        if let Some(frame) = self.stack.pop() {
1828            hist.curhist = frame.curhist;
1829            hist.histsiz = frame.histsiz as i64;
1830            hist.histactive = frame.histactive;
1831        }
1832    }
1833
1834    /// Save and pop history stack (from hist.c saveandpophiststack)
1835    pub fn saveandpophiststack(&mut self, hist: &mut History) {
1836        self.pophiststack(hist);
1837    }
1838}
1839
1840/// Resolve path to real path (from hist.c chrealpath)
1841pub fn chrealpath(path: &str) -> Option<String> {
1842    std::fs::canonicalize(path)
1843        .ok()
1844        .map(|p| p.to_string_lossy().to_string())
1845}
1846
1847/// Get all words from current edit buffer (from hist.c bufferwords)
1848pub fn bufferwords(line: &str, cursor_pos: usize) -> (Vec<String>, usize) {
1849    let words: Vec<String> = line.split_whitespace().map(String::from).collect();
1850    // Find which word the cursor is in
1851    let mut pos = 0;
1852    let mut word_idx = 0;
1853    for (i, word) in line.split_whitespace().enumerate() {
1854        if let Some(start) = line[pos..].find(word) {
1855            let wstart = pos + start;
1856            let wend = wstart + word.len();
1857            if cursor_pos >= wstart && cursor_pos <= wend {
1858                word_idx = i;
1859                break;
1860            }
1861            pos = wend;
1862        }
1863    }
1864    (words, word_idx)
1865}