Skip to main content

tome/
script.rs

1use std::{
2    fs::File,
3    io,
4    io::{prelude::*, BufReader, Read},
5    os::unix::fs::PermissionsExt,
6    path::Path,
7    process::{Command, Stdio},
8};
9
10use super::types::CommandType;
11
12const SOURCE_EXTENSION: &str = "source";
13const SOURCED_SCRIPT_PREFIX: &str = ".";
14const SINGLE_QUOTE_ESCAPE_SEQUENCE: &str = r"'\''";
15
16// used to determine if the file is a valid script or not
17pub fn is_tome_script(path: &Path) -> bool {
18    let filename = path
19        .file_name()
20        .unwrap_or_default()
21        .to_str()
22        .unwrap_or_default();
23    if filename.starts_with('.') {
24        return false;
25    }
26    if is_source_file(filename) {
27        return true;
28    }
29    is_executable(path)
30}
31
32pub fn is_source_file(filename: &str) -> bool {
33    Path::new(filename)
34        .extension()
35        .map_or(false, |ext| ext == SOURCE_EXTENSION)
36}
37
38fn is_executable(path: &Path) -> bool {
39    path.metadata()
40        .map(|m| m.permissions().mode() & 0o111 != 0)
41        .unwrap_or(false)
42}
43
44pub fn strip_source_suffix(name: &str) -> &str {
45    name.strip_suffix(".source").unwrap_or(name)
46}
47
48/// Try to resolve a path that may have a `.source` extension on disk.
49/// Returns the resolved path if found, or None.
50pub fn resolve_source_path(path: &Path) -> Option<std::path::PathBuf> {
51    if path.is_file() {
52        return Some(path.to_path_buf());
53    }
54    let source_path = path.with_extension(SOURCE_EXTENSION);
55    if source_path.is_file() {
56        return Some(source_path);
57    }
58    None
59}
60
61/// Any executable script
62/// can be added to be executed, but
63/// It's possible to add metadata
64/// to the script via comments as well.
65pub struct Script {
66    pub help_string: String,
67    /// the string that should be used for
68    /// usage information
69    /// the path the script is located at.
70    pub path: String,
71    /// determines if the script should
72    /// be sourced or not.
73    pub should_source: bool,
74    /// determines if the script should
75    /// have completion invoked or not.
76    pub should_complete: bool,
77    /// the string that should be printed
78    /// when help is requested.
79    pub summary_string: String,
80}
81
82impl Script {
83    pub fn load(path: &str) -> io::Result<Script> {
84        let file = Box::new(File::open(path)?) as Box<dyn Read>;
85        Ok(Script::load_from_buffer(path.to_owned(), file))
86    }
87    pub fn load_from_buffer(path: String, body: Box<dyn Read>) -> Script {
88        let mut buffer = BufReader::new(body);
89        let mut should_complete = false;
90        let should_source = is_source_file(&path);
91        let mut help_string = String::new();
92        let mut summary_string = String::new();
93        let mut line = String::new();
94        let mut consuming_help = false;
95        loop {
96            line.clear();
97            match buffer.read_line(&mut line) {
98                Ok(bytes_read) => {
99                    if bytes_read == 0 {
100                        break;
101                    }
102                }
103                Err(_) => break,
104            }
105            if consuming_help {
106                if line.starts_with("# END HELP") {
107                    consuming_help = false;
108                } else if let Some(rest) = line.strip_prefix("# ") {
109                    // omit first two characters since they are
110                    // signifying continued help.
111                    help_string.push_str(rest);
112                }
113            } else if line.starts_with("# COMPLETE") {
114                should_complete = true;
115            } else if line.starts_with("# START HELP") {
116                consuming_help = true;
117            } else if line.starts_with("# SUMMARY: ") {
118                // 11 = prefix, -1 strips newline
119                summary_string.push_str(&line[11..(line.len() - 1)]);
120            } else if !line.starts_with("#!") {
121                // if a shebang is encountered, we skip.
122                // as it can indicate the command to run the script with.
123                // metadata lines must be consecutive.
124                break;
125            }
126        }
127        Script {
128            help_string,
129            path,
130            should_complete,
131            should_source,
132            summary_string,
133        }
134    }
135
136    // return the appropriate string that should be executed within the
137    // function.
138    pub fn get_execution_body(
139        &self,
140        command_type: CommandType,
141        shell: &str,
142        args: &[&String],
143    ) -> Result<String, String> {
144        match command_type {
145            CommandType::Completion => {
146                if !self.should_complete {
147                    return Ok(String::new());
148                }
149                // in the completion case, we need to execute the script itself.
150                // There's a possible optimization here
151                // if we just inherit parent file descriptors.
152                let mut command = match self.should_source {
153                    true => Command::new(shell),
154                    false => Command::new(self.path.clone()),
155                };
156                if self.should_source {
157                    command.arg(self.path.clone());
158                }
159                command.arg("--complete");
160                let command_output = command.args(args).stdout(Stdio::piped()).output();
161                match command_output {
162                    Ok(output) => match String::from_utf8(output.stdout) {
163                        Err(error) => Err(format!(
164                            "unable to parse completion results as a utf8 string: {}",
165                            error
166                        )),
167                        Ok(result) => Ok(result),
168                    },
169                    // TODO: it's hard to get output from a completion call.
170                    // possible to print to stderr?
171                    Err(result) => Err(format!("completion called failed: {}", result)),
172                }
173            }
174            CommandType::Execute => {
175                let prefix: &[&str] = if self.should_source {
176                    &[SOURCED_SCRIPT_PREFIX, &self.path]
177                } else {
178                    &[&self.path]
179                };
180                let escaped_command_string: Vec<String> = prefix
181                    .iter()
182                    .copied()
183                    .chain(args.iter().map(|arg| arg.as_str()))
184                    .map(quote_for_shell)
185                    .collect();
186                let mut final_command = escaped_command_string.join(" ");
187                // handle edge case where a source with zero arguments
188                // should not pass in the script directory.
189                if self.should_source
190                    && args.is_empty()
191                    && (shell.ends_with("bash") || shell.ends_with("zsh"))
192                {
193                    final_command = format!("set --; {}", final_command);
194                }
195                Ok(final_command)
196            }
197        }
198    }
199}
200
201pub fn quote_for_shell(arg: &str) -> String {
202    format!("'{}'", arg.replace('\'', SINGLE_QUOTE_ESCAPE_SEQUENCE))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_quote_for_shell_plain() {
211        assert_eq!(quote_for_shell("hello"), "'hello'");
212    }
213
214    #[test]
215    fn test_quote_for_shell_with_single_quote() {
216        assert_eq!(
217            quote_for_shell("I'm the developer"),
218            r"'I'\''m the developer'"
219        );
220    }
221
222    #[test]
223    fn test_quote_for_shell_with_surrounding_single_quotes() {
224        assert_eq!(quote_for_shell("'hello'"), r"''\''hello'\'''");
225    }
226
227    #[test]
228    fn test_quote_for_shell_isolated_single_quote() {
229        assert_eq!(quote_for_shell("'"), r"''\'''");
230    }
231
232    #[test]
233    fn test_quote_for_shell_with_double_quotes() {
234        assert_eq!(quote_for_shell("\"hello\""), "'\"hello\"'");
235    }
236
237    #[test]
238    fn test_quote_for_shell_empty() {
239        assert_eq!(quote_for_shell(""), "''");
240    }
241
242    #[test]
243    fn test_quote_for_shell_special_characters() {
244        assert_eq!(
245            quote_for_shell("$PATH `rm -rf` \\ \""),
246            "'$PATH `rm -rf` \\ \"'"
247        );
248    }
249
250    #[test]
251    fn test_get_execution_body_escapes_single_quotes() {
252        let script = Script {
253            help_string: String::new(),
254            path: String::from("/path/to/quotes"),
255            should_complete: false,
256            should_source: false,
257            summary_string: String::new(),
258        };
259        let arg1 = String::from("13");
260        let arg2 = String::from("I'm the developer");
261        let arg3 = String::from("'hello'");
262        let result =
263            script.get_execution_body(CommandType::Execute, "bash", &[&arg1, &arg2, &arg3]);
264        assert_eq!(
265            result,
266            Ok(String::from(
267                r#"'/path/to/quotes' '13' 'I'\''m the developer' ''\''hello'\'''"#
268            ))
269        );
270    }
271
272    #[test]
273    fn test_get_execution_body_sourced_escapes_single_quotes() {
274        let script = Script {
275            help_string: String::new(),
276            path: String::from("/path/to/quotes.source"),
277            should_complete: false,
278            should_source: true,
279            summary_string: String::new(),
280        };
281        let arg1 = String::from("I'm the developer");
282        let result = script.get_execution_body(CommandType::Execute, "bash", &[&arg1]);
283        assert_eq!(
284            result,
285            Ok(String::from(
286                r#"'.' '/path/to/quotes.source' 'I'\''m the developer'"#
287            ))
288        );
289    }
290}