mirror of
https://github.com/nushell/nushell.git
synced 2025-07-08 02:17:22 +02:00
<!--
if this PR closes one or more issues, you can automatically link the PR
with
them by using one of the [*linking
keywords*](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword),
e.g.
- this PR should close #xxxx
- fixes #xxxx
you can also mention related issues, PRs or discussions!
-->
# Description
<!--
Thank you for improving Nushell. Please, check our [contributing
guide](../CONTRIBUTING.md) and talk to the core team before making major
changes.
Description of your pull request goes here. **Provide examples and/or
screenshots** if your changes affect the user experience.
-->
As mentioned in #10698, we have too many `ShellError` variants, with
some even overlapping in meaning. This PR simplifies and improves I/O
error handling by restructuring `ShellError` related to I/O issues.
Previously, `ShellError::IOError` only contained a message string,
making it convenient but overly generic. It was widely used without
providing spans (#4323).
This PR introduces a new `ShellError::Io` variant that consolidates
multiple I/O-related errors (except for `ShellError::NetworkFailure`,
which remains distinct for now). The new `ShellError::Io` variant
replaces the following:
- `FileNotFound`
- `FileNotFoundCustom`
- `IOInterrupted`
- `IOError`
- `IOErrorSpanned`
- `NotADirectory`
- `DirectoryNotFound`
- `MoveNotPossible`
- `CreateNotPossible`
- `ChangeAccessTimeNotPossible`
- `ChangeModifiedTimeNotPossible`
- `RemoveNotPossible`
- `ReadingFile`
## The `IoError`
`IoError` includes the following fields:
1. **`kind`**: Extends `std::io::ErrorKind` to specify the type of I/O
error without needing new `ShellError` variants. This aligns with the
approach used in `std::io::Error`. This adds a second dimension to error
reporting by combining the `kind` field with `ShellError` variants,
making it easier to describe errors in more detail. As proposed by
@kubouch in [#design-discussion on
Discord](https://discord.com/channels/601130461678272522/615329862395101194/1323699197165178930),
this helps reduce the number of `ShellError` variants. In the error
report, the `kind` field is displayed as the "source" of the error,
e.g., "I/O error," followed by the specific kind of I/O error.
2. **`span`**: A non-optional field to encourage providing spans for
better error reporting (#4323).
3. **`path`**: Optional `PathBuf` to give context about the file or
directory involved in the error (#7695). If provided, it’s shown as a
help entry in error reports.
4. **`additional_context`**: Allows adding custom messages when the
span, kind, and path are insufficient. This is rendered in the error
report at the labeled span.
5. **`location`**: Sometimes, I/O errors occur in the engine itself and
are not caused directly by user input. In such cases, if we don’t have a
span and must set it to `Span::unknown()`, we need another way to
reference the error. For this, the `location` field uses the new
`Location` struct, which records the Rust file and line number where the
error occurred. This ensures that we at least know the Rust code
location that failed, helping with debugging. To make this work, a new
`location!` macro was added, which retrieves `file!`, `line!`, and
`column!` values accurately. If `Location::new` is used directly, it
issues a warning to remind developers to use the macro instead, ensuring
consistent and correct usage.
### Constructor Behavior
`IoError` provides five constructor methods:
- `new` and `new_with_additional_context`: Used for errors caused by
user input and require a valid (non-unknown) span to ensure precise
error reporting.
- `new_internal` and `new_internal_with_path`: Used for internal errors
where a span is not available. These methods require additional context
and the `Location` struct to pinpoint the source of the error in the
engine code.
- `factory`: Returns a closure that maps an `std::io::Error` to an
`IoError`. This is useful for handling multiple I/O errors that share
the same span and path, streamlining error handling in such cases.
## New Report Look
This is simulation how the I/O errors look like (the `open crates` is
simulated to show how internal errors are referenced now):

## `Span::test_data()`
To enable better testing, `Span::test_data()` now returns a value
distinct from `Span::unknown()`. Both `Span::test_data()` and
`Span::unknown()` refer to invalid source code, but having a separate
value for test data helps identify issues during testing while keeping
spans unique.
## Cursed Sneaky Error Transfers
I removed the conversions between `std::io::Error` and `ShellError` as
they often removed important information and were used too broadly to
handle I/O errors. This also removed the problematic implementation
found here:
7ea4895513/crates/nu-protocol/src/errors/shell_error.rs (L1534-L1583)
which hid some downcasting from I/O errors and made it hard to trace
where `ShellError` was converted into `std::io::Error`. To address this,
I introduced a new struct called `ShellErrorBridge`, which explicitly
defines this transfer behavior. With `ShellErrorBridge`, we can now
easily grep the codebase to locate and manage such conversions.
## Miscellaneous
- Removed the OS error added in #14640, as it’s no longer needed.
- Improved error messages in `glob_from` (#14679).
- Trying to open a directory with `open` caused a permissions denied
error (it's just what the OS provides). I added a `is_dir` check to
provide a better error in that case.
# User-Facing Changes
<!-- List of all changes that impact the user experience here. This
helps us keep track of breaking changes. -->
- Error outputs now include more detailed information and are formatted
differently, including updated error codes.
- The structure of `ShellError` has changed, requiring plugin authors
and embedders to update their implementations.
# Tests + Formatting
<!--
Don't forget to add tests that cover your changes.
Make sure you've run and fixed any issues with these commands:
- `cargo fmt --all -- --check` to check standard code formatting (`cargo
fmt --all` applies these changes)
- `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to
check that you're using the standard code style
- `cargo test --workspace` to check that all tests pass (on Windows make
sure to [enable developer
mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging))
- `cargo run -- -c "use toolkit.nu; toolkit test stdlib"` to run the
tests for the standard library
> **Note**
> from `nushell` you can also use the `toolkit` as follows
> ```bash
> use toolkit.nu # or use an `env_change` hook to activate it
automatically
> toolkit check pr
> ```
-->
I updated tests to account for the new I/O error structure and
formatting changes.
# After Submitting
<!-- If your PR had any user-facing changes, update [the
documentation](https://github.com/nushell/nushell.github.io) after the
PR is merged, if necessary. This will help us keep the docs up to date.
-->
This PR closes #7695 and closes #14892 and partially addresses #4323 and
#10698.
---------
Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com>
472 lines
16 KiB
Rust
472 lines
16 KiB
Rust
use super::util::try_interaction;
|
|
#[allow(deprecated)]
|
|
use nu_engine::{command_prelude::*, env::current_dir};
|
|
use nu_glob::MatchOptions;
|
|
use nu_path::expand_path_with;
|
|
use nu_protocol::{
|
|
report_shell_error,
|
|
shell_error::{self, io::IoError},
|
|
NuGlob,
|
|
};
|
|
#[cfg(unix)]
|
|
use std::os::unix::prelude::FileTypeExt;
|
|
use std::{
|
|
collections::HashMap,
|
|
io::{Error, ErrorKind},
|
|
path::PathBuf,
|
|
};
|
|
|
|
const TRASH_SUPPORTED: bool = cfg!(all(
|
|
feature = "trash-support",
|
|
not(any(target_os = "android", target_os = "ios"))
|
|
));
|
|
|
|
#[derive(Clone)]
|
|
pub struct Rm;
|
|
|
|
impl Command for Rm {
|
|
fn name(&self) -> &str {
|
|
"rm"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Remove files and directories."
|
|
}
|
|
|
|
fn search_terms(&self) -> Vec<&str> {
|
|
vec!["delete", "remove"]
|
|
}
|
|
|
|
fn signature(&self) -> Signature {
|
|
Signature::build("rm")
|
|
.input_output_types(vec![(Type::Nothing, Type::Nothing)])
|
|
.rest("paths", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The file paths(s) to remove.")
|
|
.switch(
|
|
"trash",
|
|
"move to the platform's trash instead of permanently deleting. not used on android and ios",
|
|
Some('t'),
|
|
)
|
|
.switch(
|
|
"permanent",
|
|
"delete permanently, ignoring the 'always_trash' config option. always enabled on android and ios",
|
|
Some('p'),
|
|
)
|
|
.switch("recursive", "delete subdirectories recursively", Some('r'))
|
|
.switch("force", "suppress error when no file", Some('f'))
|
|
.switch("verbose", "print names of deleted files", Some('v'))
|
|
.switch("interactive", "ask user to confirm action", Some('i'))
|
|
.switch(
|
|
"interactive-once",
|
|
"ask user to confirm action only once",
|
|
Some('I'),
|
|
)
|
|
.category(Category::FileSystem)
|
|
}
|
|
|
|
fn run(
|
|
&self,
|
|
engine_state: &EngineState,
|
|
stack: &mut Stack,
|
|
call: &Call,
|
|
_input: PipelineData,
|
|
) -> Result<PipelineData, ShellError> {
|
|
rm(engine_state, stack, call)
|
|
}
|
|
|
|
fn examples(&self) -> Vec<Example> {
|
|
let mut examples = vec![Example {
|
|
description:
|
|
"Delete, or move a file to the trash (based on the 'always_trash' config option)",
|
|
example: "rm file.txt",
|
|
result: None,
|
|
}];
|
|
if TRASH_SUPPORTED {
|
|
examples.append(&mut vec![
|
|
Example {
|
|
description: "Move a file to the trash",
|
|
example: "rm --trash file.txt",
|
|
result: None,
|
|
},
|
|
Example {
|
|
description:
|
|
"Delete a file permanently, even if the 'always_trash' config option is true",
|
|
example: "rm --permanent file.txt",
|
|
result: None,
|
|
},
|
|
]);
|
|
}
|
|
examples.push(Example {
|
|
description: "Delete a file, ignoring 'file not found' errors",
|
|
example: "rm --force file.txt",
|
|
result: None,
|
|
});
|
|
examples.push(Example {
|
|
description: "Delete all 0KB files in the current directory",
|
|
example: "ls | where size == 0KB and type == file | each { rm $in.name } | null",
|
|
result: None,
|
|
});
|
|
examples
|
|
}
|
|
}
|
|
|
|
fn rm(
|
|
engine_state: &EngineState,
|
|
stack: &mut Stack,
|
|
call: &Call,
|
|
) -> Result<PipelineData, ShellError> {
|
|
let trash = call.has_flag(engine_state, stack, "trash")?;
|
|
let permanent = call.has_flag(engine_state, stack, "permanent")?;
|
|
let recursive = call.has_flag(engine_state, stack, "recursive")?;
|
|
let force = call.has_flag(engine_state, stack, "force")?;
|
|
let verbose = call.has_flag(engine_state, stack, "verbose")?;
|
|
let interactive = call.has_flag(engine_state, stack, "interactive")?;
|
|
let interactive_once = call.has_flag(engine_state, stack, "interactive-once")? && !interactive;
|
|
|
|
let mut paths = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
|
|
|
|
if paths.is_empty() {
|
|
return Err(ShellError::MissingParameter {
|
|
param_name: "requires file paths".to_string(),
|
|
span: call.head,
|
|
});
|
|
}
|
|
|
|
let mut unique_argument_check = None;
|
|
|
|
#[allow(deprecated)]
|
|
let currentdir_path = current_dir(engine_state, stack)?;
|
|
|
|
let home: Option<String> = nu_path::home_dir().map(|path| {
|
|
{
|
|
if path.exists() {
|
|
nu_path::canonicalize_with(&path, ¤tdir_path).unwrap_or(path.into())
|
|
} else {
|
|
path.into()
|
|
}
|
|
}
|
|
.to_string_lossy()
|
|
.into()
|
|
});
|
|
|
|
for (idx, path) in paths.clone().into_iter().enumerate() {
|
|
if let Some(ref home) = home {
|
|
if expand_path_with(path.item.as_ref(), ¤tdir_path, path.item.is_expand())
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
== home.as_str()
|
|
{
|
|
unique_argument_check = Some(path.span);
|
|
}
|
|
}
|
|
let corrected_path = Spanned {
|
|
item: match path.item {
|
|
NuGlob::DoNotExpand(s) => {
|
|
NuGlob::DoNotExpand(nu_utils::strip_ansi_string_unlikely(s))
|
|
}
|
|
NuGlob::Expand(s) => NuGlob::Expand(nu_utils::strip_ansi_string_unlikely(s)),
|
|
},
|
|
span: path.span,
|
|
};
|
|
let _ = std::mem::replace(&mut paths[idx], corrected_path);
|
|
}
|
|
|
|
let span = call.head;
|
|
let rm_always_trash = stack.get_config(engine_state).rm.always_trash;
|
|
|
|
if !TRASH_SUPPORTED {
|
|
if rm_always_trash {
|
|
return Err(ShellError::GenericError {
|
|
error: "Cannot execute `rm`; the current configuration specifies \
|
|
`always_trash = true`, but the current nu executable was not \
|
|
built with feature `trash_support`."
|
|
.into(),
|
|
msg: "trash required to be true but not supported".into(),
|
|
span: Some(span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
} else if trash {
|
|
return Err(ShellError::GenericError{
|
|
error: "Cannot execute `rm` with option `--trash`; feature `trash-support` not enabled or on an unsupported platform"
|
|
.into(),
|
|
msg: "this option is only available if nu is built with the `trash-support` feature and the platform supports trash"
|
|
.into(),
|
|
span: Some(span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
}
|
|
|
|
if paths.is_empty() {
|
|
return Err(ShellError::GenericError {
|
|
error: "rm requires target paths".into(),
|
|
msg: "needs parameter".into(),
|
|
span: Some(span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
|
|
if unique_argument_check.is_some() && !(interactive_once || interactive) {
|
|
return Err(ShellError::GenericError {
|
|
error: "You are trying to remove your home dir".into(),
|
|
msg: "If you really want to remove your home dir, please use -I or -i".into(),
|
|
span: unique_argument_check,
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
|
|
let targets_span = Span::new(
|
|
paths
|
|
.iter()
|
|
.map(|x| x.span.start)
|
|
.min()
|
|
.expect("targets were empty"),
|
|
paths
|
|
.iter()
|
|
.map(|x| x.span.end)
|
|
.max()
|
|
.expect("targets were empty"),
|
|
);
|
|
|
|
let (mut target_exists, mut empty_span) = (false, call.head);
|
|
let mut all_targets: HashMap<PathBuf, Span> = HashMap::new();
|
|
|
|
for target in paths {
|
|
let path = expand_path_with(
|
|
target.item.as_ref(),
|
|
¤tdir_path,
|
|
target.item.is_expand(),
|
|
);
|
|
if currentdir_path.to_string_lossy() == path.to_string_lossy()
|
|
|| currentdir_path.starts_with(format!("{}{}", target.item, std::path::MAIN_SEPARATOR))
|
|
{
|
|
return Err(ShellError::GenericError {
|
|
error: "Cannot remove any parent directory".into(),
|
|
msg: "cannot remove any parent directory".into(),
|
|
span: Some(target.span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
|
|
match nu_engine::glob_from(
|
|
&target,
|
|
¤tdir_path,
|
|
call.head,
|
|
Some(MatchOptions {
|
|
require_literal_leading_dot: true,
|
|
..Default::default()
|
|
}),
|
|
) {
|
|
Ok(files) => {
|
|
for file in files.1 {
|
|
match file {
|
|
Ok(f) => {
|
|
if !target_exists {
|
|
target_exists = true;
|
|
}
|
|
|
|
// It is not appropriate to try and remove the
|
|
// current directory or its parent when using
|
|
// glob patterns.
|
|
let name = f.display().to_string();
|
|
if name.ends_with("/.") || name.ends_with("/..") {
|
|
continue;
|
|
}
|
|
|
|
all_targets
|
|
.entry(nu_path::expand_path_with(
|
|
f,
|
|
¤tdir_path,
|
|
target.item.is_expand(),
|
|
))
|
|
.or_insert_with(|| target.span);
|
|
}
|
|
Err(e) => {
|
|
return Err(ShellError::GenericError {
|
|
error: format!("Could not remove {:}", path.to_string_lossy()),
|
|
msg: e.to_string(),
|
|
span: Some(target.span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Target doesn't exists
|
|
if !target_exists && empty_span.eq(&call.head) {
|
|
empty_span = target.span;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// glob_from may canonicalize path and return an error when a directory is not found
|
|
// nushell should suppress the error if `--force` is used.
|
|
if !(force
|
|
&& matches!(
|
|
e,
|
|
ShellError::Io(IoError {
|
|
kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound),
|
|
..
|
|
})
|
|
))
|
|
{
|
|
return Err(e);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
if all_targets.is_empty() && !force {
|
|
return Err(ShellError::GenericError {
|
|
error: "File(s) not found".into(),
|
|
msg: "File(s) not found".into(),
|
|
span: Some(targets_span),
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
}
|
|
|
|
if interactive_once {
|
|
let (interaction, confirmed) = try_interaction(
|
|
interactive_once,
|
|
format!("rm: remove {} files? ", all_targets.len()),
|
|
);
|
|
if let Err(e) = interaction {
|
|
return Err(ShellError::GenericError {
|
|
error: format!("Error during interaction: {e:}"),
|
|
msg: "could not move".into(),
|
|
span: None,
|
|
help: None,
|
|
inner: vec![],
|
|
});
|
|
} else if !confirmed {
|
|
return Ok(PipelineData::Empty);
|
|
}
|
|
}
|
|
|
|
let iter = all_targets.into_iter().map(move |(f, span)| {
|
|
let is_empty = || match f.read_dir() {
|
|
Ok(mut p) => p.next().is_none(),
|
|
Err(_) => false,
|
|
};
|
|
|
|
if let Ok(metadata) = f.symlink_metadata() {
|
|
#[cfg(unix)]
|
|
let is_socket = metadata.file_type().is_socket();
|
|
#[cfg(unix)]
|
|
let is_fifo = metadata.file_type().is_fifo();
|
|
|
|
#[cfg(not(unix))]
|
|
let is_socket = false;
|
|
#[cfg(not(unix))]
|
|
let is_fifo = false;
|
|
|
|
if metadata.is_file()
|
|
|| metadata.file_type().is_symlink()
|
|
|| recursive
|
|
|| is_socket
|
|
|| is_fifo
|
|
|| is_empty()
|
|
{
|
|
let (interaction, confirmed) = try_interaction(
|
|
interactive,
|
|
format!("rm: remove '{}'? ", f.to_string_lossy()),
|
|
);
|
|
|
|
let result = if let Err(e) = interaction {
|
|
Err(Error::new(ErrorKind::Other, &*e.to_string()))
|
|
} else if interactive && !confirmed {
|
|
Ok(())
|
|
} else if TRASH_SUPPORTED && (trash || (rm_always_trash && !permanent)) {
|
|
#[cfg(all(
|
|
feature = "trash-support",
|
|
not(any(target_os = "android", target_os = "ios"))
|
|
))]
|
|
{
|
|
trash::delete(&f).map_err(|e: trash::Error| {
|
|
Error::new(ErrorKind::Other, format!("{e:?}\nTry '--permanent' flag"))
|
|
})
|
|
}
|
|
|
|
// Should not be reachable since we error earlier if
|
|
// these options are given on an unsupported platform
|
|
#[cfg(any(
|
|
not(feature = "trash-support"),
|
|
target_os = "android",
|
|
target_os = "ios"
|
|
))]
|
|
{
|
|
unreachable!()
|
|
}
|
|
} else if metadata.is_symlink() {
|
|
// In Windows, symlink pointing to a directory can be removed using
|
|
// std::fs::remove_dir instead of std::fs::remove_file.
|
|
#[cfg(windows)]
|
|
{
|
|
f.metadata().and_then(|metadata| {
|
|
if metadata.is_dir() {
|
|
std::fs::remove_dir(&f)
|
|
} else {
|
|
std::fs::remove_file(&f)
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
std::fs::remove_file(&f)
|
|
} else if metadata.is_file() || is_socket || is_fifo {
|
|
std::fs::remove_file(&f)
|
|
} else {
|
|
std::fs::remove_dir_all(&f)
|
|
};
|
|
|
|
if let Err(e) = result {
|
|
Err(ShellError::Io(IoError::new(e.kind(), span, f)))
|
|
} else if verbose {
|
|
let msg = if interactive && !confirmed {
|
|
"not deleted"
|
|
} else {
|
|
"deleted"
|
|
};
|
|
Ok(Some(format!("{} {:}", msg, f.to_string_lossy())))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
let error = format!("Cannot remove {:}. try --recursive", f.to_string_lossy());
|
|
Err(ShellError::GenericError {
|
|
error,
|
|
msg: "cannot remove non-empty directory".into(),
|
|
span: Some(span),
|
|
help: None,
|
|
inner: vec![],
|
|
})
|
|
}
|
|
} else {
|
|
let error = format!("no such file or directory: {:}", f.to_string_lossy());
|
|
Err(ShellError::GenericError {
|
|
error,
|
|
msg: "no such file or directory".into(),
|
|
span: Some(span),
|
|
help: None,
|
|
inner: vec![],
|
|
})
|
|
}
|
|
});
|
|
|
|
for result in iter {
|
|
engine_state.signals().check(call.head)?;
|
|
match result {
|
|
Ok(None) => {}
|
|
Ok(Some(msg)) => eprintln!("{msg}"),
|
|
Err(err) => report_shell_error(engine_state, &err),
|
|
}
|
|
}
|
|
|
|
Ok(PipelineData::empty())
|
|
}
|