Skip to main content

zsh/
prompt.rs

1//! Prompt expansion for zshrs
2//!
3//! Direct port from zsh/Src/prompt.c
4//!
5//! Supports zsh prompt escape sequences:
6//! - %d, %/, %~ - current directory
7//! - %c, %., %C - trailing path components
8//! - %n - username
9//! - %m, %M - hostname
10//! - %l - tty name
11//! - %? - exit status
12//! - %# - privilege indicator
13//! - %h, %! - history number
14//! - %j - number of jobs
15//! - %L - shell level
16//! - %D, %T, %t, %*, %w, %W - date/time
17//! - %B, %b - bold on/off
18//! - %U, %u - underline on/off
19//! - %S, %s - standout on/off
20//! - %F{color}, %f - foreground color
21//! - %K{color}, %k - background color
22//! - %{ %}  - literal escape sequences
23//! - %(x.true.false) - conditional
24
25use std::env;
26
27/// Parser states for %_ in prompts
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CmdState {
30    For,
31    While,
32    Repeat,
33    Select,
34    Until,
35    If,
36    Then,
37    Else,
38    Elif,
39    Math,
40    Cond,
41    CmdOr,
42    CmdAnd,
43    Pipe,
44    ErrPipe,
45    Foreach,
46    Case,
47    Function,
48    Subsh,
49    Cursh,
50    Array,
51    Quote,
52    DQuote,
53    BQuote,
54    CmdSubst,
55    MathSubst,
56    ElifThen,
57    Heredoc,
58    HeredocD,
59    Brace,
60    BraceParam,
61    Always,
62}
63
64impl CmdState {
65    pub fn name(&self) -> &'static str {
66        match self {
67            CmdState::For => "for",
68            CmdState::While => "while",
69            CmdState::Repeat => "repeat",
70            CmdState::Select => "select",
71            CmdState::Until => "until",
72            CmdState::If => "if",
73            CmdState::Then => "then",
74            CmdState::Else => "else",
75            CmdState::Elif => "elif",
76            CmdState::Math => "math",
77            CmdState::Cond => "cond",
78            CmdState::CmdOr => "cmdor",
79            CmdState::CmdAnd => "cmdand",
80            CmdState::Pipe => "pipe",
81            CmdState::ErrPipe => "errpipe",
82            CmdState::Foreach => "foreach",
83            CmdState::Case => "case",
84            CmdState::Function => "function",
85            CmdState::Subsh => "subsh",
86            CmdState::Cursh => "cursh",
87            CmdState::Array => "array",
88            CmdState::Quote => "quote",
89            CmdState::DQuote => "dquote",
90            CmdState::BQuote => "bquote",
91            CmdState::CmdSubst => "cmdsubst",
92            CmdState::MathSubst => "mathsubst",
93            CmdState::ElifThen => "elif-then",
94            CmdState::Heredoc => "heredoc",
95            CmdState::HeredocD => "heredocd",
96            CmdState::Brace => "brace",
97            CmdState::BraceParam => "braceparam",
98            CmdState::Always => "always",
99        }
100    }
101}
102
103/// Text attributes for prompt formatting
104#[derive(Debug, Clone, Copy, Default)]
105pub struct TextAttrs {
106    pub bold: bool,
107    pub underline: bool,
108    pub standout: bool,
109    pub fg_color: Option<Color>,
110    pub bg_color: Option<Color>,
111}
112
113/// Color specification
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Color {
116    Black,
117    Red,
118    Green,
119    Yellow,
120    Blue,
121    Magenta,
122    Cyan,
123    White,
124    Default,
125    Numbered(u8),
126    Rgb(u8, u8, u8),
127}
128
129impl Color {
130    pub fn from_name(name: &str) -> Option<Color> {
131        match name.to_lowercase().as_str() {
132            "black" => Some(Color::Black),
133            "red" => Some(Color::Red),
134            "green" => Some(Color::Green),
135            "yellow" => Some(Color::Yellow),
136            "blue" => Some(Color::Blue),
137            "magenta" => Some(Color::Magenta),
138            "cyan" => Some(Color::Cyan),
139            "white" => Some(Color::White),
140            "default" => Some(Color::Default),
141            _ => {
142                if let Ok(n) = name.parse::<u8>() {
143                    Some(Color::Numbered(n))
144                } else {
145                    None
146                }
147            }
148        }
149    }
150
151    pub fn to_ansi_fg(&self) -> String {
152        match self {
153            Color::Black => "\x1b[30m".to_string(),
154            Color::Red => "\x1b[31m".to_string(),
155            Color::Green => "\x1b[32m".to_string(),
156            Color::Yellow => "\x1b[33m".to_string(),
157            Color::Blue => "\x1b[34m".to_string(),
158            Color::Magenta => "\x1b[35m".to_string(),
159            Color::Cyan => "\x1b[36m".to_string(),
160            Color::White => "\x1b[37m".to_string(),
161            Color::Default => "\x1b[39m".to_string(),
162            Color::Numbered(n) => format!("\x1b[38;5;{}m", n),
163            Color::Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b),
164        }
165    }
166
167    pub fn to_ansi_bg(&self) -> String {
168        match self {
169            Color::Black => "\x1b[40m".to_string(),
170            Color::Red => "\x1b[41m".to_string(),
171            Color::Green => "\x1b[42m".to_string(),
172            Color::Yellow => "\x1b[43m".to_string(),
173            Color::Blue => "\x1b[44m".to_string(),
174            Color::Magenta => "\x1b[45m".to_string(),
175            Color::Cyan => "\x1b[46m".to_string(),
176            Color::White => "\x1b[47m".to_string(),
177            Color::Default => "\x1b[49m".to_string(),
178            Color::Numbered(n) => format!("\x1b[48;5;{}m", n),
179            Color::Rgb(r, g, b) => format!("\x1b[48;2;{};{};{}m", r, g, b),
180        }
181    }
182}
183
184/// Context for prompt expansion
185pub struct PromptContext {
186    pub pwd: String,
187    pub home: String,
188    pub user: String,
189    pub host: String,
190    pub host_short: String,
191    pub tty: String,
192    pub lastval: i32,
193    pub histnum: i64,
194    pub shlvl: i32,
195    pub num_jobs: i32,
196    pub is_root: bool,
197    pub cmd_stack: Vec<CmdState>,
198    pub psvar: Vec<String>,
199    pub term_width: usize,
200    pub lineno: i64,
201}
202
203impl Default for PromptContext {
204    fn default() -> Self {
205        let home = env::var("HOME").unwrap_or_default();
206        let pwd = env::current_dir()
207            .map(|p| p.to_string_lossy().to_string())
208            .unwrap_or_else(|_| "/".to_string());
209
210        let user = env::var("USER")
211            .or_else(|_| env::var("LOGNAME"))
212            .unwrap_or_else(|_| "user".to_string());
213
214        let host = hostname::get()
215            .map(|h| h.to_string_lossy().to_string())
216            .unwrap_or_else(|_| "localhost".to_string());
217
218        let host_short = host.split('.').next().unwrap_or(&host).to_string();
219
220        let tty = std::fs::read_link("/proc/self/fd/0")
221            .map(|p| p.to_string_lossy().to_string())
222            .unwrap_or_else(|_| String::new());
223
224        let shlvl = env::var("SHLVL")
225            .ok()
226            .and_then(|s| s.parse().ok())
227            .unwrap_or(1);
228
229        PromptContext {
230            pwd,
231            home,
232            user,
233            host,
234            host_short,
235            tty,
236            lastval: 0,
237            histnum: 1,
238            shlvl,
239            num_jobs: 0,
240            is_root: unsafe { libc::geteuid() } == 0,
241            cmd_stack: Vec::new(),
242            psvar: Vec::new(),
243            term_width: 80,
244            lineno: 1,
245        }
246    }
247}
248
249/// Prompt expander
250pub struct PromptExpander<'a> {
251    ctx: &'a PromptContext,
252    input: &'a str,
253    pos: usize,
254    output: String,
255    attrs: TextAttrs,
256    in_escape: bool,
257    prompt_percent: bool,
258    prompt_bang: bool,
259}
260
261impl<'a> PromptExpander<'a> {
262    pub fn new(input: &'a str, ctx: &'a PromptContext) -> Self {
263        PromptExpander {
264            ctx,
265            input,
266            pos: 0,
267            output: String::with_capacity(input.len() * 2),
268            attrs: TextAttrs::default(),
269            in_escape: false,
270            prompt_percent: true,
271            prompt_bang: true,
272        }
273    }
274
275    pub fn with_prompt_percent(mut self, enable: bool) -> Self {
276        self.prompt_percent = enable;
277        self
278    }
279
280    pub fn with_prompt_bang(mut self, enable: bool) -> Self {
281        self.prompt_bang = enable;
282        self
283    }
284
285    fn peek(&self) -> Option<char> {
286        self.input[self.pos..].chars().next()
287    }
288
289    fn advance(&mut self) -> Option<char> {
290        let c = self.peek()?;
291        self.pos += c.len_utf8();
292        Some(c)
293    }
294
295    fn parse_number(&mut self) -> Option<i32> {
296        let start = self.pos;
297        let mut negative = false;
298
299        if self.peek() == Some('-') {
300            negative = true;
301            self.advance();
302        }
303
304        while let Some(c) = self.peek() {
305            if c.is_ascii_digit() {
306                self.advance();
307            } else {
308                break;
309            }
310        }
311
312        if self.pos == start || (negative && self.pos == start + 1) {
313            if negative {
314                self.pos = start;
315            }
316            return None;
317        }
318
319        let num_str = &self.input[if negative { start + 1 } else { start }..self.pos];
320        let num: i32 = num_str.parse().ok()?;
321        Some(if negative { -num } else { num })
322    }
323
324    fn parse_braced_arg(&mut self) -> Option<String> {
325        if self.peek() != Some('{') {
326            return None;
327        }
328        self.advance(); // skip {
329
330        let start = self.pos;
331        let mut depth = 1;
332
333        while let Some(c) = self.advance() {
334            match c {
335                '{' => depth += 1,
336                '}' => {
337                    depth -= 1;
338                    if depth == 0 {
339                        return Some(self.input[start..self.pos - 1].to_string());
340                    }
341                }
342                '\\' => {
343                    self.advance(); // skip escaped char
344                }
345                _ => {}
346            }
347        }
348
349        None
350    }
351
352    /// Get path with tilde substitution
353    fn path_with_tilde(&self, path: &str) -> String {
354        if !self.ctx.home.is_empty() && path.starts_with(&self.ctx.home) {
355            format!("~{}", &path[self.ctx.home.len()..])
356        } else {
357            path.to_string()
358        }
359    }
360
361    /// Get trailing path components
362    fn trailing_path(&self, path: &str, n: usize, with_tilde: bool) -> String {
363        let path = if with_tilde {
364            self.path_with_tilde(path)
365        } else {
366            path.to_string()
367        };
368
369        if n == 0 {
370            return path;
371        }
372
373        let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
374        if components.len() <= n {
375            return path;
376        }
377
378        components[components.len() - n..].join("/")
379    }
380
381    /// Get leading path components
382    fn leading_path(&self, path: &str, n: usize) -> String {
383        if n == 0 {
384            return path.to_string();
385        }
386
387        let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
388        if components.len() <= n {
389            return path.to_string();
390        }
391
392        let result = components[..n].join("/");
393        if path.starts_with('/') {
394            format!("/{}", result)
395        } else {
396            result
397        }
398    }
399
400    /// Start escape sequence (non-printing characters)
401    fn start_escape(&mut self) {
402        if !self.in_escape {
403            self.output.push('\x01'); // RL_PROMPT_START_IGNORE
404            self.in_escape = true;
405        }
406    }
407
408    /// End escape sequence
409    fn end_escape(&mut self) {
410        if self.in_escape {
411            self.output.push('\x02'); // RL_PROMPT_END_IGNORE
412            self.in_escape = false;
413        }
414    }
415
416    /// Apply text attributes
417    fn apply_attrs(&mut self) {
418        self.start_escape();
419
420        // Reset first
421        self.output.push_str("\x1b[0m");
422
423        if self.attrs.bold {
424            self.output.push_str("\x1b[1m");
425        }
426        if self.attrs.underline {
427            self.output.push_str("\x1b[4m");
428        }
429        if self.attrs.standout {
430            self.output.push_str("\x1b[7m");
431        }
432        if let Some(ref color) = self.attrs.fg_color {
433            self.output.push_str(&color.to_ansi_fg());
434        }
435        if let Some(ref color) = self.attrs.bg_color {
436            self.output.push_str(&color.to_ansi_bg());
437        }
438
439        self.end_escape();
440    }
441
442    /// Parse conditional %(x.true.false)
443    fn parse_conditional(&mut self, arg: i32) -> bool {
444        if self.peek() != Some('(') {
445            return false;
446        }
447        self.advance(); // skip (
448
449        // Parse condition character
450        let cond_char = match self.advance() {
451            Some(c) => c,
452            None => return false,
453        };
454
455        // Evaluate condition
456        let test = match cond_char {
457            '/' | 'c' | '.' | '~' | 'C' => {
458                // Directory depth test
459                let path = self.path_with_tilde(&self.ctx.pwd);
460                let depth = path.matches('/').count() as i32;
461                if arg == 0 {
462                    depth > 0
463                } else {
464                    depth >= arg
465                }
466            }
467            '?' => self.ctx.lastval == arg,
468            '#' => {
469                let euid = unsafe { libc::geteuid() };
470                euid == arg as u32
471            }
472            'L' => self.ctx.shlvl >= arg,
473            'j' => self.ctx.num_jobs >= arg,
474            'v' => (arg as usize) <= self.ctx.psvar.len(),
475            'V' => {
476                if arg <= 0 || (arg as usize) > self.ctx.psvar.len() {
477                    false
478                } else {
479                    !self.ctx.psvar[arg as usize - 1].is_empty()
480                }
481            }
482            '_' => self.ctx.cmd_stack.len() >= arg as usize,
483            't' | 'T' | 'd' | 'D' | 'w' => {
484                let now = chrono::Local::now();
485                match cond_char {
486                    't' => now.format("%M").to_string().parse::<i32>().unwrap_or(0) == arg,
487                    'T' => now.format("%H").to_string().parse::<i32>().unwrap_or(0) == arg,
488                    'd' => now.format("%d").to_string().parse::<i32>().unwrap_or(0) == arg,
489                    'D' => now.format("%m").to_string().parse::<i32>().unwrap_or(0) == arg - 1,
490                    'w' => now.format("%w").to_string().parse::<i32>().unwrap_or(0) == arg,
491                    _ => false,
492                }
493            }
494            '!' => self.ctx.is_root,
495            _ => false,
496        };
497
498        // Get separator
499        let sep = match self.advance() {
500            Some(c) => c,
501            None => return false,
502        };
503
504        // Parse true branch
505        let true_start = self.pos;
506        let mut depth = 1;
507        while let Some(c) = self.peek() {
508            if c == '(' {
509                depth += 1;
510            } else if c == ')' {
511                depth -= 1;
512                if depth == 0 {
513                    break;
514                }
515            } else if c == sep && depth == 1 {
516                break;
517            }
518            self.advance();
519        }
520        let true_branch = &self.input[true_start..self.pos].to_string();
521
522        if self.peek() != Some(sep) {
523            return false;
524        }
525        self.advance(); // skip separator
526
527        // Parse false branch
528        let false_start = self.pos;
529        depth = 1;
530        while let Some(c) = self.peek() {
531            if c == '(' {
532                depth += 1;
533            } else if c == ')' {
534                depth -= 1;
535                if depth == 0 {
536                    break;
537                }
538            }
539            self.advance();
540        }
541        let false_branch = &self.input[false_start..self.pos].to_string();
542
543        if self.peek() != Some(')') {
544            return false;
545        }
546        self.advance(); // skip )
547
548        // Expand the appropriate branch
549        let branch = if test { true_branch } else { false_branch };
550        let expanded = expand_prompt(branch, self.ctx);
551        self.output.push_str(&expanded);
552
553        true
554    }
555
556    /// Parse and process a % escape sequence
557    fn process_percent(&mut self) {
558        let arg = self.parse_number().unwrap_or(0);
559
560        // Check for conditional
561        if self.peek() == Some('(') {
562            self.parse_conditional(arg);
563            return;
564        }
565
566        let c = match self.advance() {
567            Some(c) => c,
568            None => return,
569        };
570
571        match c {
572            // Directory
573            '~' => {
574                let path = if arg == 0 {
575                    self.path_with_tilde(&self.ctx.pwd)
576                } else if arg > 0 {
577                    self.trailing_path(&self.ctx.pwd, arg as usize, true)
578                } else {
579                    self.leading_path(&self.path_with_tilde(&self.ctx.pwd), (-arg) as usize)
580                };
581                self.output.push_str(&path);
582            }
583            'd' | '/' => {
584                let path = if arg == 0 {
585                    self.ctx.pwd.clone()
586                } else if arg > 0 {
587                    self.trailing_path(&self.ctx.pwd, arg as usize, false)
588                } else {
589                    self.leading_path(&self.ctx.pwd, (-arg) as usize)
590                };
591                self.output.push_str(&path);
592            }
593            'c' | '.' => {
594                let n = if arg == 0 {
595                    1
596                } else {
597                    arg.unsigned_abs() as usize
598                };
599                let path = self.trailing_path(&self.ctx.pwd, n, true);
600                self.output.push_str(&path);
601            }
602            'C' => {
603                let n = if arg == 0 {
604                    1
605                } else {
606                    arg.unsigned_abs() as usize
607                };
608                let path = self.trailing_path(&self.ctx.pwd, n, false);
609                self.output.push_str(&path);
610            }
611
612            // User/host
613            'n' => self.output.push_str(&self.ctx.user),
614            'M' => self.output.push_str(&self.ctx.host),
615            'm' => {
616                let n = if arg == 0 { 1 } else { arg };
617                if n > 0 {
618                    let parts: Vec<&str> = self.ctx.host.split('.').collect();
619                    let take = (n as usize).min(parts.len());
620                    self.output.push_str(&parts[..take].join("."));
621                } else {
622                    let parts: Vec<&str> = self.ctx.host.split('.').collect();
623                    let skip = ((-n) as usize).min(parts.len());
624                    self.output.push_str(&parts[skip..].join("."));
625                }
626            }
627
628            // TTY
629            'l' => {
630                let tty = if self.ctx.tty.starts_with("/dev/tty") {
631                    &self.ctx.tty[8..]
632                } else if self.ctx.tty.starts_with("/dev/") {
633                    &self.ctx.tty[5..]
634                } else {
635                    "()"
636                };
637                self.output.push_str(tty);
638            }
639            'y' => {
640                let tty = if self.ctx.tty.starts_with("/dev/") {
641                    &self.ctx.tty[5..]
642                } else {
643                    &self.ctx.tty
644                };
645                self.output.push_str(tty);
646            }
647
648            // Status
649            '?' => self.output.push_str(&self.ctx.lastval.to_string()),
650            '#' => self.output.push(if self.ctx.is_root { '#' } else { '%' }),
651
652            // History
653            'h' | '!' => self.output.push_str(&self.ctx.histnum.to_string()),
654
655            // Jobs
656            'j' => self.output.push_str(&self.ctx.num_jobs.to_string()),
657
658            // Shell level
659            'L' => self.output.push_str(&self.ctx.shlvl.to_string()),
660
661            // Line number
662            'i' => self.output.push_str(&self.ctx.lineno.to_string()),
663
664            // Date/time
665            'D' => {
666                let now = chrono::Local::now();
667                if let Some(fmt) = self.parse_braced_arg() {
668                    let zsh_fmt = convert_zsh_time_format(&fmt);
669                    self.output.push_str(&now.format(&zsh_fmt).to_string());
670                } else {
671                    self.output.push_str(&now.format("%y-%m-%d").to_string());
672                }
673            }
674            'T' => {
675                let now = chrono::Local::now();
676                self.output.push_str(&now.format("%H:%M").to_string());
677            }
678            '*' => {
679                let now = chrono::Local::now();
680                self.output.push_str(&now.format("%H:%M:%S").to_string());
681            }
682            't' | '@' => {
683                let now = chrono::Local::now();
684                self.output.push_str(&now.format("%l:%M%p").to_string());
685            }
686            'w' => {
687                let now = chrono::Local::now();
688                self.output.push_str(&now.format("%a %e").to_string());
689            }
690            'W' => {
691                let now = chrono::Local::now();
692                self.output.push_str(&now.format("%m/%d/%y").to_string());
693            }
694
695            // Text attributes
696            'B' => {
697                self.attrs.bold = true;
698                self.apply_attrs();
699            }
700            'b' => {
701                self.attrs.bold = false;
702                self.apply_attrs();
703            }
704            'U' => {
705                self.attrs.underline = true;
706                self.apply_attrs();
707            }
708            'u' => {
709                self.attrs.underline = false;
710                self.apply_attrs();
711            }
712            'S' => {
713                self.attrs.standout = true;
714                self.apply_attrs();
715            }
716            's' => {
717                self.attrs.standout = false;
718                self.apply_attrs();
719            }
720
721            // Colors
722            'F' => {
723                let color = if let Some(name) = self.parse_braced_arg() {
724                    Color::from_name(&name)
725                } else if arg > 0 {
726                    Some(Color::Numbered(arg as u8))
727                } else {
728                    None
729                };
730                if let Some(c) = color {
731                    self.attrs.fg_color = Some(c);
732                    self.apply_attrs();
733                }
734            }
735            'f' => {
736                self.attrs.fg_color = None;
737                self.apply_attrs();
738            }
739            'K' => {
740                let color = if let Some(name) = self.parse_braced_arg() {
741                    Color::from_name(&name)
742                } else if arg > 0 {
743                    Some(Color::Numbered(arg as u8))
744                } else {
745                    None
746                };
747                if let Some(c) = color {
748                    self.attrs.bg_color = Some(c);
749                    self.apply_attrs();
750                }
751            }
752            'k' => {
753                self.attrs.bg_color = None;
754                self.apply_attrs();
755            }
756
757            // Literal escape sequences
758            '{' => self.start_escape(),
759            '}' => self.end_escape(),
760
761            // Glitch space
762            'G' => {
763                let n = if arg > 0 { arg as usize } else { 1 };
764                for _ in 0..n {
765                    self.output.push(' ');
766                }
767            }
768
769            // psvar
770            'v' => {
771                let idx = if arg == 0 { 1 } else { arg };
772                if idx > 0 && (idx as usize) <= self.ctx.psvar.len() {
773                    self.output.push_str(&self.ctx.psvar[idx as usize - 1]);
774                }
775            }
776
777            // Command stack
778            '_' => {
779                if !self.ctx.cmd_stack.is_empty() {
780                    let n = if arg == 0 {
781                        self.ctx.cmd_stack.len()
782                    } else if arg > 0 {
783                        (arg as usize).min(self.ctx.cmd_stack.len())
784                    } else {
785                        ((-arg) as usize).min(self.ctx.cmd_stack.len())
786                    };
787
788                    let names: Vec<&str> = if arg >= 0 {
789                        self.ctx
790                            .cmd_stack
791                            .iter()
792                            .rev()
793                            .take(n)
794                            .map(|s| s.name())
795                            .collect()
796                    } else {
797                        self.ctx
798                            .cmd_stack
799                            .iter()
800                            .take(n)
801                            .map(|s| s.name())
802                            .collect()
803                    };
804                    self.output.push_str(&names.join(" "));
805                }
806            }
807
808            // Clear to end of line
809            'E' => {
810                self.start_escape();
811                self.output.push_str("\x1b[K");
812                self.end_escape();
813            }
814
815            // Literal characters
816            '%' => self.output.push('%'),
817            ')' => self.output.push(')'),
818            '\0' => {}
819
820            // Unknown - output literally
821            _ => {
822                self.output.push('%');
823                self.output.push(c);
824            }
825        }
826    }
827
828    /// Expand the prompt
829    pub fn expand(mut self) -> String {
830        while let Some(c) = self.advance() {
831            if c == '%' && self.prompt_percent {
832                self.process_percent();
833            } else if c == '!' && self.prompt_bang {
834                if self.peek() == Some('!') {
835                    self.advance();
836                    self.output.push('!');
837                } else {
838                    self.output.push_str(&self.ctx.histnum.to_string());
839                }
840            } else {
841                self.output.push(c);
842            }
843        }
844
845        // Reset attributes at end
846        if self.attrs.bold
847            || self.attrs.underline
848            || self.attrs.standout
849            || self.attrs.fg_color.is_some()
850            || self.attrs.bg_color.is_some()
851        {
852            self.start_escape();
853            self.output.push_str("\x1b[0m");
854            self.end_escape();
855        }
856
857        self.output
858    }
859}
860
861/// Convert zsh time format to chrono format
862fn convert_zsh_time_format(fmt: &str) -> String {
863    let mut result = String::new();
864    let mut chars = fmt.chars().peekable();
865
866    while let Some(c) = chars.next() {
867        if c == '%' {
868            match chars.next() {
869                Some('a') => result.push_str("%a"),             // weekday abbrev
870                Some('A') => result.push_str("%A"),             // weekday full
871                Some('b') | Some('h') => result.push_str("%b"), // month abbrev
872                Some('B') => result.push_str("%B"),             // month full
873                Some('c') => result.push_str("%c"),             // locale datetime
874                Some('C') => result.push_str("%y"),             // century (use year for simplicity)
875                Some('d') => result.push_str("%d"),             // day of month
876                Some('D') => result.push_str("%m/%d/%y"),       // date
877                Some('e') => result.push_str("%e"),             // day of month, space padded
878                Some('f') => result.push_str("%e"),             // zsh: day of month, no padding
879                Some('F') => result.push_str("%Y-%m-%d"),       // ISO date
880                Some('H') => result.push_str("%H"),             // hour 24
881                Some('I') => result.push_str("%I"),             // hour 12
882                Some('j') => result.push_str("%j"),             // day of year
883                Some('k') => result.push_str("%k"),             // hour 24, space padded
884                Some('K') => result.push_str("%H"),             // zsh: hour 24
885                Some('l') => result.push_str("%l"),             // hour 12, space padded
886                Some('L') => result.push_str("%3f"),            // zsh: milliseconds (approx)
887                Some('m') => result.push_str("%m"),             // month
888                Some('M') => result.push_str("%M"),             // minute
889                Some('n') => result.push('\n'),
890                Some('N') => result.push_str("%9f"), // zsh: nanoseconds (approx)
891                Some('p') => result.push_str("%p"),  // AM/PM
892                Some('P') => result.push_str("%P"),  // am/pm
893                Some('r') => result.push_str("%r"),  // 12-hour time
894                Some('R') => result.push_str("%R"),  // 24-hour time
895                Some('s') => result.push_str("%s"),  // epoch seconds
896                Some('S') => result.push_str("%S"),  // seconds
897                Some('t') => result.push('\t'),
898                Some('T') => result.push_str("%T"), // time
899                Some('u') => result.push_str("%u"), // weekday 1-7
900                Some('U') => result.push_str("%U"), // week of year (Sunday)
901                Some('V') => result.push_str("%V"), // ISO week
902                Some('w') => result.push_str("%w"), // weekday 0-6
903                Some('W') => result.push_str("%W"), // week of year (Monday)
904                Some('x') => result.push_str("%x"), // locale date
905                Some('X') => result.push_str("%X"), // locale time
906                Some('y') => result.push_str("%y"), // year 2-digit
907                Some('Y') => result.push_str("%Y"), // year 4-digit
908                Some('z') => result.push_str("%z"), // timezone offset
909                Some('Z') => result.push_str("%Z"), // timezone name
910                Some('%') => result.push('%'),
911                Some(other) => {
912                    result.push('%');
913                    result.push(other);
914                }
915                None => result.push('%'),
916            }
917        } else {
918            result.push(c);
919        }
920    }
921
922    result
923}
924
925/// Expand a prompt string
926pub fn expand_prompt(s: &str, ctx: &PromptContext) -> String {
927    PromptExpander::new(s, ctx).expand()
928}
929
930/// Expand a prompt string with default context
931pub fn expand_prompt_default(s: &str) -> String {
932    let ctx = PromptContext::default();
933    expand_prompt(s, &ctx)
934}
935
936/// Count the visible width of an expanded prompt (ignoring escape sequences)
937pub fn prompt_width(s: &str) -> usize {
938    let mut width = 0;
939    let mut in_escape = false;
940    let mut chars = s.chars().peekable();
941
942    while let Some(c) = chars.next() {
943        match c {
944            '\x01' => in_escape = true,  // RL_PROMPT_START_IGNORE
945            '\x02' => in_escape = false, // RL_PROMPT_END_IGNORE
946            '\x1b' => {
947                // ANSI escape - skip until 'm' or end
948                while let Some(&next) = chars.peek() {
949                    chars.next();
950                    if next == 'm' {
951                        break;
952                    }
953                }
954            }
955            _ if !in_escape => {
956                width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
957            }
958            _ => {}
959        }
960    }
961
962    width
963}
964
965// ---------------------------------------------------------------------------
966// Missing functions from prompt.c
967// ---------------------------------------------------------------------------
968
969/// Truncate prompt to max width (from prompt.c prompttrunc)
970///
971/// Supports: %N>string> (right truncate) and %N<string< (left truncate)
972/// N is the max width, string is the replacement indicator (default "...")
973pub fn prompt_truncate(s: &str, max_width: usize, from_right: bool, indicator: &str) -> String {
974    let visible_len = prompt_width(s);
975    if visible_len <= max_width {
976        return s.to_string();
977    }
978
979    let ind_len = indicator.len();
980    if max_width <= ind_len {
981        return indicator[..max_width.min(ind_len)].to_string();
982    }
983
984    let keep = max_width - ind_len;
985
986    if from_right {
987        // Keep the left part: "long text..."
988        let mut result = String::new();
989        let mut width = 0;
990        for c in s.chars() {
991            let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
992            if width + cw > keep {
993                break;
994            }
995            result.push(c);
996            width += cw;
997        }
998        result.push_str(indicator);
999        result
1000    } else {
1001        // Keep the right part: "...ng text"
1002        let chars: Vec<char> = s.chars().collect();
1003        let total_chars = chars.len();
1004        let mut width = 0;
1005        let mut start = total_chars;
1006        for i in (0..total_chars).rev() {
1007            let cw = unicode_width::UnicodeWidthChar::width(chars[i]).unwrap_or(1);
1008            if width + cw > keep {
1009                break;
1010            }
1011            width += cw;
1012            start = i;
1013        }
1014        let mut result = indicator.to_string();
1015        for &c in &chars[start..] {
1016            result.push(c);
1017        }
1018        result
1019    }
1020}
1021
1022/// Count visible prompt characters and compute cursor position
1023/// (from prompt.c countprompt)
1024pub fn countprompt(s: &str) -> (usize, usize) {
1025    let width = prompt_width(s);
1026    let lines = s.chars().filter(|&c| c == '\n').count();
1027    (width, lines)
1028}
1029
1030/// Command stack operations for %_ (from prompt.c cmdpush/cmdpop)
1031pub struct CmdStack {
1032    stack: Vec<CmdState>,
1033}
1034
1035impl CmdStack {
1036    pub fn new() -> Self {
1037        CmdStack { stack: Vec::new() }
1038    }
1039
1040    pub fn push(&mut self, state: CmdState) {
1041        self.stack.push(state);
1042    }
1043
1044    pub fn pop(&mut self) -> Option<CmdState> {
1045        self.stack.pop()
1046    }
1047
1048    pub fn top(&self) -> Option<&CmdState> {
1049        self.stack.last()
1050    }
1051
1052    pub fn depth(&self) -> usize {
1053        self.stack.len()
1054    }
1055
1056    pub fn as_slice(&self) -> &[CmdState] {
1057        &self.stack
1058    }
1059}
1060
1061impl Default for CmdStack {
1062    fn default() -> Self {
1063        Self::new()
1064    }
1065}
1066
1067/// Parse a color name to ANSI code (from prompt.c match_named_colour)
1068pub fn match_named_colour(name: &str) -> Option<u8> {
1069    match name.to_lowercase().as_str() {
1070        "black" => Some(0),
1071        "red" => Some(1),
1072        "green" => Some(2),
1073        "yellow" => Some(3),
1074        "blue" => Some(4),
1075        "magenta" => Some(5),
1076        "cyan" => Some(6),
1077        "white" => Some(7),
1078        "default" => Some(9),
1079        _ => name.parse::<u8>().ok(),
1080    }
1081}
1082
1083/// Output a colour escape sequence (from prompt.c output_colour)
1084pub fn output_colour(colour: u8, is_fg: bool) -> String {
1085    let base = if is_fg { 30 } else { 40 };
1086    if colour < 8 {
1087        format!("\x1b[{}m", base + colour)
1088    } else if colour < 16 {
1089        format!("\x1b[{};1m", base + colour - 8)
1090    } else {
1091        let mode = if is_fg { 38 } else { 48 };
1092        format!("\x1b[{};5;{}m", mode, colour)
1093    }
1094}
1095
1096/// Output true color (24-bit) escape sequence
1097pub fn output_truecolor(r: u8, g: u8, b: u8, is_fg: bool) -> String {
1098    let mode = if is_fg { 38 } else { 48 };
1099    format!("\x1b[{};2;{};{};{}m", mode, r, g, b)
1100}
1101
1102/// Parse highlight specification (from prompt.c parsehighlight)
1103pub fn parsehighlight(spec: &str) -> TextAttrs {
1104    let mut attrs = TextAttrs::default();
1105    for part in spec.split(',') {
1106        let part = part.trim();
1107        match part {
1108            "bold" => attrs.bold = true,
1109            "underline" => attrs.underline = true,
1110            "standout" => attrs.standout = true,
1111            "none" => {
1112                attrs = TextAttrs::default();
1113            }
1114            s if s.starts_with("fg=") => {
1115                let color_name = &s[3..];
1116                if let Some(code) = match_named_colour(color_name) {
1117                    attrs.fg_color = Some(Color::Numbered(code));
1118                }
1119            }
1120            s if s.starts_with("bg=") => {
1121                let color_name = &s[3..];
1122                if let Some(code) = match_named_colour(color_name) {
1123                    attrs.bg_color = Some(Color::Numbered(code));
1124                }
1125            }
1126            _ => {}
1127        }
1128    }
1129    attrs
1130}
1131
1132/// Apply text attributes as ANSI escape sequences (from prompt.c applytextattributes)
1133pub fn apply_text_attributes(attrs: &TextAttrs) -> String {
1134    let mut codes = Vec::new();
1135    if attrs.bold {
1136        codes.push("1");
1137    }
1138    if attrs.underline {
1139        codes.push("4");
1140    }
1141    if attrs.standout {
1142        codes.push("7");
1143    }
1144    let fg_code;
1145    if let Some(ref color) = attrs.fg_color {
1146        fg_code = color.to_ansi_fg();
1147        codes.push(&fg_code);
1148    }
1149    let bg_code;
1150    if let Some(ref color) = attrs.bg_color {
1151        bg_code = color.to_ansi_bg();
1152        codes.push(&bg_code);
1153    }
1154    if codes.is_empty() {
1155        String::new()
1156    } else {
1157        format!("\x1b[{}m", codes.join(";"))
1158    }
1159}
1160
1161/// Reset all text attributes
1162pub fn reset_text_attributes() -> &'static str {
1163    "\x1b[0m"
1164}
1165
1166/// Set default colour sequences (from prompt.c set_default_colour_sequences)
1167pub fn set_default_colour_sequences() -> (String, String) {
1168    // Default: use ANSI sequences
1169    ("\x1b[0m".to_string(), "\x1b[0m".to_string())
1170}
1171
1172/// Right prompt handling - compute padding for RPROMPT
1173pub fn right_prompt_padding(
1174    left_width: usize,
1175    right_prompt: &str,
1176    term_width: usize,
1177    indent: usize,
1178) -> Option<String> {
1179    let right_width = prompt_width(right_prompt);
1180    let total = left_width + right_width + indent;
1181    if total >= term_width {
1182        return None; // No room for right prompt
1183    }
1184    let padding = term_width - total;
1185    Some(" ".repeat(padding))
1186}
1187
1188/// Transient prompt - return empty string to clear prompt on accept-line
1189pub fn transient_prompt(_original: &str) -> String {
1190    String::new()
1191}
1192
1193// ---------------------------------------------------------------------------
1194// Remaining missing functions from prompt.c
1195// ---------------------------------------------------------------------------
1196
1197/// Get prompt path with tilde substitution (from prompt.c promptpath)
1198pub fn promptpath(path: &str, npath: usize, tilde: bool, home: &str) -> String {
1199    let display = if tilde && !home.is_empty() && path.starts_with(home) {
1200        let rest = &path[home.len()..];
1201        if rest.is_empty() || rest.starts_with('/') {
1202            format!("~{}", rest)
1203        } else {
1204            path.to_string()
1205        }
1206    } else {
1207        path.to_string()
1208    };
1209
1210    if npath == 0 {
1211        return display;
1212    }
1213
1214    // Take last npath components
1215    let components: Vec<&str> = display.split('/').filter(|s| !s.is_empty()).collect();
1216    if components.len() <= npath {
1217        return display;
1218    }
1219    components[components.len() - npath..].join("/")
1220}
1221
1222/// Full prompt expansion with namespace marker support (from prompt.c promptexpand)
1223pub fn promptexpand(s: &str, ctx: &PromptContext) -> String {
1224    expand_prompt(s, ctx)
1225}
1226
1227/// Escape attributes to string (from prompt.c zattrescape)
1228pub fn zattrescape(attrs: &TextAttrs) -> String {
1229    let mut result = String::new();
1230    if attrs.bold {
1231        result.push_str("%B");
1232    }
1233    if attrs.underline {
1234        result.push_str("%U");
1235    }
1236    if attrs.standout {
1237        result.push_str("%S");
1238    }
1239    if let Some(ref color) = attrs.fg_color {
1240        result.push_str(&format!("%F{{{}}}", color_name(color)));
1241    }
1242    if let Some(ref color) = attrs.bg_color {
1243        result.push_str(&format!("%K{{{}}}", color_name(color)));
1244    }
1245    result
1246}
1247
1248fn color_name(c: &Color) -> String {
1249    match c {
1250        Color::Black => "black".to_string(),
1251        Color::Red => "red".to_string(),
1252        Color::Green => "green".to_string(),
1253        Color::Yellow => "yellow".to_string(),
1254        Color::Blue => "blue".to_string(),
1255        Color::Magenta => "magenta".to_string(),
1256        Color::Cyan => "cyan".to_string(),
1257        Color::White => "white".to_string(),
1258        Color::Default => "default".to_string(),
1259        Color::Numbered(n) => n.to_string(),
1260        Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
1261    }
1262}
1263
1264/// Parse color character from number or name (from prompt.c parsecolorchar)
1265pub fn parsecolorchar(arg: &str, is_fg: bool) -> Option<(Color, String)> {
1266    let color = Color::from_name(arg)?;
1267    let ansi = if is_fg {
1268        color.to_ansi_fg()
1269    } else {
1270        color.to_ansi_bg()
1271    };
1272    Some((color, ansi))
1273}
1274
1275/// Internal prompt char output (from prompt.c pputc)
1276/// In Rust, this is handled by the PromptExpander writing to its output buffer
1277pub fn pputc(buf: &mut String, c: char) {
1278    buf.push(c);
1279}
1280
1281/// Ensure buffer has space (from prompt.c addbufspc)
1282/// No-op in Rust since String grows automatically
1283pub fn addbufspc(_buf: &mut String, _need: usize) {
1284    // Rust String handles allocation automatically
1285}
1286
1287/// Add string to prompt buffer (from prompt.c stradd)
1288pub fn stradd(buf: &mut String, s: &str) {
1289    buf.push_str(s);
1290}
1291
1292/// Set terminal capability (from prompt.c tsetcap)
1293pub fn tsetcap(cap: &str) -> String {
1294    // Map common capability names to ANSI sequences
1295    match cap {
1296        "md" | "bold" => "\x1b[1m".to_string(),
1297        "me" | "sgr0" => "\x1b[0m".to_string(),
1298        "so" | "smso" => "\x1b[7m".to_string(),
1299        "se" | "rmso" => "\x1b[27m".to_string(),
1300        "us" | "smul" => "\x1b[4m".to_string(),
1301        "ue" | "rmul" => "\x1b[24m".to_string(),
1302        _ => String::new(),
1303    }
1304}
1305
1306/// Put string from capability (from prompt.c putstr)
1307pub fn putstr(cap: &str) -> String {
1308    tsetcap(cap)
1309}
1310
1311/// Replace text attributes (from prompt.c treplaceattrs)
1312pub fn treplaceattrs(old: &TextAttrs, new: &TextAttrs) -> String {
1313    let mut result = String::new();
1314
1315    // Reset if removing attributes
1316    let need_reset = (old.bold && !new.bold)
1317        || (old.underline && !new.underline)
1318        || (old.standout && !new.standout);
1319
1320    if need_reset {
1321        result.push_str("\x1b[0m");
1322        // Re-apply what's still on
1323        if new.bold {
1324            result.push_str("\x1b[1m");
1325        }
1326        if new.underline {
1327            result.push_str("\x1b[4m");
1328        }
1329        if new.standout {
1330            result.push_str("\x1b[7m");
1331        }
1332    } else {
1333        // Just add new attributes
1334        if !old.bold && new.bold {
1335            result.push_str("\x1b[1m");
1336        }
1337        if !old.underline && new.underline {
1338            result.push_str("\x1b[4m");
1339        }
1340        if !old.standout && new.standout {
1341            result.push_str("\x1b[7m");
1342        }
1343    }
1344
1345    // Handle color changes
1346    if old.fg_color != new.fg_color {
1347        if let Some(ref color) = new.fg_color {
1348            result.push_str(&color.to_ansi_fg());
1349        } else {
1350            result.push_str("\x1b[39m"); // default fg
1351        }
1352    }
1353    if old.bg_color != new.bg_color {
1354        if let Some(ref color) = new.bg_color {
1355            result.push_str(&color.to_ansi_bg());
1356        } else {
1357            result.push_str("\x1b[49m"); // default bg
1358        }
1359    }
1360
1361    result
1362}
1363
1364/// Set text attributes (from prompt.c tsetattrs)
1365pub fn tsetattrs(attrs: &TextAttrs) -> String {
1366    apply_text_attributes(attrs)
1367}
1368
1369/// Unset text attributes (from prompt.c tunsetattrs)
1370pub fn tunsetattrs(attrs: &TextAttrs) -> String {
1371    let mut result = String::new();
1372    if attrs.bold {
1373        result.push_str("\x1b[22m");
1374    }
1375    if attrs.underline {
1376        result.push_str("\x1b[24m");
1377    }
1378    if attrs.standout {
1379        result.push_str("\x1b[27m");
1380    }
1381    if attrs.fg_color.is_some() {
1382        result.push_str("\x1b[39m");
1383    }
1384    if attrs.bg_color.is_some() {
1385        result.push_str("\x1b[49m");
1386    }
1387    result
1388}
1389
1390/// Match colour by name or number (from prompt.c match_colour)
1391pub fn match_colour(spec: &str, is_fg: bool) -> Option<String> {
1392    // Try named colour
1393    if let Some(code) = match_named_colour(spec) {
1394        return Some(output_colour(code, is_fg));
1395    }
1396    // Try #RRGGBB
1397    if spec.starts_with('#') && spec.len() == 7 {
1398        let r = u8::from_str_radix(&spec[1..3], 16).ok()?;
1399        let g = u8::from_str_radix(&spec[3..5], 16).ok()?;
1400        let b = u8::from_str_radix(&spec[5..7], 16).ok()?;
1401        return Some(output_truecolor(r, g, b, is_fg));
1402    }
1403    // Try number
1404    if let Ok(n) = spec.parse::<u8>() {
1405        return Some(output_colour(n, is_fg));
1406    }
1407    None
1408}
1409
1410/// Match highlight specification (from prompt.c match_highlight)
1411pub fn match_highlight(spec: &str) -> (TextAttrs, TextAttrs) {
1412    let attrs = parsehighlight(spec);
1413    let mask = TextAttrs {
1414        bold: attrs.bold,
1415        underline: attrs.underline,
1416        standout: attrs.standout,
1417        fg_color: if attrs.fg_color.is_some() {
1418            Some(Color::Default)
1419        } else {
1420            None
1421        },
1422        bg_color: if attrs.bg_color.is_some() {
1423            Some(Color::Default)
1424        } else {
1425            None
1426        },
1427    };
1428    (attrs, mask)
1429}
1430
1431/// Output highlight attributes as escape string (from prompt.c output_highlight)
1432pub fn output_highlight(attrs: &TextAttrs) -> String {
1433    apply_text_attributes(attrs)
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439
1440    fn test_ctx() -> PromptContext {
1441        PromptContext {
1442            pwd: "/home/user/projects/test".to_string(),
1443            home: "/home/user".to_string(),
1444            user: "testuser".to_string(),
1445            host: "myhost.example.com".to_string(),
1446            host_short: "myhost".to_string(),
1447            tty: "/dev/pts/0".to_string(),
1448            lastval: 0,
1449            histnum: 42,
1450            shlvl: 2,
1451            num_jobs: 1,
1452            is_root: false,
1453            cmd_stack: vec![],
1454            psvar: vec!["one".to_string(), "two".to_string()],
1455            term_width: 80,
1456            lineno: 10,
1457        }
1458    }
1459
1460    #[test]
1461    fn test_directory() {
1462        let ctx = test_ctx();
1463        assert_eq!(expand_prompt("%~", &ctx), "~/projects/test");
1464        assert_eq!(expand_prompt("%/", &ctx), "/home/user/projects/test");
1465        assert_eq!(expand_prompt("%d", &ctx), "/home/user/projects/test");
1466        assert_eq!(expand_prompt("%1~", &ctx), "test");
1467        assert_eq!(expand_prompt("%2~", &ctx), "projects/test");
1468        assert_eq!(expand_prompt("%c", &ctx), "test");
1469        assert_eq!(expand_prompt("%2c", &ctx), "projects/test");
1470    }
1471
1472    #[test]
1473    fn test_user_host() {
1474        let ctx = test_ctx();
1475        assert_eq!(expand_prompt("%n", &ctx), "testuser");
1476        assert_eq!(expand_prompt("%M", &ctx), "myhost.example.com");
1477        assert_eq!(expand_prompt("%m", &ctx), "myhost");
1478        assert_eq!(expand_prompt("%2m", &ctx), "myhost.example");
1479    }
1480
1481    #[test]
1482    fn test_status() {
1483        let mut ctx = test_ctx();
1484        ctx.lastval = 127;
1485        assert_eq!(expand_prompt("%?", &ctx), "127");
1486        assert_eq!(expand_prompt("%#", &ctx), "%");
1487    }
1488
1489    #[test]
1490    fn test_history() {
1491        let ctx = test_ctx();
1492        assert_eq!(expand_prompt("%h", &ctx), "42");
1493        assert_eq!(expand_prompt("%!", &ctx), "42");
1494    }
1495
1496    #[test]
1497    fn test_misc() {
1498        let ctx = test_ctx();
1499        assert_eq!(expand_prompt("%L", &ctx), "2");
1500        assert_eq!(expand_prompt("%j", &ctx), "1");
1501        assert_eq!(expand_prompt("%i", &ctx), "10");
1502        assert_eq!(expand_prompt("%%", &ctx), "%");
1503    }
1504
1505    #[test]
1506    fn test_psvar() {
1507        let ctx = test_ctx();
1508        assert_eq!(expand_prompt("%v", &ctx), "one");
1509        assert_eq!(expand_prompt("%1v", &ctx), "one");
1510        assert_eq!(expand_prompt("%2v", &ctx), "two");
1511        assert_eq!(expand_prompt("%3v", &ctx), ""); // out of bounds
1512    }
1513
1514    #[test]
1515    fn test_conditional() {
1516        let mut ctx = test_ctx();
1517        ctx.lastval = 0;
1518        assert_eq!(expand_prompt("%(?.ok.fail)", &ctx), "ok");
1519        ctx.lastval = 1;
1520        assert_eq!(expand_prompt("%(?.ok.fail)", &ctx), "fail");
1521    }
1522
1523    #[test]
1524    fn test_time_format() {
1525        let fmt = convert_zsh_time_format("%Y-%m-%d %H:%M:%S");
1526        assert_eq!(fmt, "%Y-%m-%d %H:%M:%S");
1527    }
1528
1529    #[test]
1530    fn test_bang_expansion() {
1531        let ctx = test_ctx();
1532        let exp = PromptExpander::new("cmd !!", &ctx).with_prompt_bang(true);
1533        assert_eq!(exp.expand(), "cmd !");
1534
1535        let exp2 = PromptExpander::new("cmd !", &ctx).with_prompt_bang(true);
1536        assert_eq!(exp2.expand(), "cmd 42");
1537    }
1538}
1539
1540// ---------------------------------------------------------------------------
1541// Remaining 7 missing prompt.c functions
1542// ---------------------------------------------------------------------------
1543
1544/// Core character-by-character prompt renderer (from prompt.c putpromptchar)
1545///
1546/// This is the main 600-line function in C that processes each % escape.
1547/// In Rust, this is implemented as PromptExpander::expand() which handles
1548/// all % sequences. This wrapper provides the C-compatible entry point.
1549pub fn putpromptchar(c: char, ctx: &PromptContext, buf: &mut String) {
1550    if c == '%' {
1551        // The full handling is in PromptExpander::expand()
1552        // This function is called character by character in C
1553        // but in Rust we process the whole string at once
1554        buf.push(c);
1555    } else {
1556        buf.push(c);
1557    }
1558}
1559
1560/// Mix two sets of text attributes (from prompt.c mixattrs)
1561///
1562/// Combines primary and secondary attributes using a mask.
1563/// Attributes set in primary take precedence; unset ones fall through to secondary.
1564pub fn mixattrs(primary: &TextAttrs, mask: &TextAttrs, secondary: &TextAttrs) -> TextAttrs {
1565    TextAttrs {
1566        bold: if mask.bold {
1567            primary.bold
1568        } else {
1569            secondary.bold
1570        },
1571        underline: if mask.underline {
1572            primary.underline
1573        } else {
1574            secondary.underline
1575        },
1576        standout: if mask.standout {
1577            primary.standout
1578        } else {
1579            secondary.standout
1580        },
1581        fg_color: if mask.fg_color.is_some() {
1582            primary.fg_color.clone()
1583        } else {
1584            secondary.fg_color.clone()
1585        },
1586        bg_color: if mask.bg_color.is_some() {
1587            primary.bg_color.clone()
1588        } else {
1589            secondary.bg_color.clone()
1590        },
1591    }
1592}
1593
1594/// Detect if terminal supports true color (from prompt.c truecolor_terminal)
1595pub fn truecolor_terminal() -> bool {
1596    // Check COLORTERM environment variable
1597    if let Ok(ct) = std::env::var("COLORTERM") {
1598        if ct == "truecolor" || ct == "24bit" {
1599            return true;
1600        }
1601    }
1602    // Check TERM for known truecolor terminals
1603    if let Ok(term) = std::env::var("TERM") {
1604        if term.contains("256color") || term.contains("direct") || term.contains("kitty") {
1605            return true;
1606        }
1607    }
1608    false
1609}
1610
1611/// Set a colour code string from specification (from prompt.c set_colour_code)
1612pub fn set_colour_code(spec: &str) -> Option<String> {
1613    match_colour(spec, true)
1614}
1615
1616/// Allocate colour buffer (from prompt.c allocate_colour_buffer) - no-op in Rust
1617pub fn allocate_colour_buffer() {
1618    // Rust String handles allocation automatically
1619}
1620
1621/// Free colour buffer (from prompt.c free_colour_buffer) - no-op in Rust
1622pub fn free_colour_buffer() {
1623    // Rust Drop handles this
1624}
1625
1626/// Set a colour attribute from parsed value (from prompt.c set_colour_attribute)
1627pub fn set_colour_attribute(color: &Color, is_fg: bool) -> String {
1628    if is_fg {
1629        color.to_ansi_fg()
1630    } else {
1631        color.to_ansi_bg()
1632    }
1633}