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,
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![
37                (Type::Nothing, Type::Nothing),
38                (
39                    Type::Nothing,
40                    Type::Table(
41                        [
42                            ("path".to_string(), Type::String),
43                            ("deleted".to_string(), Type::Bool),
44                            (
45                                "error".to_string(),
46                                Type::OneOf([Type::Nothing, Type::String].into()),
47                            ),
48                        ]
49                        .into(),
50                    ),
51                ),
52            ])
53            .rest("paths", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The file paths(s) to remove.")
54            .switch(
55                "trash",
56                "Move to the platform's trash instead of permanently deleting. not used on android and ios.",
57                Some('t'),
58            )
59            .switch(
60                "permanent",
61                "Delete permanently, ignoring the 'always_trash' config option. always enabled on android and ios.",
62                Some('p'),
63            )
64            .switch("recursive", "Delete subdirectories recursively.", Some('r'))
65            .switch("force", "Suppress error when no file.", Some('f'))
66            .switch("verbose", "Return a table for each processed path.", Some('v'))
67            .switch("interactive", "Ask user to confirm action.", Some('i'))
68            .switch(
69                "interactive-once",
70                "Ask user to confirm action only once.",
71                Some('I'),
72            )
73            .switch("all", "Remove hidden files if '*' is provided.", Some('a'))
74            .category(Category::FileSystem)
75    }
76
77    fn run(
78        &self,
79        engine_state: &EngineState,
80        stack: &mut Stack,
81        call: &Call,
82        _input: PipelineData,
83    ) -> Result<PipelineData, ShellError> {
84        rm(engine_state, stack, call)
85    }
86
87    fn examples(&self) -> Vec<Example<'_>> {
88        let mut examples = vec![Example {
89            description: "Delete, or move a file to the trash (based on the 'always_trash' config option).",
90            example: "rm file.txt",
91            result: None,
92        }];
93        if TRASH_SUPPORTED {
94            examples.append(&mut vec![
95                Example {
96                    description: "Move a file to the trash.",
97                    example: "rm --trash file.txt",
98                    result: None,
99                },
100                Example {
101                    description:
102                        "Delete a file permanently, even if the 'always_trash' config option is true.",
103                    example: "rm --permanent file.txt",
104                    result: None,
105                },
106            ]);
107        }
108        examples.push(Example {
109            description: "Delete a file, ignoring 'file not found' errors.",
110            example: "rm --force file.txt",
111            result: None,
112        });
113        examples.push(Example {
114            description: "Delete all 0KB files in the current directory.",
115            example: "ls | where size == 0KB and type == file | each { rm $in.name } | null",
116            result: None,
117        });
118        examples
119    }
120}
121
122fn rm(
123    engine_state: &EngineState,
124    stack: &mut Stack,
125    call: &Call,
126) -> Result<PipelineData, ShellError> {
127    let trash = call.has_flag(engine_state, stack, "trash")?;
128    let permanent = call.has_flag(engine_state, stack, "permanent")?;
129    let recursive = call.has_flag(engine_state, stack, "recursive")?;
130    let force = call.has_flag(engine_state, stack, "force")?;
131    let verbose = call.has_flag(engine_state, stack, "verbose")?;
132    let interactive = call.has_flag(engine_state, stack, "interactive")?;
133    let interactive_once = call.has_flag(engine_state, stack, "interactive-once")? && !interactive;
134    let all = call.has_flag(engine_state, stack, "all")?;
135    let mut paths = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
136
137    if paths.is_empty() {
138        return Err(ShellError::MissingParameter {
139            param_name: "requires file paths".to_string(),
140            span: call.head,
141        });
142    }
143
144    let mut unique_argument_check = None;
145
146    let currentdir_path = engine_state.cwd(Some(stack))?.into_std_path_buf();
147
148    let home: Option<String> = nu_path::home_dir().map(|path| {
149        {
150            if path.exists() {
151                nu_path::absolute_with(&path, &currentdir_path).unwrap_or(path.into())
152            } else {
153                path.into()
154            }
155        }
156        .to_string_lossy()
157        .into()
158    });
159
160    for (idx, path) in paths.clone().into_iter().enumerate() {
161        if let Some(ref home) = home
162            && expand_path_with(path.item.as_ref(), &currentdir_path, path.item.is_expand())
163                .to_string_lossy()
164                .as_ref()
165                == home.as_str()
166        {
167            unique_argument_check = Some(path.span);
168        }
169        let corrected_path = path.map(NuGlob::strip_ansi_string_unlikely);
170        let _ = std::mem::replace(&mut paths[idx], corrected_path);
171    }
172
173    let span = call.head;
174    let rm_always_trash = stack.get_config(engine_state).rm.always_trash;
175
176    if !TRASH_SUPPORTED {
177        if rm_always_trash {
178            return Err(ShellError::Generic(GenericError::new(
179                "Cannot execute `rm`; the current configuration specifies \
180                    `always_trash = true`, but the current nu executable was not \
181                    built with feature `trash_support`.",
182                "trash required to be true but not supported",
183                span,
184            )));
185        } else if trash {
186            return Err(ShellError::Generic(GenericError::new(
187                "Cannot execute `rm` with option `--trash`; feature `trash-support` not enabled or on an unsupported platform",
188                "this option is only available if nu is built with the `trash-support` feature and the platform supports trash",
189                span,
190            )));
191        }
192    }
193
194    if paths.is_empty() {
195        return Err(ShellError::Generic(GenericError::new(
196            "rm requires target paths",
197            "needs parameter",
198            span,
199        )));
200    }
201
202    if unique_argument_check.is_some() && !(interactive_once || interactive) {
203        return Err(ShellError::Generic(GenericError::new(
204            "You are trying to remove your home dir",
205            "If you really want to remove your home dir, please use -I or -i",
206            unique_argument_check.unwrap_or(call.head),
207        )));
208    }
209
210    let mut all_targets: HashMap<PathBuf, Span> = HashMap::new();
211    let mut verbose_out = Vec::new();
212    let mut first_error = None;
213    let mut collect_rm_result =
214        |path: String, deleted: bool, err: Option<ShellError>, span: Span| {
215            if verbose {
216                let error = err.map_or_else(
217                    || Value::nothing(span),
218                    |err| Value::string(err.to_string(), span),
219                );
220                verbose_out.push(
221                    record! {
222                        "path" => Value::string(path, span),
223                        "deleted" => Value::bool(deleted, span),
224                        "error" => error,
225                    }
226                    .into_value(span),
227                );
228            } else if let Some(err) = err {
229                if first_error.is_none() {
230                    first_error = Some(err);
231                } else {
232                    nu_protocol::report_shell_error(Some(&*stack), engine_state, &err);
233                }
234            }
235        };
236
237    let glob_options = if all {
238        None
239    } else {
240        let glob_options = MatchOptions {
241            require_literal_leading_dot: true,
242            ..Default::default()
243        };
244
245        Some(glob_options)
246    };
247
248    for target in paths {
249        let path = expand_path_with(
250            target.item.as_ref(),
251            &currentdir_path,
252            target.item.is_expand(),
253        );
254        let path_string = path.to_string_lossy().into_owned();
255
256        // `rm link/` where `link` is a symlink to a directory
257        // should error with "is a directory" rather than removing the underlying directory.
258        // A trailing slash forces dereferencing the symlink, which rm should not do.
259        let raw = target.item.as_ref();
260        if raw.ends_with('/') || raw.ends_with(std::path::MAIN_SEPARATOR) {
261            let without_sep = raw
262                .trim_end_matches('/')
263                .trim_end_matches(std::path::MAIN_SEPARATOR);
264            let symlink_check =
265                expand_path_with(without_sep, &currentdir_path, target.item.is_expand());
266            if symlink_check
267                .symlink_metadata()
268                .map(|m| m.file_type().is_symlink())
269                .unwrap_or(false)
270            {
271                collect_rm_result(
272                    raw.to_string(),
273                    false,
274                    Some(ShellError::Generic(
275                        GenericError::new(
276                            format!("Cannot remove `{}`: is a directory", raw),
277                            "is a directory",
278                            target.span,
279                        )
280                        .with_help(format!(
281                            "use `rm {}` without the trailing slash to remove the symlink itself",
282                            without_sep
283                        )),
284                    )),
285                    call.head,
286                );
287                continue;
288            }
289        }
290
291        if currentdir_path.to_string_lossy() == path.to_string_lossy()
292            || currentdir_path.starts_with(format!("{}{}", target.item, std::path::MAIN_SEPARATOR))
293        {
294            collect_rm_result(
295                path_string,
296                false,
297                Some(ShellError::Generic(GenericError::new(
298                    "Cannot remove any parent directory",
299                    "cannot remove any parent directory",
300                    target.span,
301                ))),
302                call.head,
303            );
304            continue;
305        }
306
307        match nu_engine::glob_from(
308            &target,
309            &currentdir_path,
310            call.head,
311            glob_options,
312            engine_state.signals().clone(),
313        ) {
314            Ok(files) => {
315                let mut target_exists = false;
316                let mut saw_glob_error = false;
317
318                for file in files.1 {
319                    match file {
320                        Ok(f) => {
321                            // It is not appropriate to try and remove the
322                            // current directory or its parent when using
323                            // glob patterns.
324                            let name = f.display().to_string();
325                            if name.ends_with("/.") || name.ends_with("/..") {
326                                continue;
327                            }
328
329                            target_exists = true;
330                            all_targets
331                                .entry(nu_path::expand_path_with(
332                                    f,
333                                    &currentdir_path,
334                                    target.item.is_expand(),
335                                ))
336                                .or_insert_with(|| target.span);
337                        }
338                        Err(e) => {
339                            saw_glob_error = true;
340                            collect_rm_result(
341                                path_string.clone(),
342                                false,
343                                Some(ShellError::Generic(GenericError::new(
344                                    format!("Could not remove {:}", path.to_string_lossy()),
345                                    e.to_string(),
346                                    target.span,
347                                ))),
348                                call.head,
349                            );
350                        }
351                    }
352                }
353
354                if !target_exists && !saw_glob_error && !force {
355                    collect_rm_result(
356                        path_string,
357                        false,
358                        Some(ShellError::Generic(GenericError::new(
359                            "File(s) not found",
360                            "File(s) not found",
361                            target.span,
362                        ))),
363                        call.head,
364                    );
365                }
366            }
367            Err(e) => {
368                // glob_from may canonicalize path and return an error when a directory is not found
369                // nushell should suppress the error if `--force` is used.
370                if !(force
371                    && matches!(
372                        e,
373                        ShellError::Io(IoError {
374                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound, ..),
375                            ..
376                        })
377                    ))
378                {
379                    collect_rm_result(path_string, false, Some(e), call.head);
380                }
381            }
382        };
383    }
384
385    if interactive_once && !all_targets.is_empty() {
386        let (interaction, confirmed) = try_interaction(
387            interactive_once,
388            format!("rm: remove {} files? ", all_targets.len()),
389        );
390        if let Err(e) = interaction {
391            return Err(ShellError::Generic(GenericError::new_internal(
392                format!("Error during interaction: {e:}"),
393                "could not move",
394            )));
395        } else if !confirmed {
396            if verbose {
397                return Ok(PipelineData::value(
398                    Value::list(verbose_out, call.head),
399                    None,
400                ));
401            }
402            return first_error.map_or_else(|| Ok(PipelineData::empty()), Err);
403        }
404    }
405
406    for (f, span) in all_targets {
407        engine_state.signals().check(&call.head)?;
408
409        let is_empty = || match f.read_dir() {
410            Ok(mut p) => p.next().is_none(),
411            Err(_) => false,
412        };
413
414        if let Ok(metadata) = f.symlink_metadata() {
415            #[cfg(unix)]
416            let is_socket = metadata.file_type().is_socket();
417            #[cfg(unix)]
418            let is_fifo = metadata.file_type().is_fifo();
419
420            #[cfg(not(unix))]
421            let is_socket = false;
422            #[cfg(not(unix))]
423            let is_fifo = false;
424
425            if metadata.is_file()
426                || metadata.file_type().is_symlink()
427                || recursive
428                || is_socket
429                || is_fifo
430                || is_empty()
431            {
432                let (interaction, confirmed) = try_interaction(
433                    interactive,
434                    format!("rm: remove '{}'? ", f.to_string_lossy()),
435                );
436
437                let result = if let Err(e) = interaction {
438                    Err(Error::other(&*e.to_string()))
439                } else if interactive && !confirmed {
440                    Ok(())
441                } else if TRASH_SUPPORTED && (trash || (rm_always_trash && !permanent)) {
442                    #[cfg(all(
443                        feature = "trash-support",
444                        not(any(target_os = "android", target_os = "ios"))
445                    ))]
446                    {
447                        trash::delete(&f).map_err(|e: trash::Error| {
448                            Error::other(format!("{e:?}\nTry '--permanent' flag"))
449                        })
450                    }
451
452                    // Should not be reachable since we error earlier if
453                    // these options are given on an unsupported platform
454                    #[cfg(any(
455                        not(feature = "trash-support"),
456                        target_os = "android",
457                        target_os = "ios"
458                    ))]
459                    {
460                        unreachable!()
461                    }
462                } else if metadata.is_symlink() {
463                    // In Windows, symlink pointing to a directory can be removed using
464                    // std::fs::remove_dir instead of std::fs::remove_file.
465                    #[cfg(windows)]
466                    {
467                        use std::os::windows::fs::FileTypeExt;
468                        if metadata.file_type().is_symlink_dir() {
469                            std::fs::remove_dir(&f)
470                        } else {
471                            std::fs::remove_file(&f)
472                        }
473                    }
474
475                    #[cfg(not(windows))]
476                    std::fs::remove_file(&f)
477                } else if metadata.is_file() || is_socket || is_fifo {
478                    std::fs::remove_file(&f)
479                } else {
480                    std::fs::remove_dir_all(&f)
481                };
482
483                if let Err(e) = result {
484                    let original_error = e.to_string();
485                    let error = ShellError::Io(IoError::new_with_additional_context(
486                        e,
487                        span,
488                        f.clone(),
489                        original_error,
490                    ));
491                    collect_rm_result(
492                        f.to_string_lossy().into_owned(),
493                        false,
494                        Some(error),
495                        call.head,
496                    );
497                } else {
498                    collect_rm_result(
499                        f.to_string_lossy().into_owned(),
500                        !interactive || confirmed,
501                        None,
502                        call.head,
503                    );
504                }
505            } else {
506                let error = format!("Cannot remove {:}. try --recursive", f.to_string_lossy());
507                collect_rm_result(
508                    f.to_string_lossy().into_owned(),
509                    false,
510                    Some(ShellError::Generic(GenericError::new(
511                        error,
512                        "cannot remove non-empty directory",
513                        span,
514                    ))),
515                    call.head,
516                );
517            }
518        } else {
519            let error = format!("no such file or directory: {:}", f.to_string_lossy());
520            collect_rm_result(
521                f.to_string_lossy().into_owned(),
522                false,
523                Some(ShellError::Generic(GenericError::new(
524                    error,
525                    "no such file or directory",
526                    span,
527                ))),
528                call.head,
529            );
530        }
531    }
532
533    if verbose {
534        Ok(PipelineData::value(
535            Value::list(verbose_out, call.head),
536            None,
537        ))
538    } else if let Some(err) = first_error {
539        Err(err)
540    } else {
541        Ok(PipelineData::empty())
542    }
543}