Skip to main content

zsh/
text.rs

1//! Textual representations of syntax trees for zshrs
2//!
3//! Direct port from zsh/Src/text.c
4//!
5//! Converts parsed shell commands back to their textual representation.
6//! Used for:
7//! - Displaying function definitions (`type -f`)
8//! - Job text (`jobs` command)
9//! - History expansion
10//! - Debugging output
11
12use crate::parser::{
13    CaseTerminator, CompoundCommand, CondExpr, ListOp, Redirect, RedirectOp, ShellCommand,
14    ShellWord, SimpleCommand,
15};
16
17/// Binary operators in conditions (order matches COND_STREQ et seq.)
18pub static COND_BINARY_OPS: &[&str] = &[
19    "=", "==", "!=", "<", ">", "-nt", "-ot", "-ef", "-eq", "-ne", "-lt", "-gt", "-le", "-ge", "=~",
20];
21
22/// Check if a string is a condition binary operator
23pub fn is_cond_binary_op(s: &str) -> bool {
24    COND_BINARY_OPS.contains(&s)
25}
26
27/// Text formatter configuration
28#[derive(Debug, Clone)]
29pub struct TextConfig {
30    /// Expand tabs to this many spaces (0 = use actual tabs)
31    pub expand_tabs: i32,
32    /// Include newlines (false = single line with semicolons)
33    pub newlines: bool,
34    /// Is job text (abbreviated output)
35    pub is_job: bool,
36    /// Maximum output size (for job text)
37    pub max_size: Option<usize>,
38}
39
40impl Default for TextConfig {
41    fn default() -> Self {
42        TextConfig {
43            expand_tabs: 0,
44            newlines: true,
45            is_job: false,
46            max_size: None,
47        }
48    }
49}
50
51impl TextConfig {
52    pub fn job_text() -> Self {
53        TextConfig {
54            expand_tabs: 0,
55            newlines: false,
56            is_job: true,
57            max_size: Some(80),
58        }
59    }
60
61    pub fn single_line() -> Self {
62        TextConfig {
63            expand_tabs: -1,
64            newlines: false,
65            is_job: false,
66            max_size: None,
67        }
68    }
69}
70
71/// Text formatter for shell commands
72pub struct TextFormatter {
73    config: TextConfig,
74    buffer: String,
75    indent: usize,
76    pending: Option<String>,
77}
78
79impl TextFormatter {
80    pub fn new(config: TextConfig) -> Self {
81        TextFormatter {
82            config,
83            buffer: String::with_capacity(256),
84            indent: 0,
85            pending: None,
86        }
87    }
88
89    pub fn with_indent(mut self, indent: usize) -> Self {
90        self.indent = indent;
91        self
92    }
93
94    /// Format a command and return the text
95    pub fn format(mut self, cmd: &ShellCommand) -> String {
96        self.format_command(cmd);
97        self.flush_pending();
98        self.buffer
99    }
100
101    /// Format a list of commands
102    pub fn format_list(mut self, cmds: &[ShellCommand]) -> String {
103        for (i, cmd) in cmds.iter().enumerate() {
104            if i > 0 {
105                self.add_separator();
106            }
107            self.format_command(cmd);
108        }
109        self.flush_pending();
110        self.buffer
111    }
112
113    fn add_char(&mut self, c: char) {
114        if let Some(max) = self.config.max_size {
115            if self.buffer.len() >= max {
116                return;
117            }
118        }
119        self.buffer.push(c);
120    }
121
122    fn add_str(&mut self, s: &str) {
123        if let Some(max) = self.config.max_size {
124            if self.buffer.len() >= max {
125                return;
126            }
127            let remaining = max - self.buffer.len();
128            if s.len() > remaining {
129                self.buffer.push_str(&s[..remaining]);
130                return;
131            }
132        }
133
134        if self.config.newlines {
135            self.buffer.push_str(s);
136        } else {
137            for c in s.chars() {
138                self.add_char(if c == '\n' { ' ' } else { c });
139            }
140        }
141    }
142
143    fn flush_pending(&mut self) {
144        if let Some(pending) = self.pending.take() {
145            self.add_char('\n');
146            self.add_str(&pending);
147        }
148    }
149
150    fn add_newline(&mut self, no_semicolon: bool) {
151        if self.config.newlines {
152            self.flush_pending();
153            self.add_char('\n');
154            self.add_indent();
155        } else if no_semicolon {
156            self.add_char(' ');
157        } else {
158            self.add_str("; ");
159        }
160    }
161
162    fn add_indent(&mut self) {
163        if self.config.expand_tabs < 0 {
164            return;
165        }
166        for _ in 0..self.indent {
167            if self.config.expand_tabs > 0 {
168                for _ in 0..self.config.expand_tabs {
169                    self.add_char(' ');
170                }
171            } else {
172                self.add_char('\t');
173            }
174        }
175    }
176
177    fn add_separator(&mut self) {
178        if self.config.newlines {
179            self.add_newline(false);
180        } else {
181            self.add_str("; ");
182        }
183    }
184
185    fn inc_indent(&mut self) {
186        self.indent += 1;
187    }
188
189    fn dec_indent(&mut self) {
190        if self.indent > 0 {
191            self.indent -= 1;
192        }
193    }
194
195    fn format_command(&mut self, cmd: &ShellCommand) {
196        match cmd {
197            ShellCommand::Simple(simple) => self.format_simple(simple),
198            ShellCommand::Pipeline(cmds, negated) => self.format_pipeline(cmds, *negated),
199            ShellCommand::List(list) => self.format_list_cmd(list),
200            ShellCommand::Compound(compound) => self.format_compound(compound),
201            ShellCommand::FunctionDef(name, body) => self.format_function(name, body),
202        }
203    }
204
205    fn format_simple(&mut self, cmd: &SimpleCommand) {
206        // Assignments first
207        for (name, value, is_append) in &cmd.assignments {
208            self.add_str(name);
209            if *is_append {
210                self.add_char('+');
211            }
212            self.add_char('=');
213            self.format_word(value);
214            self.add_char(' ');
215        }
216
217        // Command and arguments
218        let mut first = true;
219        for word in &cmd.words {
220            if !first {
221                self.add_char(' ');
222            }
223            self.format_word(word);
224            first = false;
225        }
226
227        // Redirections
228        self.format_redirects(&cmd.redirects);
229    }
230
231    fn format_word(&mut self, word: &ShellWord) {
232        match word {
233            ShellWord::Literal(s) => self.add_str(s),
234            ShellWord::SingleQuoted(s) => {
235                self.add_char('\'');
236                self.add_str(s);
237                self.add_char('\'');
238            }
239            ShellWord::DoubleQuoted(parts) => {
240                self.add_char('"');
241                for part in parts {
242                    self.format_word(part);
243                }
244                self.add_char('"');
245            }
246            ShellWord::Variable(name) => {
247                self.add_char('$');
248                self.add_str(name);
249            }
250            ShellWord::VariableBraced(name, modifier) => {
251                self.add_str("${");
252                self.add_str(name);
253                if modifier.is_some() {
254                    self.add_str("..."); // Simplified
255                }
256                self.add_char('}');
257            }
258            ShellWord::ArrayVar(name, _idx) => {
259                self.add_str("${");
260                self.add_str(name);
261                self.add_str("[...]}");
262            }
263            ShellWord::CommandSub(cmd) => {
264                self.add_str("$(");
265                self.format_command(cmd);
266                self.add_char(')');
267            }
268            ShellWord::ProcessSubIn(cmd) => {
269                self.add_str("<(");
270                self.format_command(cmd);
271                self.add_char(')');
272            }
273            ShellWord::ProcessSubOut(cmd) => {
274                self.add_str(">(");
275                self.format_command(cmd);
276                self.add_char(')');
277            }
278            ShellWord::ArithSub(expr) => {
279                self.add_str("$((");
280                self.add_str(expr);
281                self.add_str("))");
282            }
283            ShellWord::ArrayLiteral(words) => {
284                self.add_char('(');
285                for (i, w) in words.iter().enumerate() {
286                    if i > 0 {
287                        self.add_char(' ');
288                    }
289                    self.format_word(w);
290                }
291                self.add_char(')');
292            }
293            ShellWord::Glob(pattern) => self.add_str(pattern),
294            ShellWord::Tilde(user) => {
295                self.add_char('~');
296                if let Some(u) = user {
297                    self.add_str(u);
298                }
299            }
300            ShellWord::Concat(parts) => {
301                for part in parts {
302                    self.format_word(part);
303                }
304            }
305        }
306    }
307
308    fn format_pipeline(&mut self, cmds: &[ShellCommand], negated: bool) {
309        if negated {
310            self.add_str("! ");
311        }
312        for (i, cmd) in cmds.iter().enumerate() {
313            if i > 0 {
314                self.add_str(" | ");
315            }
316            self.format_command(cmd);
317        }
318    }
319
320    fn format_list_cmd(&mut self, list: &[(ShellCommand, ListOp)]) {
321        for (i, (cmd, op)) in list.iter().enumerate() {
322            if i > 0 {
323                match list.get(i - 1).map(|(_, o)| o) {
324                    Some(ListOp::And) => self.add_str(" && "),
325                    Some(ListOp::Or) => self.add_str(" || "),
326                    Some(ListOp::Amp) => self.add_str(" & "),
327                    Some(ListOp::Semi) | Some(ListOp::Newline) => {
328                        if self.config.newlines {
329                            self.add_newline(false);
330                        } else {
331                            self.add_str("; ");
332                        }
333                    }
334                    None => {}
335                }
336            }
337            self.format_command(cmd);
338
339            // Handle trailing operator for last command
340            if i == list.len() - 1 {
341                match op {
342                    ListOp::Amp => self.add_str(" &"),
343                    _ => {}
344                }
345            }
346        }
347    }
348
349    fn format_compound(&mut self, compound: &CompoundCommand) {
350        match compound {
351            CompoundCommand::BraceGroup(cmds) => self.format_brace_group(cmds),
352            CompoundCommand::Subshell(cmds) => self.format_subshell(cmds),
353            CompoundCommand::If {
354                conditions,
355                else_part,
356            } => {
357                self.format_if(conditions, else_part);
358            }
359            CompoundCommand::For { var, words, body } => {
360                self.format_for(var, words, body);
361            }
362            CompoundCommand::ForArith {
363                init,
364                cond,
365                step,
366                body,
367            } => {
368                self.format_for_arith(init, cond, step, body);
369            }
370            CompoundCommand::While { condition, body } => {
371                self.format_while(condition, body);
372            }
373            CompoundCommand::Until { condition, body } => {
374                self.format_until(condition, body);
375            }
376            CompoundCommand::Case { word, cases } => {
377                self.format_case(word, cases);
378            }
379            CompoundCommand::Select { var, words, body } => {
380                self.format_select(var, words, body);
381            }
382            CompoundCommand::Repeat { count, body } => {
383                self.add_str("repeat ");
384                self.add_str(count);
385                self.add_newline(false);
386                self.add_str("do");
387                self.inc_indent();
388                self.add_newline(false);
389                for cmd in body {
390                    self.format_command(cmd);
391                    self.add_newline(false);
392                }
393                self.dec_indent();
394                self.add_str("done");
395            }
396            CompoundCommand::Try {
397                try_body,
398                always_body,
399            } => {
400                self.add_char('{');
401                self.inc_indent();
402                self.add_newline(false);
403                for cmd in try_body {
404                    self.format_command(cmd);
405                    self.add_newline(false);
406                }
407                self.dec_indent();
408                self.add_str("} always {");
409                self.inc_indent();
410                self.add_newline(false);
411                for cmd in always_body {
412                    self.format_command(cmd);
413                    self.add_newline(false);
414                }
415                self.dec_indent();
416                self.add_char('}');
417            }
418            CompoundCommand::Coproc { name, body } => {
419                self.add_str("coproc ");
420                if let Some(n) = name {
421                    self.add_str(n);
422                    self.add_char(' ');
423                }
424                self.format_command(body);
425            }
426            CompoundCommand::Cond(expr) => {
427                self.add_str("[[ ");
428                self.format_cond_expr(expr);
429                self.add_str(" ]]");
430            }
431            CompoundCommand::Arith(expr) => {
432                self.add_str("((");
433                self.add_str(expr);
434                self.add_str("))");
435            }
436            CompoundCommand::WithRedirects(cmd, redirects) => {
437                self.format_command(cmd);
438                self.format_redirects(redirects);
439            }
440        }
441    }
442
443    fn format_cond_expr(&mut self, expr: &CondExpr) {
444        match expr {
445            CondExpr::Not(inner) => {
446                self.add_str("! ");
447                self.format_cond_expr(inner);
448            }
449            CondExpr::And(left, right) => {
450                self.format_cond_expr(left);
451                self.add_str(" && ");
452                self.format_cond_expr(right);
453            }
454            CondExpr::Or(left, right) => {
455                self.format_cond_expr(left);
456                self.add_str(" || ");
457                self.format_cond_expr(right);
458            }
459            // File tests
460            CondExpr::FileExists(w) => {
461                self.add_str("-e ");
462                self.format_word(w);
463            }
464            CondExpr::FileRegular(w) => {
465                self.add_str("-f ");
466                self.format_word(w);
467            }
468            CondExpr::FileDirectory(w) => {
469                self.add_str("-d ");
470                self.format_word(w);
471            }
472            CondExpr::FileSymlink(w) => {
473                self.add_str("-L ");
474                self.format_word(w);
475            }
476            CondExpr::FileReadable(w) => {
477                self.add_str("-r ");
478                self.format_word(w);
479            }
480            CondExpr::FileWritable(w) => {
481                self.add_str("-w ");
482                self.format_word(w);
483            }
484            CondExpr::FileExecutable(w) => {
485                self.add_str("-x ");
486                self.format_word(w);
487            }
488            CondExpr::FileNonEmpty(w) => {
489                self.add_str("-s ");
490                self.format_word(w);
491            }
492            // String tests
493            CondExpr::StringEmpty(w) => {
494                self.add_str("-z ");
495                self.format_word(w);
496            }
497            CondExpr::StringNonEmpty(w) => {
498                self.add_str("-n ");
499                self.format_word(w);
500            }
501            CondExpr::StringEqual(l, r) => {
502                self.format_word(l);
503                self.add_str(" == ");
504                self.format_word(r);
505            }
506            CondExpr::StringNotEqual(l, r) => {
507                self.format_word(l);
508                self.add_str(" != ");
509                self.format_word(r);
510            }
511            CondExpr::StringMatch(l, r) => {
512                self.format_word(l);
513                self.add_str(" =~ ");
514                self.format_word(r);
515            }
516            CondExpr::StringLess(l, r) => {
517                self.format_word(l);
518                self.add_str(" < ");
519                self.format_word(r);
520            }
521            CondExpr::StringGreater(l, r) => {
522                self.format_word(l);
523                self.add_str(" > ");
524                self.format_word(r);
525            }
526            // Numeric tests
527            CondExpr::NumEqual(l, r) => {
528                self.format_word(l);
529                self.add_str(" -eq ");
530                self.format_word(r);
531            }
532            CondExpr::NumNotEqual(l, r) => {
533                self.format_word(l);
534                self.add_str(" -ne ");
535                self.format_word(r);
536            }
537            CondExpr::NumLess(l, r) => {
538                self.format_word(l);
539                self.add_str(" -lt ");
540                self.format_word(r);
541            }
542            CondExpr::NumLessEqual(l, r) => {
543                self.format_word(l);
544                self.add_str(" -le ");
545                self.format_word(r);
546            }
547            CondExpr::NumGreater(l, r) => {
548                self.format_word(l);
549                self.add_str(" -gt ");
550                self.format_word(r);
551            }
552            CondExpr::NumGreaterEqual(l, r) => {
553                self.format_word(l);
554                self.add_str(" -ge ");
555                self.format_word(r);
556            }
557        }
558    }
559
560    fn format_for(&mut self, var: &str, words: &Option<Vec<ShellWord>>, body: &[ShellCommand]) {
561        self.add_str("for ");
562        self.add_str(var);
563
564        if let Some(word_list) = words {
565            self.add_str(" in ");
566            for (i, w) in word_list.iter().enumerate() {
567                if i > 0 {
568                    self.add_char(' ');
569                }
570                self.format_word(w);
571            }
572        }
573
574        self.add_newline(false);
575        self.add_str("do");
576        self.inc_indent();
577        self.add_newline(false);
578
579        for cmd in body {
580            self.format_command(cmd);
581            self.add_newline(false);
582        }
583
584        self.dec_indent();
585        self.add_newline(false);
586        self.add_str("done");
587    }
588
589    fn format_for_arith(&mut self, init: &str, cond: &str, step: &str, body: &[ShellCommand]) {
590        self.add_str("for ((");
591        self.add_str(init);
592        self.add_str("; ");
593        self.add_str(cond);
594        self.add_str("; ");
595        self.add_str(step);
596        self.add_str(")) do");
597        self.inc_indent();
598        self.add_newline(false);
599
600        for cmd in body {
601            self.format_command(cmd);
602            self.add_newline(false);
603        }
604
605        self.dec_indent();
606        self.add_newline(false);
607        self.add_str("done");
608    }
609
610    fn format_while(&mut self, condition: &[ShellCommand], body: &[ShellCommand]) {
611        self.add_str("while ");
612        self.inc_indent();
613
614        for cmd in condition {
615            self.format_command(cmd);
616        }
617
618        self.dec_indent();
619        self.add_newline(false);
620        self.add_str("do");
621        self.inc_indent();
622        self.add_newline(false);
623
624        for cmd in body {
625            self.format_command(cmd);
626            self.add_newline(false);
627        }
628
629        self.dec_indent();
630        self.add_newline(false);
631        self.add_str("done");
632    }
633
634    fn format_until(&mut self, condition: &[ShellCommand], body: &[ShellCommand]) {
635        self.add_str("until ");
636        self.inc_indent();
637
638        for cmd in condition {
639            self.format_command(cmd);
640        }
641
642        self.dec_indent();
643        self.add_newline(false);
644        self.add_str("do");
645        self.inc_indent();
646        self.add_newline(false);
647
648        for cmd in body {
649            self.format_command(cmd);
650            self.add_newline(false);
651        }
652
653        self.dec_indent();
654        self.add_newline(false);
655        self.add_str("done");
656    }
657
658    fn format_case(
659        &mut self,
660        word: &ShellWord,
661        cases: &[(Vec<ShellWord>, Vec<ShellCommand>, CaseTerminator)],
662    ) {
663        self.add_str("case ");
664        self.format_word(word);
665        self.add_str(" in");
666
667        if cases.is_empty() {
668            if self.config.newlines {
669                self.add_newline(false);
670            } else {
671                self.add_char(' ');
672            }
673            self.add_str("esac");
674            return;
675        }
676
677        self.inc_indent();
678
679        for (patterns, body, terminator) in cases {
680            if self.config.newlines {
681                self.add_newline(false);
682            } else {
683                self.add_char(' ');
684            }
685
686            self.add_str("(");
687            for (i, pat) in patterns.iter().enumerate() {
688                if i > 0 {
689                    self.add_str(" | ");
690                }
691                self.format_word(pat);
692            }
693            self.add_str(") ");
694
695            self.inc_indent();
696            for cmd in body {
697                self.format_command(cmd);
698            }
699            self.dec_indent();
700
701            match terminator {
702                CaseTerminator::Break => self.add_str(" ;;"),
703                CaseTerminator::Fallthrough => self.add_str(" ;&"),
704                CaseTerminator::Continue => self.add_str(" ;|"),
705            }
706        }
707
708        self.dec_indent();
709        if self.config.newlines {
710            self.add_newline(false);
711        } else {
712            self.add_char(' ');
713        }
714        self.add_str("esac");
715    }
716
717    fn format_if(
718        &mut self,
719        conditions: &[(Vec<ShellCommand>, Vec<ShellCommand>)],
720        else_part: &Option<Vec<ShellCommand>>,
721    ) {
722        for (i, (cond, body)) in conditions.iter().enumerate() {
723            if i == 0 {
724                self.add_str("if ");
725            } else {
726                self.dec_indent();
727                self.add_newline(false);
728                self.add_str("elif ");
729            }
730
731            self.inc_indent();
732            for cmd in cond {
733                self.format_command(cmd);
734            }
735            self.dec_indent();
736
737            self.add_newline(false);
738            self.add_str("then");
739            self.inc_indent();
740            self.add_newline(false);
741
742            for cmd in body {
743                self.format_command(cmd);
744                self.add_newline(false);
745            }
746        }
747
748        if let Some(else_body) = else_part {
749            self.dec_indent();
750            self.add_newline(false);
751            self.add_str("else");
752            self.inc_indent();
753            self.add_newline(false);
754
755            for cmd in else_body {
756                self.format_command(cmd);
757                self.add_newline(false);
758            }
759        }
760
761        self.dec_indent();
762        self.add_newline(false);
763        self.add_str("fi");
764    }
765
766    fn format_select(&mut self, var: &str, words: &Option<Vec<ShellWord>>, body: &[ShellCommand]) {
767        self.add_str("select ");
768        self.add_str(var);
769
770        if let Some(word_list) = words {
771            self.add_str(" in ");
772            for (i, w) in word_list.iter().enumerate() {
773                if i > 0 {
774                    self.add_char(' ');
775                }
776                self.format_word(w);
777            }
778        }
779
780        self.add_newline(false);
781        self.add_str("do");
782        self.add_newline(false);
783        self.inc_indent();
784
785        for cmd in body {
786            self.format_command(cmd);
787            self.add_newline(false);
788        }
789
790        self.dec_indent();
791        self.add_newline(false);
792        self.add_str("done");
793    }
794
795    fn format_function(&mut self, name: &str, body: &ShellCommand) {
796        self.add_str(name);
797        self.add_str("() ");
798
799        if self.config.is_job {
800            self.add_str("{ ... }");
801            return;
802        }
803
804        self.add_str("{");
805        self.inc_indent();
806        self.add_newline(true);
807
808        self.format_command(body);
809
810        self.dec_indent();
811        self.add_newline(false);
812        self.add_str("}");
813    }
814
815    fn format_subshell(&mut self, cmds: &[ShellCommand]) {
816        self.add_str("(");
817        self.inc_indent();
818        self.add_newline(true);
819
820        for cmd in cmds {
821            self.format_command(cmd);
822            self.add_newline(false);
823        }
824
825        self.dec_indent();
826        self.add_newline(false);
827        self.add_str(")");
828    }
829
830    fn format_brace_group(&mut self, cmds: &[ShellCommand]) {
831        self.add_str("{");
832        self.inc_indent();
833        self.add_newline(true);
834
835        for cmd in cmds {
836            self.format_command(cmd);
837            self.add_newline(false);
838        }
839
840        self.dec_indent();
841        self.add_newline(false);
842        self.add_str("}");
843    }
844
845    fn format_redirects(&mut self, redirects: &[Redirect]) {
846        if redirects.is_empty() {
847            return;
848        }
849
850        self.add_char(' ');
851
852        for redir in redirects {
853            self.format_redirect(redir);
854            self.add_char(' ');
855        }
856
857        // Remove trailing space
858        if self.buffer.ends_with(' ') {
859            self.buffer.pop();
860        }
861    }
862
863    fn format_redirect(&mut self, redir: &Redirect) {
864        // File descriptor variable
865        if let Some(ref var) = redir.fd_var {
866            self.add_char('{');
867            self.add_str(var);
868            self.add_char('}');
869        } else if let Some(fd) = redir.fd {
870            let default_fd = match redir.op {
871                RedirectOp::Read
872                | RedirectOp::ReadWrite
873                | RedirectOp::HereDoc
874                | RedirectOp::HereString
875                | RedirectOp::DupRead => 0,
876                _ => 1,
877            };
878            if fd != default_fd {
879                self.add_str(&fd.to_string());
880            }
881        }
882
883        // Operator
884        let op = match redir.op {
885            RedirectOp::Write => ">",
886            RedirectOp::Clobber => ">|",
887            RedirectOp::Append => ">>",
888            RedirectOp::WriteBoth => "&>",
889            RedirectOp::AppendBoth => "&>>",
890            RedirectOp::ReadWrite => "<>",
891            RedirectOp::Read => "<",
892            RedirectOp::HereDoc => "<<",
893            RedirectOp::HereString => "<<<",
894            RedirectOp::DupRead => "<&",
895            RedirectOp::DupWrite => ">&",
896        };
897        self.add_str(op);
898
899        // Target
900        if !matches!(redir.op, RedirectOp::DupRead | RedirectOp::DupWrite) {
901            self.add_char(' ');
902        }
903        self.format_word(&redir.target);
904    }
905}
906
907/// Get a permanent textual representation of a command
908pub fn getpermtext(cmd: &ShellCommand) -> String {
909    TextFormatter::new(TextConfig::default()).format(cmd)
910}
911
912/// Get a permanent textual representation with custom indent
913pub fn getpermtext_indent(cmd: &ShellCommand, indent: usize) -> String {
914    TextFormatter::new(TextConfig::default())
915        .with_indent(indent)
916        .format(cmd)
917}
918
919/// Get a representation suitable for job text (abbreviated, single line)
920pub fn getjobtext(cmd: &ShellCommand) -> String {
921    TextFormatter::new(TextConfig::job_text()).format(cmd)
922}
923
924/// Get a single-line representation
925pub fn getsingleline(cmd: &ShellCommand) -> String {
926    TextFormatter::new(TextConfig::single_line()).format(cmd)
927}
928
929/// Format a list of commands
930pub fn format_commands(cmds: &[ShellCommand], config: TextConfig) -> String {
931    TextFormatter::new(config).format_list(cmds)
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    fn simple_cmd(words: &[&str]) -> ShellCommand {
939        ShellCommand::Simple(SimpleCommand {
940            words: words
941                .iter()
942                .map(|s| ShellWord::Literal(s.to_string()))
943                .collect(),
944            assignments: vec![],
945            redirects: vec![],
946        })
947    }
948
949    #[test]
950    fn test_simple_command() {
951        let cmd = simple_cmd(&["echo", "hello"]);
952        assert_eq!(getpermtext(&cmd), "echo hello");
953    }
954
955    #[test]
956    fn test_pipeline() {
957        let pipeline = ShellCommand::Pipeline(
958            vec![
959                simple_cmd(&["cat", "file"]),
960                simple_cmd(&["grep", "pattern"]),
961            ],
962            false,
963        );
964        assert_eq!(getpermtext(&pipeline), "cat file | grep pattern");
965    }
966
967    #[test]
968    fn test_negated_pipeline() {
969        let pipeline = ShellCommand::Pipeline(vec![simple_cmd(&["test", "-f", "file"])], true);
970        assert_eq!(getpermtext(&pipeline), "! test -f file");
971    }
972
973    #[test]
974    fn test_and_list() {
975        let list = ShellCommand::List(vec![
976            (simple_cmd(&["test", "-f", "file"]), ListOp::And),
977            (simple_cmd(&["cat", "file"]), ListOp::Semi),
978        ]);
979        let text = getpermtext(&list);
980        assert!(text.contains("&&"));
981    }
982
983    #[test]
984    fn test_or_list() {
985        let list = ShellCommand::List(vec![
986            (simple_cmd(&["test", "-f", "file"]), ListOp::Or),
987            (simple_cmd(&["echo", "not found"]), ListOp::Semi),
988        ]);
989        let text = getpermtext(&list);
990        assert!(text.contains("||"));
991    }
992
993    #[test]
994    fn test_subshell() {
995        let cmd =
996            ShellCommand::Compound(CompoundCommand::Subshell(vec![simple_cmd(&["echo", "hi"])]));
997        let text = getpermtext(&cmd);
998        assert!(text.contains("("));
999        assert!(text.contains(")"));
1000        assert!(text.contains("echo hi"));
1001    }
1002
1003    #[test]
1004    fn test_brace_group() {
1005        let cmd = ShellCommand::Compound(CompoundCommand::BraceGroup(vec![simple_cmd(&[
1006            "echo", "hi",
1007        ])]));
1008        let text = getpermtext(&cmd);
1009        assert!(text.contains("{"));
1010        assert!(text.contains("}"));
1011    }
1012
1013    #[test]
1014    fn test_job_text() {
1015        let cmd = simple_cmd(&["very", "long", "command", "with", "many", "arguments"]);
1016        let job_text = getjobtext(&cmd);
1017        assert!(job_text.len() <= 80);
1018    }
1019
1020    #[test]
1021    fn test_single_line() {
1022        let cmd = ShellCommand::Compound(CompoundCommand::BraceGroup(vec![
1023            simple_cmd(&["echo", "a"]),
1024            simple_cmd(&["echo", "b"]),
1025        ]));
1026        let text = getsingleline(&cmd);
1027        assert!(!text.contains('\n'));
1028        assert!(text.contains(';'));
1029    }
1030
1031    #[test]
1032    fn test_is_cond_binary_op() {
1033        assert!(is_cond_binary_op("="));
1034        assert!(is_cond_binary_op("-eq"));
1035        assert!(is_cond_binary_op("-nt"));
1036        assert!(!is_cond_binary_op("-f"));
1037        assert!(!is_cond_binary_op("foo"));
1038    }
1039
1040    #[test]
1041    fn test_redirect_output() {
1042        let cmd = ShellCommand::Simple(SimpleCommand {
1043            words: vec![
1044                ShellWord::Literal("echo".to_string()),
1045                ShellWord::Literal("hello".to_string()),
1046            ],
1047            assignments: vec![],
1048            redirects: vec![Redirect {
1049                fd: Some(1),
1050                op: RedirectOp::Write,
1051                target: ShellWord::Literal("file.txt".to_string()),
1052                heredoc_content: None,
1053                fd_var: None,
1054            }],
1055        });
1056        let text = getpermtext(&cmd);
1057        assert!(text.contains("> file.txt"));
1058    }
1059}