Skip to main content

shannon_nu_command/filesystem/
rm.rs

1use super::util::try_interaction;
2use nu_engine::command_prelude::*;
3use nu_glob::MatchOptions;
4use nu_path::expand_path_with;
5use nu_protocol::{
6    NuGlob, report_shell_error,
7    shell_error::{self, generic::GenericError, io::IoError},
8};
9#[cfg(unix)]
10use std::os::unix::prelude::FileTypeExt;
11use std::{collections::HashMap, io::Error, path::PathBuf};
12
13const TRASH_SUPPORTED: bool = cfg!(all(
14    feature = "trash-support",
15    not(any(target_os = "android", target_os = "ios"))
16));
17
18#[derive(Clone)]
19pub struct Rm;
20
21impl Command for Rm {
22    fn name(&self) -> &str {
23        "rm"
24    }
25
26    fn description(&self) -> &str {
27        "Remove files and directories."
28    }
29
30    fn search_terms(&self) -> Vec<&str> {
31        vec!["delete", "remove", "del", "erase"]
32    }
33
34    fn signature(&self) -> Signature {
35        Signature::build("rm")
36            .input_output_types(vec![(Type::Nothing, Type::Nothing)])
37            .rest("paths", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The file paths(s) to remove.")
38            .switch(
39                "trash",
40                "Move to the platform's trash instead of permanently deleting. not used on android and ios.",
41                Some('t'),
42            )
43            .switch(
44                "permanent",
45                "Delete permanently, ignoring the 'always_trash' config option. always enabled on android and ios.",
46                Some('p'),
47            )
48            .switch("recursive", "Delete subdirectories recursively.", Some('r'))
49            .switch("force", "Suppress error when no file.", Some('f'))
50            .switch("verbose", "Print names of deleted files.", Some('v'))
51            .switch("interactive", "Ask user to confirm action.", Some('i'))
52            .switch(
53                "interactive-once",
54                "Ask user to confirm action only once.",
55                Some('I'),
56            )
57            .switch("all", "Remove hidden files if '*' is provided.", Some('a'))
58            .category(Category::FileSystem)
59    }
60
61    fn run(
62        &self,
63        engine_state: &EngineState,
64        stack: &mut Stack,
65        call: &Call,
66        _input: PipelineData,
67    ) -> Result<PipelineData, ShellError> {
68        rm(engine_state, stack, call)
69    }
70
71    fn examples(&self) -> Vec<Example<'_>> {
72        let mut examples = vec![Example {
73            description: "Delete, or move a file to the trash (based on the 'always_trash' config option).",
74            example: "rm file.txt",
75            result: None,
76        }];
77        if TRASH_SUPPORTED {
78            examples.append(&mut vec![
79                Example {
80                    description: "Move a file to the trash.",
81                    example: "rm --trash file.txt",
82                    result: None,
83                },
84                Example {
85                    description:
86                        "Delete a file permanently, even if the 'always_trash' config option is true.",
87                    example: "rm --permanent file.txt",
88                    result: None,
89                },
90            ]);
91        }
92        examples.push(Example {
93            description: "Delete a file, ignoring 'file not found' errors.",
94            example: "rm --force file.txt",
95            result: None,
96        });
97        examples.push(Example {
98            description: "Delete all 0KB files in the current directory.",
99            example: "ls | where size == 0KB and type == file | each { rm $in.name } | null",
100            result: None,
101        });
102        examples
103    }
104}
105
106fn rm(
107    engine_state: &EngineState,
108    stack: &mut Stack,
109    call: &Call,
110) -> Result<PipelineData, ShellError> {
111    let trash = call.has_flag(engine_state, stack, "trash")?;
112    let permanent = call.has_flag(engine_state, stack, "permanent")?;
113    let recursive = call.has_flag(engine_state, stack, "recursive")?;
114    let force = call.has_flag(engine_state, stack, "force")?;
115    let verbose = call.has_flag(engine_state, stack, "verbose")?;
116    let interactive = call.has_flag(engine_state, stack, "interactive")?;
117    let interactive_once = call.has_flag(engine_state, stack, "interactive-once")? && !interactive;
118    let all = call.has_flag(engine_state, stack, "all")?;
119    let mut paths = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
120
121    if paths.is_empty() {
122        return Err(ShellError::MissingParameter {
123            param_name: "requires file paths".to_string(),
124            span: call.head,
125        });
126    }
127
128    let mut unique_argument_check = None;
129
130    let currentdir_path = engine_state.cwd(Some(stack))?.into_std_path_buf();
131
132    let home: Option<String> = nu_path::home_dir().map(|path| {
133        {
134            if path.exists() {
135                nu_path::absolute_with(&path, &currentdir_path).unwrap_or(path.into())
136            } else {
137                path.into()
138            }
139        }
140        .to_string_lossy()
141        .into()
142    });
143
144    for (idx, path) in paths.clone().into_iter().enumerate() {
145        if let Some(ref home) = home
146            && expand_path_with(path.item.as_ref(), &currentdir_path, path.item.is_expand())
147                .to_string_lossy()
148                .as_ref()
149                == home.as_str()
150        {
151            unique_argument_check = Some(path.span);
152        }
153        let corrected_path = Spanned {
154            item: match path.item {
155                NuGlob::DoNotExpand(s) => {
156                    NuGlob::DoNotExpand(nu_utils::strip_ansi_string_unlikely(s))
157                }
158                NuGlob::Expand(s) => NuGlob::Expand(nu_utils::strip_ansi_string_unlikely(s)),
159            },
160            span: path.span,
161        };
162        let _ = std::mem::replace(&mut paths[idx], corrected_path);
163    }
164
165    let span = call.head;
166    let rm_always_trash = stack.get_config(engine_state).rm.always_trash;
167
168    if !TRASH_SUPPORTED {
169        if rm_always_trash {
170            return Err(ShellError::Generic(GenericError::new(
171                "Cannot execute `rm`; the current configuration specifies \
172                    `always_trash = true`, but the current nu executable was not \
173                    built with feature `trash_support`.",
174                "trash required to be true but not supported",
175                span,
176            )));
177        } else if trash {
178            return Err(ShellError::Generic(GenericError::new(
179                "Cannot execute `rm` with option `--trash`; feature `trash-support` not enabled or on an unsupported platform",
180                "this option is only available if nu is built with the `trash-support` feature and the platform supports trash",
181                span,
182            )));
183        }
184    }
185
186    if paths.is_empty() {
187        return Err(ShellError::Generic(GenericError::new(
188            "rm requires target paths",
189            "needs parameter",
190            span,
191        )));
192    }
193
194    if unique_argument_check.is_some() && !(interactive_once || interactive) {
195        return Err(ShellError::Generic(GenericError::new(
196            "You are trying to remove your home dir",
197            "If you really want to remove your home dir, please use -I or -i",
198            unique_argument_check.unwrap_or(call.head),
199        )));
200    }
201
202    let targets_span = Span::new(
203        paths
204            .iter()
205            .map(|x| x.span.start)
206            .min()
207            .expect("targets were empty"),
208        paths
209            .iter()
210            .map(|x| x.span.end)
211            .max()
212            .expect("targets were empty"),
213    );
214
215    let (mut target_exists, mut empty_span) = (false, call.head);
216    let mut all_targets: HashMap<PathBuf, Span> = HashMap::new();
217
218    let glob_options = if all {
219        None
220    } else {
221        let glob_options = MatchOptions {
222            require_literal_leading_dot: true,
223            ..Default::default()
224        };
225
226        Some(glob_options)
227    };
228
229    for target in paths {
230        let path = expand_path_with(
231            target.item.as_ref(),
232            &currentdir_path,
233            target.item.is_expand(),
234        );
235        if currentdir_path.to_string_lossy() == path.to_string_lossy()
236            || currentdir_path.starts_with(format!("{}{}", target.item, std::path::MAIN_SEPARATOR))
237        {
238            return Err(ShellError::Generic(GenericError::new(
239                "Cannot remove any parent directory",
240                "cannot remove any parent directory",
241                target.span,
242            )));
243        }
244
245        match nu_engine::glob_from(
246            &target,
247            &currentdir_path,
248            call.head,
249            glob_options,
250            engine_state.signals().clone(),
251        ) {
252            Ok(files) => {
253                for file in files.1 {
254                    match file {
255                        Ok(f) => {
256                            if !target_exists {
257                                target_exists = true;
258                            }
259
260                            // It is not appropriate to try and remove the
261                            // current directory or its parent when using
262                            // glob patterns.
263                            let name = f.display().to_string();
264                            if name.ends_with("/.") || name.ends_with("/..") {
265                                continue;
266                            }
267
268                            all_targets
269                                .entry(nu_path::expand_path_with(
270                                    f,
271                                    &currentdir_path,
272                                    target.item.is_expand(),
273                                ))
274                                .or_insert_with(|| target.span);
275                        }
276                        Err(e) => {
277                            return Err(ShellError::Generic(GenericError::new(
278                                format!("Could not remove {:}", path.to_string_lossy()),
279                                e.to_string(),
280                                target.span,
281                            )));
282                        }
283                    }
284                }
285
286                // Target doesn't exists
287                if !target_exists && empty_span.eq(&call.head) {
288                    empty_span = target.span;
289                }
290            }
291            Err(e) => {
292                // glob_from may canonicalize path and return an error when a directory is not found
293                // nushell should suppress the error if `--force` is used.
294                if !(force
295                    && matches!(
296                        e,
297                        ShellError::Io(IoError {
298                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound, ..),
299                            ..
300                        })
301                    ))
302                {
303                    return Err(e);
304                }
305            }
306        };
307    }
308
309    if all_targets.is_empty() && !force {
310        return Err(ShellError::Generic(GenericError::new(
311            "File(s) not found",
312            "File(s) not found",
313            targets_span,
314        )));
315    }
316
317    if interactive_once {
318        let (interaction, confirmed) = try_interaction(
319            interactive_once,
320            format!("rm: remove {} files? ", all_targets.len()),
321        );
322        if let Err(e) = interaction {
323            return Err(ShellError::Generic(GenericError::new_internal(
324                format!("Error during interaction: {e:}"),
325                "could not move",
326            )));
327        } else if !confirmed {
328            return Ok(PipelineData::empty());
329        }
330    }
331
332    let iter = all_targets.into_iter().map(move |(f, span)| {
333        let is_empty = || match f.read_dir() {
334            Ok(mut p) => p.next().is_none(),
335            Err(_) => false,
336        };
337
338        if let Ok(metadata) = f.symlink_metadata() {
339            #[cfg(unix)]
340            let is_socket = metadata.file_type().is_socket();
341            #[cfg(unix)]
342            let is_fifo = metadata.file_type().is_fifo();
343
344            #[cfg(not(unix))]
345            let is_socket = false;
346            #[cfg(not(unix))]
347            let is_fifo = false;
348
349            if metadata.is_file()
350                || metadata.file_type().is_symlink()
351                || recursive
352                || is_socket
353                || is_fifo
354                || is_empty()
355            {
356                let (interaction, confirmed) = try_interaction(
357                    interactive,
358                    format!("rm: remove '{}'? ", f.to_string_lossy()),
359                );
360
361                let result = if let Err(e) = interaction {
362                    Err(Error::other(&*e.to_string()))
363                } else if interactive && !confirmed {
364                    Ok(())
365                } else if TRASH_SUPPORTED && (trash || (rm_always_trash && !permanent)) {
366                    #[cfg(all(
367                        feature = "trash-support",
368                        not(any(target_os = "android", target_os = "ios"))
369                    ))]
370                    {
371                        trash::delete(&f).map_err(|e: trash::Error| {
372                            Error::other(format!("{e:?}\nTry '--permanent' flag"))
373                        })
374                    }
375
376                    // Should not be reachable since we error earlier if
377                    // these options are given on an unsupported platform
378                    #[cfg(any(
379                        not(feature = "trash-support"),
380                        target_os = "android",
381                        target_os = "ios"
382                    ))]
383                    {
384                        unreachable!()
385                    }
386                } else if metadata.is_symlink() {
387                    // In Windows, symlink pointing to a directory can be removed using
388                    // std::fs::remove_dir instead of std::fs::remove_file.
389                    #[cfg(windows)]
390                    {
391                        use std::os::windows::fs::FileTypeExt;
392                        if metadata.file_type().is_symlink_dir() {
393                            std::fs::remove_dir(&f)
394                        } else {
395                            std::fs::remove_file(&f)
396                        }
397                    }
398
399                    #[cfg(not(windows))]
400                    std::fs::remove_file(&f)
401                } else if metadata.is_file() || is_socket || is_fifo {
402                    std::fs::remove_file(&f)
403                } else {
404                    std::fs::remove_dir_all(&f)
405                };
406
407                if let Err(e) = result {
408                    let original_error = e.to_string();
409                    Err(ShellError::Io(IoError::new_with_additional_context(
410                        e,
411                        span,
412                        f,
413                        original_error,
414                    )))
415                } else if verbose {
416                    let msg = if interactive && !confirmed {
417                        "not deleted"
418                    } else {
419                        "deleted"
420                    };
421                    Ok(Some(format!("{} {:}", msg, f.to_string_lossy())))
422                } else {
423                    Ok(None)
424                }
425            } else {
426                let error = format!("Cannot remove {:}. try --recursive", f.to_string_lossy());
427                Err(ShellError::Generic(GenericError::new(
428                    error,
429                    "cannot remove non-empty directory",
430                    span,
431                )))
432            }
433        } else {
434            let error = format!("no such file or directory: {:}", f.to_string_lossy());
435            Err(ShellError::Generic(GenericError::new(
436                error,
437                "no such file or directory",
438                span,
439            )))
440        }
441    });
442
443    let mut cmd_result = Ok(PipelineData::empty());
444    for result in iter {
445        engine_state.signals().check(&call.head)?;
446        match result {
447            Ok(None) => {}
448            Ok(Some(msg)) => eprintln!("{msg}"),
449            Err(err) => {
450                if !(force
451                    && matches!(
452                        err,
453                        ShellError::Io(IoError {
454                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound, ..),
455                            ..
456                        })
457                    ))
458                {
459                    if cmd_result.is_ok() {
460                        cmd_result = Err(err);
461                    } else {
462                        report_shell_error(Some(stack), engine_state, &err)
463                    }
464                }
465            }
466        }
467    }
468
469    cmd_result
470}