nu_command/filesystem/
rm.rs

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