Skip to main content

zsh/
subst.rs

1//! Substitution handling for zshrs
2//!
3//! Port from zsh/Src/subst.c
4//!
5//! Provides parameter expansion, command substitution, arithmetic expansion,
6//! brace expansion, tilde expansion, and filename generation.
7
8use std::collections::HashMap;
9use std::env;
10use std::process::{Command, Stdio};
11
12/// Prefork flags
13pub mod prefork {
14    pub const SINGLE: u32 = 1; // Single word expected
15    pub const SPLIT: u32 = 2; // Force word splitting
16    pub const SHWORDSPLIT: u32 = 4; // sh-style word splitting
17    pub const NOSHWORDSPLIT: u32 = 8; // Disable word splitting
18    pub const ASSIGN: u32 = 16; // Assignment context
19    pub const TYPESET: u32 = 32; // Typeset context
20    pub const SUBEXP: u32 = 64; // Subexpression
21    pub const KEY_VALUE: u32 = 128; // Key-value pair found
22}
23
24/// Perform all substitutions on a word
25pub fn subst_string(
26    s: &str,
27    params: &HashMap<String, String>,
28    opts: &SubstOptions,
29) -> Result<String, String> {
30    let mut result = s.to_string();
31
32    // Tilde expansion
33    result = tilde_expand(&result, opts)?;
34
35    // Parameter expansion
36    result = param_expand(&result, params, opts)?;
37
38    // Command substitution
39    result = command_subst(&result, opts)?;
40
41    // Arithmetic expansion
42    result = arith_expand(&result, params, opts)?;
43
44    Ok(result)
45}
46
47/// Substitution options
48#[derive(Clone, Debug, Default)]
49pub struct SubstOptions {
50    pub noglob: bool,
51    pub noexec: bool,
52    pub nounset: bool,
53    pub word_split: bool,
54    pub ignore_braces: bool,
55}
56
57/// Tilde expansion
58pub fn tilde_expand(s: &str, _opts: &SubstOptions) -> Result<String, String> {
59    if !s.starts_with('~') {
60        return Ok(s.to_string());
61    }
62
63    let rest = &s[1..];
64
65    // Find end of username
66    let (user, suffix) = match rest.find('/') {
67        Some(pos) => (&rest[..pos], &rest[pos..]),
68        None => (rest, ""),
69    };
70
71    let expanded = if user.is_empty() {
72        // ~ alone means $HOME
73        env::var("HOME").unwrap_or_else(|_| "/".to_string())
74    } else if user.starts_with('+') {
75        // ~+ means $PWD
76        env::var("PWD").unwrap_or_else(|_| ".".to_string())
77    } else if user.starts_with('-') {
78        // ~- means $OLDPWD
79        env::var("OLDPWD").unwrap_or_else(|_| ".".to_string())
80    } else {
81        // ~user means user's home directory
82        #[cfg(unix)]
83        {
84            get_user_home(user).unwrap_or_else(|| format!("~{}", user))
85        }
86        #[cfg(not(unix))]
87        {
88            format!("~{}", user)
89        }
90    };
91
92    Ok(format!("{}{}", expanded, suffix))
93}
94
95#[cfg(unix)]
96fn get_user_home(user: &str) -> Option<String> {
97    use std::ffi::CString;
98    unsafe {
99        let c_user = CString::new(user).ok()?;
100        let pw = libc::getpwnam(c_user.as_ptr());
101        if pw.is_null() {
102            return None;
103        }
104        let dir = std::ffi::CStr::from_ptr((*pw).pw_dir);
105        dir.to_str().ok().map(|s| s.to_string())
106    }
107}
108
109/// Parameter expansion
110pub fn param_expand(
111    s: &str,
112    params: &HashMap<String, String>,
113    opts: &SubstOptions,
114) -> Result<String, String> {
115    let mut result = String::new();
116    let mut chars = s.chars().peekable();
117
118    while let Some(c) = chars.next() {
119        if c == '$' {
120            match chars.peek() {
121                Some(&'{') => {
122                    // ${...} form
123                    chars.next();
124                    let expanded = parse_brace_param(&mut chars, params, opts)?;
125                    result.push_str(&expanded);
126                }
127                Some(&'(') => {
128                    // $(...) command substitution or $((...)) arithmetic
129                    chars.next();
130                    if chars.peek() == Some(&'(') {
131                        // $((...)) arithmetic
132                        chars.next();
133                        let expr = collect_until(&mut chars, ')');
134                        if chars.next() != Some(')') {
135                            return Err("Missing )) in arithmetic expansion".to_string());
136                        }
137                        let value = eval_arith(&expr, params)?;
138                        result.push_str(&value.to_string());
139                    } else {
140                        // $(...) command substitution
141                        let cmd = collect_balanced(&mut chars, '(', ')');
142                        if !opts.noexec {
143                            let output = run_command(&cmd)?;
144                            result.push_str(output.trim_end_matches('\n'));
145                        }
146                    }
147                }
148                Some(&c) if c.is_ascii_alphabetic() || c == '_' => {
149                    // Simple $var - consume first char we peeked
150                    chars.next();
151                    let name = collect_varname(&mut chars);
152                    let full_name = format!("{}{}", c, name);
153
154                    if let Some(value) = params.get(&full_name) {
155                        result.push_str(value);
156                    } else if let Ok(value) = env::var(&full_name) {
157                        result.push_str(&value);
158                    } else if opts.nounset {
159                        return Err(format!("{}: parameter not set", full_name));
160                    }
161                }
162                Some(&c) if c.is_ascii_digit() => {
163                    // Positional parameter
164                    let mut num = String::new();
165                    while let Some(&c) = chars.peek() {
166                        if c.is_ascii_digit() {
167                            num.push(chars.next().unwrap());
168                        } else {
169                            break;
170                        }
171                    }
172                    // Positional params would be looked up here
173                }
174                Some(&'?') => {
175                    chars.next();
176                    result.push_str("0"); // Last exit status
177                }
178                Some(&'$') => {
179                    chars.next();
180                    result.push_str(&std::process::id().to_string());
181                }
182                Some(&'#') => {
183                    chars.next();
184                    result.push_str("0"); // Number of positional params
185                }
186                Some(&'*') | Some(&'@') => {
187                    chars.next();
188                    // All positional params
189                }
190                _ => result.push('$'),
191            }
192        } else {
193            result.push(c);
194        }
195    }
196
197    Ok(result)
198}
199
200fn parse_brace_param(
201    chars: &mut std::iter::Peekable<std::str::Chars>,
202    params: &HashMap<String, String>,
203    _opts: &SubstOptions,
204) -> Result<String, String> {
205    let mut name = String::new();
206    let mut operator = None;
207    let mut operand = String::new();
208
209    // Check for special prefix operators
210    let prefix = match chars.peek() {
211        Some(&'#') => {
212            chars.next();
213            if chars.peek() == Some(&'}') {
214                // ${#} - number of params
215                chars.next();
216                return Ok("0".to_string());
217            }
218            Some('#') // Length operator
219        }
220        Some(&'!') => {
221            chars.next();
222            Some('!') // Indirect expansion
223        }
224        _ => None,
225    };
226
227    // Collect variable name
228    while let Some(&c) = chars.peek() {
229        if c.is_ascii_alphanumeric() || c == '_' {
230            name.push(chars.next().unwrap());
231        } else {
232            break;
233        }
234    }
235
236    // Check for operators
237    match chars.peek() {
238        Some(&':') => {
239            chars.next();
240            match chars.peek() {
241                Some(&'-') => {
242                    chars.next();
243                    operator = Some(":-");
244                }
245                Some(&'=') => {
246                    chars.next();
247                    operator = Some(":=");
248                }
249                Some(&'+') => {
250                    chars.next();
251                    operator = Some(":+");
252                }
253                Some(&'?') => {
254                    chars.next();
255                    operator = Some(":?");
256                }
257                _ => operator = Some(":"),
258            }
259        }
260        Some(&'-') => {
261            chars.next();
262            operator = Some("-");
263        }
264        Some(&'=') => {
265            chars.next();
266            operator = Some("=");
267        }
268        Some(&'+') => {
269            chars.next();
270            operator = Some("+");
271        }
272        Some(&'?') => {
273            chars.next();
274            operator = Some("?");
275        }
276        Some(&'#') => {
277            chars.next();
278            operator = Some("#");
279        }
280        Some(&'%') => {
281            chars.next();
282            operator = Some("%");
283        }
284        Some(&'/') => {
285            chars.next();
286            operator = Some("/");
287        }
288        Some(&'^') => {
289            chars.next();
290            operator = Some("^");
291        }
292        Some(&',') => {
293            chars.next();
294            operator = Some(",");
295        }
296        _ => {}
297    }
298
299    // Collect operand until closing brace
300    let mut depth = 1;
301    while depth > 0 {
302        match chars.next() {
303            Some('{') => depth += 1,
304            Some('}') => depth -= 1,
305            Some(c) if depth > 0 => operand.push(c),
306            None => return Err("Missing } in parameter expansion".to_string()),
307            _ => {}
308        }
309    }
310
311    // Get the value
312    let value = params.get(&name).cloned().or_else(|| env::var(&name).ok());
313
314    // Handle prefix operators
315    if let Some('#') = prefix {
316        // ${#var} - length
317        return Ok(value.map(|v| v.len()).unwrap_or(0).to_string());
318    }
319
320    // Apply operator
321    match operator {
322        Some(":-") | Some("-") => {
323            if value.as_ref().map(|v| v.is_empty()).unwrap_or(true) {
324                Ok(operand)
325            } else {
326                Ok(value.unwrap_or_default())
327            }
328        }
329        Some(":+") | Some("+") => {
330            if value.as_ref().map(|v| !v.is_empty()).unwrap_or(false) {
331                Ok(operand)
332            } else {
333                Ok(String::new())
334            }
335        }
336        Some(":?") | Some("?") => {
337            if value.as_ref().map(|v| v.is_empty()).unwrap_or(true) {
338                Err(if operand.is_empty() {
339                    format!("{}: parameter null or not set", name)
340                } else {
341                    operand
342                })
343            } else {
344                Ok(value.unwrap_or_default())
345            }
346        }
347        Some("#") => {
348            // Remove shortest prefix
349            if let Some(v) = value {
350                Ok(remove_prefix(&v, &operand, false))
351            } else {
352                Ok(String::new())
353            }
354        }
355        Some("##") => {
356            // Remove longest prefix
357            if let Some(v) = value {
358                Ok(remove_prefix(&v, &operand, true))
359            } else {
360                Ok(String::new())
361            }
362        }
363        Some("%") => {
364            // Remove shortest suffix
365            if let Some(v) = value {
366                Ok(remove_suffix(&v, &operand, false))
367            } else {
368                Ok(String::new())
369            }
370        }
371        Some("%%") => {
372            // Remove longest suffix
373            if let Some(v) = value {
374                Ok(remove_suffix(&v, &operand, true))
375            } else {
376                Ok(String::new())
377            }
378        }
379        Some("^") => {
380            // Uppercase first char
381            if let Some(v) = value {
382                let mut c = v.chars();
383                match c.next() {
384                    Some(first) => Ok(first.to_uppercase().collect::<String>() + c.as_str()),
385                    None => Ok(String::new()),
386                }
387            } else {
388                Ok(String::new())
389            }
390        }
391        Some("^^") => {
392            // Uppercase all
393            Ok(value.map(|v| v.to_uppercase()).unwrap_or_default())
394        }
395        Some(",") => {
396            // Lowercase first char
397            if let Some(v) = value {
398                let mut c = v.chars();
399                match c.next() {
400                    Some(first) => Ok(first.to_lowercase().collect::<String>() + c.as_str()),
401                    None => Ok(String::new()),
402                }
403            } else {
404                Ok(String::new())
405            }
406        }
407        Some(",,") => {
408            // Lowercase all
409            Ok(value.map(|v| v.to_lowercase()).unwrap_or_default())
410        }
411        Some("/") => {
412            // Substitution
413            if let Some(v) = value {
414                let parts: Vec<&str> = operand.splitn(2, '/').collect();
415                if parts.len() == 2 {
416                    Ok(v.replacen(parts[0], parts[1], 1))
417                } else {
418                    Ok(v.replacen(parts[0], "", 1))
419                }
420            } else {
421                Ok(String::new())
422            }
423        }
424        Some("//") => {
425            // Global substitution
426            if let Some(v) = value {
427                let parts: Vec<&str> = operand.splitn(2, '/').collect();
428                if parts.len() == 2 {
429                    Ok(v.replace(parts[0], parts[1]))
430                } else {
431                    Ok(v.replace(parts[0], ""))
432                }
433            } else {
434                Ok(String::new())
435            }
436        }
437        _ => Ok(value.unwrap_or_default()),
438    }
439}
440
441fn remove_prefix(s: &str, pattern: &str, greedy: bool) -> String {
442    // Simple glob-less version
443    if greedy {
444        for i in (0..=s.len()).rev() {
445            if s[..i].ends_with(pattern) || (pattern == "*" && i > 0) {
446                return s[i..].to_string();
447            }
448        }
449    } else {
450        for i in 0..=s.len() {
451            if s[..i].ends_with(pattern) || (pattern == "*" && i > 0) {
452                return s[i..].to_string();
453            }
454        }
455    }
456    s.to_string()
457}
458
459fn remove_suffix(s: &str, pattern: &str, greedy: bool) -> String {
460    // Simple glob-less version
461    if greedy {
462        for i in 0..=s.len() {
463            if s[i..].starts_with(pattern) || (pattern == "*" && i < s.len()) {
464                return s[..i].to_string();
465            }
466        }
467    } else {
468        for i in (0..=s.len()).rev() {
469            if s[i..].starts_with(pattern) || (pattern == "*" && i < s.len()) {
470                return s[..i].to_string();
471            }
472        }
473    }
474    s.to_string()
475}
476
477fn collect_varname(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
478    let mut name = String::new();
479    while let Some(&c) = chars.peek() {
480        if c.is_ascii_alphanumeric() || c == '_' {
481            name.push(chars.next().unwrap());
482        } else {
483            break;
484        }
485    }
486    name
487}
488
489fn collect_until(chars: &mut std::iter::Peekable<std::str::Chars>, end: char) -> String {
490    let mut result = String::new();
491    while let Some(&c) = chars.peek() {
492        if c == end {
493            break;
494        }
495        result.push(chars.next().unwrap());
496    }
497    result
498}
499
500fn collect_balanced(
501    chars: &mut std::iter::Peekable<std::str::Chars>,
502    open: char,
503    close: char,
504) -> String {
505    let mut result = String::new();
506    let mut depth = 1;
507
508    while depth > 0 {
509        match chars.next() {
510            Some(c) if c == open => {
511                depth += 1;
512                result.push(c);
513            }
514            Some(c) if c == close => {
515                depth -= 1;
516                if depth > 0 {
517                    result.push(c);
518                }
519            }
520            Some(c) => result.push(c),
521            None => break,
522        }
523    }
524
525    result
526}
527
528/// Command substitution
529pub fn command_subst(s: &str, opts: &SubstOptions) -> Result<String, String> {
530    if opts.noexec {
531        return Ok(s.to_string());
532    }
533
534    let mut result = String::new();
535    let mut chars = s.chars().peekable();
536
537    while let Some(c) = chars.next() {
538        if c == '`' {
539            // Backtick form
540            let cmd = collect_until(&mut chars, '`');
541            chars.next(); // consume closing backtick
542            let output = run_command(&cmd)?;
543            result.push_str(output.trim_end_matches('\n'));
544        } else {
545            result.push(c);
546        }
547    }
548
549    Ok(result)
550}
551
552fn run_command(cmd: &str) -> Result<String, String> {
553    let output = Command::new("sh")
554        .arg("-c")
555        .arg(cmd)
556        .stdin(Stdio::null())
557        .stdout(Stdio::piped())
558        .stderr(Stdio::inherit())
559        .output()
560        .map_err(|e| e.to_string())?;
561
562    String::from_utf8(output.stdout).map_err(|e| e.to_string())
563}
564
565/// Arithmetic expansion
566pub fn arith_expand(
567    s: &str,
568    params: &HashMap<String, String>,
569    opts: &SubstOptions,
570) -> Result<String, String> {
571    let mut result = String::new();
572    let mut chars = s.chars().peekable();
573
574    while let Some(c) = chars.next() {
575        if c == '$' && chars.peek() == Some(&'[') {
576            // $[...] form
577            chars.next();
578            let expr = collect_until(&mut chars, ']');
579            chars.next(); // consume ]
580
581            if !opts.noexec {
582                let value = eval_arith(&expr, params)?;
583                result.push_str(&value.to_string());
584            }
585        } else {
586            result.push(c);
587        }
588    }
589
590    Ok(result)
591}
592
593fn eval_arith(expr: &str, _params: &HashMap<String, String>) -> Result<i64, String> {
594    // Simple arithmetic evaluation
595    // This would use the math module in practice
596    let expr = expr.trim();
597
598    // Handle simple integers
599    if let Ok(n) = expr.parse::<i64>() {
600        return Ok(n);
601    }
602
603    // Try simple expression
604    if let Some(pos) = expr.find('+') {
605        let left = expr[..pos]
606            .trim()
607            .parse::<i64>()
608            .map_err(|e| e.to_string())?;
609        let right = expr[pos + 1..]
610            .trim()
611            .parse::<i64>()
612            .map_err(|e| e.to_string())?;
613        return Ok(left + right);
614    }
615    if let Some(pos) = expr.rfind('-') {
616        if pos > 0 {
617            let left = expr[..pos]
618                .trim()
619                .parse::<i64>()
620                .map_err(|e| e.to_string())?;
621            let right = expr[pos + 1..]
622                .trim()
623                .parse::<i64>()
624                .map_err(|e| e.to_string())?;
625            return Ok(left - right);
626        }
627    }
628    if let Some(pos) = expr.find('*') {
629        let left = expr[..pos]
630            .trim()
631            .parse::<i64>()
632            .map_err(|e| e.to_string())?;
633        let right = expr[pos + 1..]
634            .trim()
635            .parse::<i64>()
636            .map_err(|e| e.to_string())?;
637        return Ok(left * right);
638    }
639    if let Some(pos) = expr.find('/') {
640        let left = expr[..pos]
641            .trim()
642            .parse::<i64>()
643            .map_err(|e| e.to_string())?;
644        let right = expr[pos + 1..]
645            .trim()
646            .parse::<i64>()
647            .map_err(|e| e.to_string())?;
648        if right == 0 {
649            return Err("division by zero".to_string());
650        }
651        return Ok(left / right);
652    }
653
654    Err(format!("Invalid arithmetic expression: {}", expr))
655}
656
657/// Brace expansion
658pub fn brace_expand(s: &str) -> Vec<String> {
659    if !s.contains('{') {
660        return vec![s.to_string()];
661    }
662
663    let mut results = vec![String::new()];
664    let mut chars = s.chars().peekable();
665
666    while let Some(c) = chars.next() {
667        if c == '{' {
668            let content = collect_balanced(&mut chars, '{', '}');
669            let alternatives: Vec<&str> = content.split(',').collect();
670
671            if alternatives.len() > 1 {
672                results = results
673                    .iter()
674                    .flat_map(|prefix| {
675                        alternatives
676                            .iter()
677                            .map(|alt| format!("{}{}", prefix, alt))
678                            .collect::<Vec<_>>()
679                    })
680                    .collect();
681            } else if let Some((start, end)) = parse_range(&content) {
682                results = results
683                    .iter()
684                    .flat_map(|prefix| {
685                        (start..=end)
686                            .map(|n| format!("{}{}", prefix, n))
687                            .collect::<Vec<_>>()
688                    })
689                    .collect();
690            } else {
691                for r in &mut results {
692                    r.push('{');
693                    r.push_str(&content);
694                    r.push('}');
695                }
696            }
697        } else {
698            for r in &mut results {
699                r.push(c);
700            }
701        }
702    }
703
704    results
705}
706
707fn parse_range(s: &str) -> Option<(i32, i32)> {
708    let parts: Vec<&str> = s.splitn(2, "..").collect();
709    if parts.len() != 2 {
710        return None;
711    }
712    let start = parts[0].parse().ok()?;
713    let end = parts[1].parse().ok()?;
714    Some((start, end))
715}
716
717/// Remove trailing path component(s)
718/// Port from remtpath() in zsh/Src/subst.c
719pub fn remtpath(path: &str, count: usize) -> String {
720    let mut result = path.to_string();
721    for _ in 0..count {
722        if let Some(pos) = result.rfind('/') {
723            if pos == 0 {
724                result = "/".to_string();
725            } else {
726                result = result[..pos].to_string();
727            }
728        } else {
729            result = ".".to_string();
730            break;
731        }
732    }
733    result
734}
735
736/// Remove leading path component(s)
737/// Port from remlpaths() in zsh/Src/subst.c
738pub fn remlpaths(path: &str, count: usize) -> String {
739    let mut result = path;
740    for _ in 0..count {
741        if let Some(pos) = result.find('/') {
742            result = &result[pos + 1..];
743        } else {
744            return result.to_string();
745        }
746    }
747    result.to_string()
748}
749
750/// Remove text after last dot (extension)
751/// Port from remtext() in zsh/Src/subst.c
752pub fn remtext(path: &str) -> String {
753    if let Some(slash_pos) = path.rfind('/') {
754        let filename = &path[slash_pos + 1..];
755        if let Some(dot_pos) = filename.rfind('.') {
756            if dot_pos > 0 {
757                return format!("{}{}", &path[..=slash_pos], &filename[..dot_pos]);
758            }
759        }
760        path.to_string()
761    } else if let Some(dot_pos) = path.rfind('.') {
762        if dot_pos > 0 {
763            return path[..dot_pos].to_string();
764        }
765        path.to_string()
766    } else {
767        path.to_string()
768    }
769}
770
771/// Remove everything but the extension
772/// Port from rembutext() in zsh/Src/subst.c
773pub fn rembutext(path: &str) -> String {
774    let filename = if let Some(slash_pos) = path.rfind('/') {
775        &path[slash_pos + 1..]
776    } else {
777        path
778    };
779
780    if let Some(dot_pos) = filename.rfind('.') {
781        if dot_pos > 0 && dot_pos < filename.len() - 1 {
782            return filename[dot_pos + 1..].to_string();
783        }
784    }
785    String::new()
786}
787
788/// Get the tail (filename) part of a path
789pub fn path_tail(path: &str) -> String {
790    if let Some(pos) = path.rfind('/') {
791        path[pos + 1..].to_string()
792    } else {
793        path.to_string()
794    }
795}
796
797/// Get the head (directory) part of a path
798pub fn path_head(path: &str) -> String {
799    remtpath(path, 1)
800}
801
802/// Case modification modes
803/// Port from CASMOD_* in zsh.h
804#[derive(Clone, Copy, PartialEq, Eq)]
805pub enum CaseMod {
806    Lower,
807    Upper,
808    Caps,
809}
810
811/// Modify case of a string
812/// Port from casemodify() in zsh/Src/subst.c
813pub fn casemodify(s: &str, mode: CaseMod) -> String {
814    match mode {
815        CaseMod::Lower => s.to_lowercase(),
816        CaseMod::Upper => s.to_uppercase(),
817        CaseMod::Caps => {
818            let mut result = String::with_capacity(s.len());
819            let mut cap_next = true;
820            for c in s.chars() {
821                if c.is_whitespace() || !c.is_alphabetic() {
822                    result.push(c);
823                    cap_next = true;
824                } else if cap_next {
825                    for uc in c.to_uppercase() {
826                        result.push(uc);
827                    }
828                    cap_next = false;
829                } else {
830                    for lc in c.to_lowercase() {
831                        result.push(lc);
832                    }
833                }
834            }
835            result
836        }
837    }
838}
839
840/// Convert path to absolute path
841/// Port from chabspath() in zsh/Src/subst.c
842pub fn chabspath(path: &str) -> String {
843    if path.starts_with('/') {
844        return clean_path(path);
845    }
846
847    let cwd = env::current_dir()
848        .map(|p| p.to_string_lossy().to_string())
849        .unwrap_or_else(|_| "/".to_string());
850
851    clean_path(&format!("{}/{}", cwd, path))
852}
853
854/// Clean up path by removing redundant components
855fn clean_path(path: &str) -> String {
856    let mut components: Vec<&str> = Vec::new();
857
858    for part in path.split('/') {
859        match part {
860            "" | "." => continue,
861            ".." => {
862                if !components.is_empty() && components.last() != Some(&"..") {
863                    components.pop();
864                } else if !path.starts_with('/') {
865                    components.push("..");
866                }
867            }
868            p => components.push(p),
869        }
870    }
871
872    if path.starts_with('/') {
873        format!("/{}", components.join("/"))
874    } else if components.is_empty() {
875        ".".to_string()
876    } else {
877        components.join("/")
878    }
879}
880
881/// Perform single substitution (no word splitting)
882/// Port from singsub() in zsh/Src/subst.c
883pub fn singsub(s: &str, params: &HashMap<String, String>) -> Result<String, String> {
884    let opts = SubstOptions::default();
885    subst_string(s, params, &opts)
886}
887
888/// Perform multiple substitution with word splitting
889/// Port from multsub() in zsh/Src/subst.c
890pub fn multsub(s: &str, params: &HashMap<String, String>) -> Result<Vec<String>, String> {
891    let mut opts = SubstOptions::default();
892    opts.word_split = true;
893
894    let expanded = subst_string(s, params, &opts)?;
895
896    // Split on IFS
897    let ifs = params.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n");
898
899    Ok(expanded
900        .split(|c: char| ifs.contains(c))
901        .filter(|s| !s.is_empty())
902        .map(|s| s.to_string())
903        .collect())
904}
905
906/// Untokenize a string (remove internal tokens)
907/// Port from untokenize() in zsh/Src/subst.c
908pub fn untokenize(s: &str) -> String {
909    s.to_string()
910}
911
912/// Remove null arguments
913/// Port from remnulargs() in zsh/Src/subst.c
914pub fn remnulargs(s: &str) -> String {
915    s.to_string()
916}
917
918/// Pad string to specified width (from subst.c dopadding lines 892-1332)
919pub fn dopadding(
920    s: &str,
921    prenum: usize,
922    postnum: usize,
923    preone: Option<&str>,
924    postone: Option<&str>,
925    premul: Option<&str>,
926    postmul: Option<&str>,
927) -> String {
928    let default_pad = " ";
929    let preone = preone.unwrap_or("");
930    let postone = postone.unwrap_or("");
931    let premul = if premul.map(|s| s.is_empty()).unwrap_or(true) {
932        default_pad
933    } else {
934        premul.unwrap()
935    };
936    let postmul = if postmul.map(|s| s.is_empty()).unwrap_or(true) {
937        default_pad
938    } else {
939        postmul.unwrap()
940    };
941
942    let slen = s.chars().count();
943
944    if prenum + postnum == slen {
945        return s.to_string();
946    }
947
948    let mut result = String::new();
949
950    if prenum > 0 {
951        let f = prenum.saturating_sub(slen);
952        if f == 0 {
953            // String is longer than prenum, truncate from left
954            let skip = slen - prenum;
955            result.extend(s.chars().skip(skip));
956        } else {
957            // Need to pad on left
958            let mut pad_needed = f.saturating_sub(preone.chars().count());
959
960            // Add repeated premul padding
961            while pad_needed > 0 {
962                let plen = premul.chars().count();
963                if pad_needed >= plen {
964                    result.push_str(premul);
965                    pad_needed -= plen;
966                } else {
967                    // Partial pad
968                    result.extend(premul.chars().take(pad_needed));
969                    pad_needed = 0;
970                }
971            }
972
973            // Add preone
974            if !preone.is_empty() && f >= preone.chars().count() {
975                result.push_str(preone);
976            } else if !preone.is_empty() {
977                // Truncate preone
978                let skip = preone.chars().count() - f;
979                result.extend(preone.chars().skip(skip));
980            }
981
982            // Add the string
983            result.push_str(s);
984        }
985    } else if postnum > 0 {
986        let f = postnum.saturating_sub(slen);
987        if f == 0 {
988            // String is longer than postnum, truncate from right
989            result.extend(s.chars().take(postnum));
990        } else {
991            // Add the string
992            result.push_str(s);
993
994            // Add postone
995            if !postone.is_empty() {
996                if f >= postone.chars().count() {
997                    result.push_str(postone);
998                } else {
999                    result.extend(postone.chars().take(f));
1000                }
1001            }
1002
1003            // Add repeated postmul padding
1004            let mut pad_needed = f.saturating_sub(postone.chars().count());
1005            while pad_needed > 0 {
1006                let plen = postmul.chars().count();
1007                if pad_needed >= plen {
1008                    result.push_str(postmul);
1009                    pad_needed -= plen;
1010                } else {
1011                    result.extend(postmul.chars().take(pad_needed));
1012                    pad_needed = 0;
1013                }
1014            }
1015        }
1016    } else {
1017        result.push_str(s);
1018    }
1019
1020    result
1021}
1022
1023/// Get delimited string argument (from subst.c get_strarg lines 1346-1417)
1024pub fn get_strarg(s: &str) -> Option<(&str, char)> {
1025    let mut chars = s.chars();
1026    let delim = chars.next()?;
1027
1028    let end_delim = match delim {
1029        '(' => ')',
1030        '[' => ']',
1031        '{' => '}',
1032        '<' => '>',
1033        _ => delim,
1034    };
1035
1036    let rest: String = chars.collect();
1037    if let Some(pos) = rest.find(end_delim) {
1038        Some((&s[1..pos + 1], end_delim))
1039    } else {
1040        None
1041    }
1042}
1043
1044/// Do =foo substitution (from subst.c equalsubstr lines 714-733)
1045pub fn equalsubstr(cmd: &str) -> Option<String> {
1046    crate::utils::find_in_path(cmd).and_then(|p| p.to_str().map(|s| s.to_string()))
1047}
1048
1049/// File substitution - tilde and equals (from subst.c filesubstr lines 736-807)
1050pub fn filesubstr(name: &str, assign: bool) -> Option<String> {
1051    if name.starts_with('~') {
1052        let rest = &name[1..];
1053
1054        // ~ alone
1055        if rest.is_empty() || rest.starts_with('/') {
1056            let home = std::env::var("HOME").unwrap_or_default();
1057            return Some(format!("{}{}", home, rest));
1058        }
1059
1060        // ~+
1061        if rest.starts_with('+') && (rest.len() == 1 || rest.chars().nth(1) == Some('/')) {
1062            let pwd = std::env::var("PWD").unwrap_or_else(|_| ".".to_string());
1063            return Some(format!("{}{}", pwd, &rest[1..]));
1064        }
1065
1066        // ~-
1067        if rest.starts_with('-') && (rest.len() == 1 || rest.chars().nth(1) == Some('/')) {
1068            let oldpwd = std::env::var("OLDPWD").unwrap_or_else(|_| ".".to_string());
1069            return Some(format!("{}{}", oldpwd, &rest[1..]));
1070        }
1071
1072        // ~user
1073        let (user, suffix) = match rest.find('/') {
1074            Some(pos) => (&rest[..pos], &rest[pos..]),
1075            None => (rest, ""),
1076        };
1077
1078        #[cfg(unix)]
1079        {
1080            if let Some(home) = crate::subst::get_user_home(user) {
1081                return Some(format!("{}{}", home, suffix));
1082            }
1083        }
1084    } else if name.starts_with('=') && name.len() > 1 {
1085        // =cmd substitution
1086        if let Some(path) = equalsubstr(&name[1..]) {
1087            return Some(path);
1088        }
1089    }
1090
1091    None
1092}
1093
1094/// Subst eval char - evaluate numeric expression to character (from subst.c substevalchar lines 1489-1520)
1095pub fn substevalchar(expr: &str) -> Option<char> {
1096    let value: i64 = expr.parse().ok()?;
1097    if value < 0 || value > 0x10FFFF {
1098        return None;
1099    }
1100    char::from_u32(value as u32)
1101}
1102
1103/// Check if string is a subscript or length after colon (from subst.c check_colon_subscript lines 1565-1599)
1104pub fn check_colon_subscript(s: &str) -> Option<(String, &str)> {
1105    if s.is_empty() || s.starts_with(|c: char| c.is_alphabetic()) || s.starts_with('&') {
1106        return None;
1107    }
1108
1109    if s.starts_with(':') {
1110        return Some(("0".to_string(), s));
1111    }
1112
1113    // Find the end of the subscript expression
1114    let end = s.find(':').unwrap_or(s.len());
1115    let expr = &s[..end];
1116    let rest = &s[end..];
1117
1118    Some((expr.to_string(), rest))
1119}
1120
1121/// Apply offset and length to array (from subst.c ${PARAM:offset:length} handling)
1122pub fn array_slice(arr: &[String], offset: i64, length: Option<i64>) -> Vec<String> {
1123    let len = arr.len() as i64;
1124
1125    let offset = if offset < 0 {
1126        (len + offset).max(0) as usize
1127    } else {
1128        (offset as usize).min(arr.len())
1129    };
1130
1131    let length = match length {
1132        Some(l) if l < 0 => (len - offset as i64 + l).max(0) as usize,
1133        Some(l) => l.max(0) as usize,
1134        None => arr.len().saturating_sub(offset),
1135    };
1136
1137    arr.iter().skip(offset).take(length).cloned().collect()
1138}
1139
1140/// Apply offset and length to string (from subst.c ${PARAM:offset:length} handling)
1141pub fn string_slice(s: &str, offset: i64, length: Option<i64>) -> String {
1142    let chars: Vec<char> = s.chars().collect();
1143    let len = chars.len() as i64;
1144
1145    let offset = if offset < 0 {
1146        (len + offset).max(0) as usize
1147    } else {
1148        (offset as usize).min(chars.len())
1149    };
1150
1151    let length = match length {
1152        Some(l) if l < 0 => (len - offset as i64 + l).max(0) as usize,
1153        Some(l) => l.max(0) as usize,
1154        None => chars.len().saturating_sub(offset),
1155    };
1156
1157    chars.iter().skip(offset).take(length).collect()
1158}
1159
1160/// Array union (from subst.c ${array|other})
1161pub fn array_union(arr1: &[String], arr2: &[String]) -> Vec<String> {
1162    use std::collections::HashSet;
1163    let set2: HashSet<_> = arr2.iter().collect();
1164
1165    let mut result: Vec<String> = arr1.to_vec();
1166    for item in arr2 {
1167        if !result.contains(item) {
1168            result.push(item.clone());
1169        }
1170    }
1171    result
1172}
1173
1174/// Array intersection (from subst.c ${array*other})
1175pub fn array_intersection(arr1: &[String], arr2: &[String]) -> Vec<String> {
1176    use std::collections::HashSet;
1177    let set2: HashSet<_> = arr2.iter().collect();
1178
1179    arr1.iter()
1180        .filter(|item| set2.contains(item))
1181        .cloned()
1182        .collect()
1183}
1184
1185/// Array difference (from subst.c ${array|other} with negation)
1186pub fn array_difference(arr1: &[String], arr2: &[String]) -> Vec<String> {
1187    use std::collections::HashSet;
1188    let set2: HashSet<_> = arr2.iter().collect();
1189
1190    arr1.iter()
1191        .filter(|item| !set2.contains(item))
1192        .cloned()
1193        .collect()
1194}
1195
1196/// Zip arrays together (from subst.c ${array:^other})
1197pub fn array_zip(arr1: &[String], arr2: &[String], shortest: bool) -> Vec<String> {
1198    let len = if shortest {
1199        arr1.len().min(arr2.len())
1200    } else {
1201        arr1.len().max(arr2.len())
1202    };
1203
1204    let mut result = Vec::with_capacity(len * 2);
1205    for i in 0..len {
1206        let v1 = arr1.get(i % arr1.len()).cloned().unwrap_or_default();
1207        let v2 = arr2.get(i % arr2.len()).cloned().unwrap_or_default();
1208        result.push(v1);
1209        result.push(v2);
1210    }
1211    result
1212}
1213
1214/// Unique array elements (from subst.c (u) flag)
1215pub fn array_unique(arr: &[String]) -> Vec<String> {
1216    use std::collections::HashSet;
1217    let mut seen = HashSet::new();
1218    arr.iter()
1219        .filter(|item| seen.insert(item.as_str()))
1220        .cloned()
1221        .collect()
1222}
1223
1224/// Reverse array (from subst.c (O) flag with 'a')
1225pub fn array_reverse(arr: &[String]) -> Vec<String> {
1226    arr.iter().rev().cloned().collect()
1227}
1228
1229/// Sort array (from subst.c (o) flag)
1230pub fn array_sort(arr: &[String], reverse: bool, numeric: bool) -> Vec<String> {
1231    let mut result = arr.to_vec();
1232    if numeric {
1233        result.sort_by(|a, b| {
1234            let na: f64 = a.parse().unwrap_or(0.0);
1235            let nb: f64 = b.parse().unwrap_or(0.0);
1236            na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal)
1237        });
1238    } else {
1239        result.sort();
1240    }
1241    if reverse {
1242        result.reverse();
1243    }
1244    result
1245}
1246
1247/// Pattern filter (from subst.c ${array:#pattern})
1248pub fn array_filter_pattern(arr: &[String], pattern: &str, invert: bool) -> Vec<String> {
1249    arr.iter()
1250        .filter(|item| {
1251            let matches = crate::glob::pattern_match(pattern, item, false, true);
1252            if invert {
1253                matches
1254            } else {
1255                !matches
1256            }
1257        })
1258        .cloned()
1259        .collect()
1260}
1261
1262/// Search and replace in array (from subst.c ${array/pat/repl})
1263pub fn array_replace(
1264    arr: &[String],
1265    pattern: &str,
1266    replacement: &str,
1267    global: bool,
1268) -> Vec<String> {
1269    arr.iter()
1270        .map(|item| {
1271            if global {
1272                item.replace(pattern, replacement)
1273            } else {
1274                item.replacen(pattern, replacement, 1)
1275            }
1276        })
1277        .collect()
1278}
1279
1280/// Case modification (from subst.c (L), (U) flags)
1281pub fn modify_case(s: &str, mode: CaseMode) -> String {
1282    match mode {
1283        CaseMode::Lower => s.to_lowercase(),
1284        CaseMode::Upper => s.to_uppercase(),
1285        CaseMode::Capitalize => {
1286            let mut chars = s.chars();
1287            match chars.next() {
1288                None => String::new(),
1289                Some(c) => c.to_uppercase().chain(chars).collect(),
1290            }
1291        }
1292        CaseMode::CapitalizeWords => s
1293            .split_whitespace()
1294            .map(|word| {
1295                let mut chars = word.chars();
1296                match chars.next() {
1297                    None => String::new(),
1298                    Some(c) => c.to_uppercase().chain(chars).collect(),
1299                }
1300            })
1301            .collect::<Vec<_>>()
1302            .join(" "),
1303    }
1304}
1305
1306#[derive(Clone, Copy, Debug)]
1307pub enum CaseMode {
1308    Lower,
1309    Upper,
1310    Capitalize,
1311    CapitalizeWords,
1312}
1313
1314/// Type info for parameter (from subst.c (t) flag)
1315pub fn param_type_info(value: &ParamValue) -> String {
1316    use crate::params::{flags, ParamValue};
1317    match value {
1318        ParamValue::Scalar(_) => "scalar".to_string(),
1319        ParamValue::Integer(_) => "integer".to_string(),
1320        ParamValue::Float(_) => "float".to_string(),
1321        ParamValue::Array(_) => "array".to_string(),
1322        ParamValue::Assoc(_) => "association".to_string(),
1323        ParamValue::Unset => "undefined".to_string(),
1324    }
1325}
1326
1327use crate::params::ParamValue;
1328
1329/// Subscript flags handling (from subst.c subscript parsing)
1330#[derive(Default, Clone, Debug)]
1331pub struct SubscriptFlags {
1332    pub reverse: bool,    // (r) flag
1333    pub words: bool,      // (w) flag
1334    pub chars: bool,      // (c) flag
1335    pub match_once: bool, // default vs (R) flag
1336}
1337
1338/// Apply subscript to string (from subst.c getstrvalue)
1339pub fn apply_subscript_string(s: &str, start: i64, end: i64, flags: &SubscriptFlags) -> String {
1340    if flags.words {
1341        let words: Vec<&str> = s.split_whitespace().collect();
1342        return apply_subscript_array(
1343            &words.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
1344            start,
1345            end,
1346        )
1347        .join(" ");
1348    }
1349
1350    let chars: Vec<char> = s.chars().collect();
1351    let len = chars.len() as i64;
1352
1353    let (start, end) = normalize_indices(start, end, len);
1354
1355    chars[start..end].iter().collect()
1356}
1357
1358/// Apply subscript to array (from subst.c getarrvalue)
1359pub fn apply_subscript_array(arr: &[String], start: i64, end: i64) -> Vec<String> {
1360    let len = arr.len() as i64;
1361    let (start, end) = normalize_indices(start, end, len);
1362    arr[start..end].to_vec()
1363}
1364
1365fn normalize_indices(start: i64, end: i64, len: i64) -> (usize, usize) {
1366    let start = if start < 0 { len + start + 1 } else { start };
1367    let end = if end < 0 { len + end + 1 } else { end };
1368    let start = ((start.max(1) - 1) as usize).min(len as usize);
1369    let end = (end.max(0) as usize).min(len as usize);
1370    (start, end.max(start))
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    use super::*;
1376
1377    #[test]
1378    fn test_tilde_expand() {
1379        let opts = SubstOptions::default();
1380        let result = tilde_expand("~", &opts).unwrap();
1381        assert!(!result.starts_with('~'));
1382    }
1383
1384    #[test]
1385    fn test_param_expand_simple() {
1386        let mut params = HashMap::new();
1387        params.insert("FOO".to_string(), "bar".to_string());
1388
1389        let opts = SubstOptions::default();
1390        let result = param_expand("$FOO", &params, &opts).unwrap();
1391        assert_eq!(result, "bar");
1392    }
1393
1394    #[test]
1395    fn test_param_expand_default() {
1396        let params = HashMap::new();
1397        let opts = SubstOptions::default();
1398
1399        let result = param_expand("${UNDEFINED:-default}", &params, &opts).unwrap();
1400        assert_eq!(result, "default");
1401    }
1402
1403    #[test]
1404    fn test_brace_expand_alternatives() {
1405        let results = brace_expand("file.{txt,md,rs}");
1406        assert_eq!(results.len(), 3);
1407        assert!(results.contains(&"file.txt".to_string()));
1408        assert!(results.contains(&"file.md".to_string()));
1409        assert!(results.contains(&"file.rs".to_string()));
1410    }
1411
1412    #[test]
1413    fn test_brace_expand_range() {
1414        let results = brace_expand("file{1..3}");
1415        assert_eq!(results.len(), 3);
1416        assert!(results.contains(&"file1".to_string()));
1417        assert!(results.contains(&"file2".to_string()));
1418        assert!(results.contains(&"file3".to_string()));
1419    }
1420
1421    #[test]
1422    fn test_remtpath() {
1423        assert_eq!(remtpath("/a/b/c", 1), "/a/b");
1424        assert_eq!(remtpath("/a/b/c", 2), "/a");
1425        assert_eq!(remtpath("foo", 1), ".");
1426    }
1427
1428    #[test]
1429    fn test_remlpaths() {
1430        assert_eq!(remlpaths("/a/b/c", 1), "a/b/c");
1431        assert_eq!(remlpaths("a/b/c", 2), "c");
1432    }
1433
1434    #[test]
1435    fn test_remtext() {
1436        assert_eq!(remtext("file.txt"), "file");
1437        assert_eq!(remtext("/path/to/file.txt"), "/path/to/file");
1438        assert_eq!(remtext("noext"), "noext");
1439    }
1440
1441    #[test]
1442    fn test_rembutext() {
1443        assert_eq!(rembutext("file.txt"), "txt");
1444        assert_eq!(rembutext("/path/to/file.rs"), "rs");
1445        assert_eq!(rembutext("noext"), "");
1446    }
1447
1448    #[test]
1449    fn test_casemodify() {
1450        assert_eq!(casemodify("Hello World", CaseMod::Lower), "hello world");
1451        assert_eq!(casemodify("Hello World", CaseMod::Upper), "HELLO WORLD");
1452        assert_eq!(casemodify("hello world", CaseMod::Caps), "Hello World");
1453    }
1454
1455    #[test]
1456    fn test_clean_path() {
1457        assert_eq!(chabspath("/a/b/../c"), "/a/c");
1458        assert_eq!(chabspath("/a/./b/c"), "/a/b/c");
1459    }
1460}