Skip to main content

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
236        // `rm link/` where `link` is a symlink to a directory
237        // should error with "is a directory" rather than removing the underlying directory.
238        // A trailing slash forces dereferencing the symlink, which rm should not do.
239        let raw = target.item.as_ref();
240        if raw.ends_with('/') || raw.ends_with(std::path::MAIN_SEPARATOR) {
241            let without_sep = raw
242                .trim_end_matches('/')
243                .trim_end_matches(std::path::MAIN_SEPARATOR);
244            let symlink_check =
245                expand_path_with(without_sep, &currentdir_path, target.item.is_expand());
246            if symlink_check
247                .symlink_metadata()
248                .map(|m| m.file_type().is_symlink())
249                .unwrap_or(false)
250            {
251                return Err(ShellError::Generic(
252                    GenericError::new(
253                        format!("Cannot remove `{}`: is a directory", raw),
254                        "is a directory",
255                        target.span,
256                    )
257                    .with_help(format!(
258                        "use `rm {}` without the trailing slash to remove the symlink itself",
259                        without_sep
260                    )),
261                ));
262            }
263        }
264
265        if currentdir_path.to_string_lossy() == path.to_string_lossy()
266            || currentdir_path.starts_with(format!("{}{}", target.item, std::path::MAIN_SEPARATOR))
267        {
268            return Err(ShellError::Generic(GenericError::new(
269                "Cannot remove any parent directory",
270                "cannot remove any parent directory",
271                target.span,
272            )));
273        }
274
275        match nu_engine::glob_from(
276            &target,
277            &currentdir_path,
278            call.head,
279            glob_options,
280            engine_state.signals().clone(),
281        ) {
282            Ok(files) => {
283                for file in files.1 {
284                    match file {
285                        Ok(f) => {
286                            if !target_exists {
287                                target_exists = true;
288                            }
289
290                            // It is not appropriate to try and remove the
291                            // current directory or its parent when using
292                            // glob patterns.
293                            let name = f.display().to_string();
294                            if name.ends_with("/.") || name.ends_with("/..") {
295                                continue;
296                            }
297
298                            all_targets
299                                .entry(nu_path::expand_path_with(
300                                    f,
301                                    &currentdir_path,
302                                    target.item.is_expand(),
303                                ))
304                                .or_insert_with(|| target.span);
305                        }
306                        Err(e) => {
307                            return Err(ShellError::Generic(GenericError::new(
308                                format!("Could not remove {:}", path.to_string_lossy()),
309                                e.to_string(),
310                                target.span,
311                            )));
312                        }
313                    }
314                }
315
316                // Target doesn't exists
317                if !target_exists && empty_span.eq(&call.head) {
318                    empty_span = target.span;
319                }
320            }
321            Err(e) => {
322                // glob_from may canonicalize path and return an error when a directory is not found
323                // nushell should suppress the error if `--force` is used.
324                if !(force
325                    && matches!(
326                        e,
327                        ShellError::Io(IoError {
328                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound, ..),
329                            ..
330                        })
331                    ))
332                {
333                    return Err(e);
334                }
335            }
336        };
337    }
338
339    if all_targets.is_empty() && !force {
340        return Err(ShellError::Generic(GenericError::new(
341            "File(s) not found",
342            "File(s) not found",
343            targets_span,
344        )));
345    }
346
347    if interactive_once {
348        let (interaction, confirmed) = try_interaction(
349            interactive_once,
350            format!("rm: remove {} files? ", all_targets.len()),
351        );
352        if let Err(e) = interaction {
353            return Err(ShellError::Generic(GenericError::new_internal(
354                format!("Error during interaction: {e:}"),
355                "could not move",
356            )));
357        } else if !confirmed {
358            return Ok(PipelineData::empty());
359        }
360    }
361
362    let iter = all_targets.into_iter().map(move |(f, span)| {
363        let is_empty = || match f.read_dir() {
364            Ok(mut p) => p.next().is_none(),
365            Err(_) => false,
366        };
367
368        if let Ok(metadata) = f.symlink_metadata() {
369            #[cfg(unix)]
370            let is_socket = metadata.file_type().is_socket();
371            #[cfg(unix)]
372            let is_fifo = metadata.file_type().is_fifo();
373
374            #[cfg(not(unix))]
375            let is_socket = false;
376            #[cfg(not(unix))]
377            let is_fifo = false;
378
379            if metadata.is_file()
380                || metadata.file_type().is_symlink()
381                || recursive
382                || is_socket
383                || is_fifo
384                || is_empty()
385            {
386                let (interaction, confirmed) = try_interaction(
387                    interactive,
388                    format!("rm: remove '{}'? ", f.to_string_lossy()),
389                );
390
391                let result = if let Err(e) = interaction {
392                    Err(Error::other(&*e.to_string()))
393                } else if interactive && !confirmed {
394                    Ok(())
395                } else if TRASH_SUPPORTED && (trash || (rm_always_trash && !permanent)) {
396                    #[cfg(all(
397                        feature = "trash-support",
398                        not(any(target_os = "android", target_os = "ios"))
399                    ))]
400                    {
401                        trash::delete(&f).map_err(|e: trash::Error| {
402                            Error::other(format!("{e:?}\nTry '--permanent' flag"))
403                        })
404                    }
405
406                    // Should not be reachable since we error earlier if
407                    // these options are given on an unsupported platform
408                    #[cfg(any(
409                        not(feature = "trash-support"),
410                        target_os = "android",
411                        target_os = "ios"
412                    ))]
413                    {
414                        unreachable!()
415                    }
416                } else if metadata.is_symlink() {
417                    // In Windows, symlink pointing to a directory can be removed using
418                    // std::fs::remove_dir instead of std::fs::remove_file.
419                    #[cfg(windows)]
420                    {
421                        use std::os::windows::fs::FileTypeExt;
422                        if metadata.file_type().is_symlink_dir() {
423                            std::fs::remove_dir(&f)
424                        } else {
425                            std::fs::remove_file(&f)
426                        }
427                    }
428
429                    #[cfg(not(windows))]
430                    std::fs::remove_file(&f)
431                } else if metadata.is_file() || is_socket || is_fifo {
432                    std::fs::remove_file(&f)
433                } else {
434                    std::fs::remove_dir_all(&f)
435                };
436
437                if let Err(e) = result {
438                    let original_error = e.to_string();
439                    Err(ShellError::Io(IoError::new_with_additional_context(
440                        e,
441                        span,
442                        f,
443                        original_error,
444                    )))
445                } else if verbose {
446                    let msg = if interactive && !confirmed {
447                        "not deleted"
448                    } else {
449                        "deleted"
450                    };
451                    Ok(Some(format!("{} {:}", msg, f.to_string_lossy())))
452                } else {
453                    Ok(None)
454                }
455            } else {
456                let error = format!("Cannot remove {:}. try --recursive", f.to_string_lossy());
457                Err(ShellError::Generic(GenericError::new(
458                    error,
459                    "cannot remove non-empty directory",
460                    span,
461                )))
462            }
463        } else {
464            let error = format!("no such file or directory: {:}", f.to_string_lossy());
465            Err(ShellError::Generic(GenericError::new(
466                error,
467                "no such file or directory",
468                span,
469            )))
470        }
471    });
472
473    let mut cmd_result = Ok(PipelineData::empty());
474    for result in iter {
475        engine_state.signals().check(&call.head)?;
476        match result {
477            Ok(None) => {}
478            Ok(Some(msg)) => eprintln!("{msg}"),
479            Err(err) => {
480                if !(force
481                    && matches!(
482                        err,
483                        ShellError::Io(IoError {
484                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound, ..),
485                            ..
486                        })
487                    ))
488                {
489                    if cmd_result.is_ok() {
490                        cmd_result = Err(err);
491                    } else {
492                        report_shell_error(Some(stack), engine_state, &err)
493                    }
494                }
495            }
496        }
497    }
498
499    cmd_result
500}