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