Skip to main content

sed_rs/
cli.rs

1use clap::Parser;
2use std::path::PathBuf;
3
4#[derive(Parser, Debug, Default)]
5#[command(
6    name = "sed",
7    version,
8    about = "A GNU-compatible stream editor implemented in Rust",
9    long_about = "A GNU-compatible stream editor implemented in Rust.\n\n\
10        Uses Rust regex syntax (similar to PCRE/ERE). The -E/-r flags are \
11        accepted for compatibility but are no-ops since extended regex \
12        syntax is always used.",
13    max_term_width = 100
14)]
15pub struct Options {
16    /// Suppress automatic printing of pattern space
17    #[arg(short = 'n', long = "quiet", alias = "silent")]
18    pub quiet: bool,
19
20    /// Add the script to the commands to be executed
21    #[arg(short = 'e', long = "expression", value_name = "SCRIPT")]
22    pub expressions: Vec<String>,
23
24    /// Add the contents of script-file to the commands to be executed
25    #[arg(short = 'f', long = "file", value_name = "SCRIPT-FILE")]
26    pub script_files: Vec<PathBuf>,
27
28    /// Edit files in place (makes backup if SUFFIX supplied).
29    /// Use --in-place=SUFFIX or -iSUFFIX (no space) for backups.
30    #[arg(
31        long = "in-place",
32        value_name = "SUFFIX",
33        num_args = 0..=1,
34        default_missing_value = "",
35        require_equals = true
36    )]
37    pub in_place: Option<String>,
38
39    /// Use extended regular expressions (accepted for compatibility; always enabled)
40    #[arg(short = 'E', short_alias = 'r', long = "regexp-extended")]
41    pub extended_regexp: bool,
42
43    /// Consider files as separate rather than as a single continuous stream
44    #[arg(short = 's', long = "separate")]
45    pub separate: bool,
46
47    /// Separate lines by NUL characters
48    #[arg(short = 'z', long = "null-data")]
49    pub null_data: bool,
50
51    /// [SCRIPT] [INPUT-FILE...]
52    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
53    pub args: Vec<String>,
54}
55
56impl Options {
57    /// Extract the sed script and input file paths from the parsed options.
58    ///
59    /// If -e or -f was used, all positional args are files.
60    /// Otherwise, the first positional arg is the script and the rest are files.
61    pub fn script_and_files(&self) -> crate::Result<(String, Vec<PathBuf>)> {
62        let mut scripts = self.expressions.clone();
63
64        for path in &self.script_files {
65            let content = std::fs::read_to_string(path).map_err(|e| {
66                crate::Error::Parse(format!(
67                    "couldn't read script file '{}': {}",
68                    path.display(),
69                    e
70                ))
71            })?;
72            scripts.push(content);
73        }
74
75        if scripts.is_empty() {
76            // First positional arg is the script, rest are files
77            if let Some((first, rest)) = self.args.split_first() {
78                Ok((
79                    first.clone(),
80                    rest.iter().map(PathBuf::from).collect(),
81                ))
82            } else {
83                Err(crate::Error::Parse(
84                    "no script provided. Usage: sed [OPTIONS] SCRIPT [FILE...]"
85                        .into(),
86                ))
87            }
88        } else {
89            Ok((
90                scripts.join("\n"),
91                self.args.iter().map(PathBuf::from).collect(),
92            ))
93        }
94    }
95}
96
97/// Pre-process raw CLI arguments to handle GNU sed's `-i[SUFFIX]` syntax.
98///
99/// Transforms:
100///   -i        → --in-place
101///   -i.bak    → --in-place=.bak
102///   -iSUFFIX  → --in-place=SUFFIX
103///
104/// This is needed because clap can't natively handle optional short-flag
105/// values that must be attached (no space) like GNU sed's -i.
106pub fn preprocess_args(raw: impl Iterator<Item = String>) -> Vec<String> {
107    let mut result = Vec::new();
108    let mut saw_double_dash = false;
109
110    for arg in raw {
111        if saw_double_dash {
112            result.push(arg);
113            continue;
114        }
115
116        if arg == "--" {
117            saw_double_dash = true;
118            result.push(arg);
119            continue;
120        }
121
122        if arg == "-i" {
123            result.push("--in-place".to_string());
124        } else if arg.starts_with("-i") && !arg.starts_with("--") {
125            let suffix = &arg[2..];
126            result.push(format!("--in-place={suffix}"));
127        } else {
128            result.push(arg);
129        }
130    }
131
132    result
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use clap::CommandFactory;
139
140    #[test]
141    fn debug_assert() {
142        Options::command().debug_assert();
143    }
144
145    #[test]
146    fn preprocess_i_flag() {
147        let args = vec![
148            "sed".into(),
149            "-i".into(),
150            "s/foo/bar/".into(),
151            "file".into(),
152        ];
153        let processed = preprocess_args(args.into_iter());
154        assert_eq!(
155            processed,
156            vec!["sed", "--in-place", "s/foo/bar/", "file"]
157        );
158    }
159
160    #[test]
161    fn preprocess_i_suffix() {
162        let args =
163            vec!["sed".into(), "-i.bak".into(), "s/foo/bar/".into()];
164        let processed = preprocess_args(args.into_iter());
165        assert_eq!(
166            processed,
167            vec!["sed", "--in-place=.bak", "s/foo/bar/"]
168        );
169    }
170}