Overhaul the plugin cache file with a new msgpack+brotli format (#12579)

# Description

- Plugin signatures are now saved to `plugin.msgpackz`, which is
brotli-compressed MessagePack.
- The file is updated incrementally, rather than writing all plugin
commands in the engine every time.
- The file always contains the result of the `Signature` call to the
plugin, even if commands were removed.
- Invalid data for a particular plugin just causes an error to be
reported, but the rest of the plugins can still be parsed

# User-Facing Changes

- The plugin file has a different filename, and it's not a nushell
script.
- The default `plugin.nu` file will be automatically migrated the first
time, but not other plugin config files.
- We don't currently provide any utilities that could help edit this
file, beyond `plugin add` and `plugin rm`
  - `from msgpackz`, `to msgpackz` could also help
- New commands: `plugin add`, `plugin rm`

# Tests + Formatting

Tests added for the format and for the invalid handling.

- 🟢 `toolkit fmt`
- 🟢 `toolkit clippy`
- 🟢 `toolkit test`
- 🟢 `toolkit test stdlib`

# After Submitting

- [ ] Check for documentation changes
- [ ] Definitely needs release notes
This commit is contained in:
Devyn Cairns 2024-04-21 05:36:26 -07:00 committed by GitHub
parent 6cba7c6b40
commit 2595f31541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1462 additions and 211 deletions

41
Cargo.lock generated
View File

@ -520,7 +520,18 @@ checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
"brotli-decompressor 2.5.1",
]
[[package]]
name = "brotli"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19483b140a7ac7174d34b5a581b406c64f84da5409d3e09cf4fff604f9270e67"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor 4.0.0",
]
[[package]]
@ -533,6 +544,16 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "brotli-decompressor"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6221fe77a248b9117d431ad93761222e1cf8ff282d9d1d5d9f53d6299a1cf76"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.9.1"
@ -2861,6 +2882,7 @@ dependencies = [
"nu-cmd-dataframe",
"nu-cmd-extra",
"nu-cmd-lang",
"nu-cmd-plugin",
"nu-command",
"nu-engine",
"nu-explore",
@ -2914,6 +2936,7 @@ dependencies = [
"nu-engine",
"nu-parser",
"nu-path",
"nu-plugin",
"nu-protocol",
"nu-test-support",
"nu-utils",
@ -3000,6 +3023,17 @@ dependencies = [
"shadow-rs",
]
[[package]]
name = "nu-cmd-plugin"
version = "0.92.3"
dependencies = [
"itertools 0.12.1",
"nu-engine",
"nu-path",
"nu-plugin",
"nu-protocol",
]
[[package]]
name = "nu-color-config"
version = "0.92.3"
@ -3262,6 +3296,7 @@ dependencies = [
name = "nu-protocol"
version = "0.92.3"
dependencies = [
"brotli 5.0.0",
"byte-unit",
"chrono",
"chrono-humanize",
@ -3274,6 +3309,8 @@ dependencies = [
"nu-test-support",
"nu-utils",
"num-format",
"pretty_assertions",
"rmp-serde",
"rstest",
"serde",
"serde_json",
@ -4282,7 +4319,7 @@ dependencies = [
"ahash 0.8.11",
"async-stream",
"base64 0.21.7",
"brotli",
"brotli 3.5.0",
"ethnum",
"flate2",
"futures",

View File

@ -31,6 +31,7 @@ members = [
"crates/nu-cmd-base",
"crates/nu-cmd-extra",
"crates/nu-cmd-lang",
"crates/nu-cmd-plugin",
"crates/nu-cmd-dataframe",
"crates/nu-command",
"crates/nu-color-config",
@ -62,6 +63,7 @@ alphanumeric-sort = "1.5"
ansi-str = "0.8"
base64 = "0.22"
bracoxide = "0.1.2"
brotli = "5.0"
byteorder = "1.5"
bytesize = "1.3"
calamine = "0.24.0"
@ -126,6 +128,7 @@ ratatui = "0.26"
rayon = "1.10"
reedline = "0.31.0"
regex = "1.9.5"
rmp-serde = "1.2"
ropey = "1.6.1"
roxmltree = "0.19"
rstest = { version = "0.18", default-features = false }
@ -168,6 +171,7 @@ winreg = "0.52"
nu-cli = { path = "./crates/nu-cli", version = "0.92.3" }
nu-cmd-base = { path = "./crates/nu-cmd-base", version = "0.92.3" }
nu-cmd-lang = { path = "./crates/nu-cmd-lang", version = "0.92.3" }
nu-cmd-plugin = { path = "./crates/nu-cmd-plugin", version = "0.92.3", optional = true }
nu-cmd-dataframe = { path = "./crates/nu-cmd-dataframe", version = "0.92.3", features = [
"dataframe",
], optional = true }
@ -223,6 +227,7 @@ tempfile = { workspace = true }
[features]
plugin = [
"nu-plugin",
"nu-cmd-plugin",
"nu-cli/plugin",
"nu-parser/plugin",
"nu-command/plugin",

View File

@ -21,6 +21,7 @@ nu-cmd-base = { path = "../nu-cmd-base", version = "0.92.3" }
nu-engine = { path = "../nu-engine", version = "0.92.3" }
nu-path = { path = "../nu-path", version = "0.92.3" }
nu-parser = { path = "../nu-parser", version = "0.92.3" }
nu-plugin = { path = "../nu-plugin", version = "0.92.3", optional = true }
nu-protocol = { path = "../nu-protocol", version = "0.92.3" }
nu-utils = { path = "../nu-utils", version = "0.92.3" }
nu-color-config = { path = "../nu-color-config", version = "0.92.3" }
@ -44,5 +45,5 @@ uuid = { workspace = true, features = ["v4"] }
which = { workspace = true }
[features]
plugin = []
plugin = ["nu-plugin"]
system-clipboard = ["reedline/system_clipboard"]

View File

@ -6,13 +6,15 @@ use nu_protocol::{
report_error, HistoryFileFormat, PipelineData,
};
#[cfg(feature = "plugin")]
use nu_protocol::{ParseError, Spanned};
use nu_protocol::{ParseError, PluginCacheFile, Spanned};
#[cfg(feature = "plugin")]
use nu_utils::utils::perf;
use std::path::PathBuf;
#[cfg(feature = "plugin")]
const PLUGIN_FILE: &str = "plugin.nu";
const PLUGIN_FILE: &str = "plugin.msgpackz";
#[cfg(feature = "plugin")]
const OLD_PLUGIN_FILE: &str = "plugin.nu";
const HISTORY_FILE_TXT: &str = "history.txt";
const HISTORY_FILE_SQLITE: &str = "history.sqlite3";
@ -20,14 +22,38 @@ const HISTORY_FILE_SQLITE: &str = "history.sqlite3";
#[cfg(feature = "plugin")]
pub fn read_plugin_file(
engine_state: &mut EngineState,
stack: &mut Stack,
plugin_file: Option<Spanned<String>>,
storage_path: &str,
) {
use std::path::Path;
use nu_protocol::{report_error_new, ShellError};
let span = plugin_file.as_ref().map(|s| s.span);
// Check and warn + abort if this is a .nu plugin file
if plugin_file
.as_ref()
.and_then(|p| Path::new(&p.item).extension())
.is_some_and(|ext| ext == "nu")
{
report_error_new(
engine_state,
&ShellError::GenericError {
error: "Wrong plugin file format".into(),
msg: ".nu plugin files are no longer supported".into(),
span,
help: Some("please recreate this file in the new .msgpackz format".into()),
inner: vec![],
},
);
return;
}
let mut start_time = std::time::Instant::now();
// Reading signatures from signature file
// The plugin.nu file stores the parsed signature collected from each registered plugin
add_plugin_file(engine_state, plugin_file, storage_path);
// Reading signatures from plugin cache file
// The plugin.msgpackz file stores the parsed signature collected from each registered plugin
add_plugin_file(engine_state, plugin_file.clone(), storage_path);
perf(
"add plugin file to engine_state",
start_time,
@ -38,14 +64,82 @@ pub fn read_plugin_file(
);
start_time = std::time::Instant::now();
let plugin_path = engine_state.plugin_signatures.clone();
let plugin_path = engine_state.plugin_path.clone();
if let Some(plugin_path) = plugin_path {
let plugin_filename = plugin_path.to_string_lossy();
let plug_path = plugin_filename.to_string();
// Open the plugin file
let mut file = match std::fs::File::open(&plugin_path) {
Ok(file) => file,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
log::warn!("Plugin file not found: {}", plugin_path.display());
// Try migration of an old plugin file if this wasn't a custom plugin file
if plugin_file.is_none() && migrate_old_plugin_file(engine_state, storage_path)
{
let Ok(file) = std::fs::File::open(&plugin_path) else {
log::warn!("Failed to load newly migrated plugin file");
return;
};
file
} else {
return;
}
} else {
report_error_new(
engine_state,
&ShellError::GenericError {
error: format!(
"Error while opening plugin cache file: {}",
plugin_path.display()
),
msg: "plugin path defined here".into(),
span,
help: None,
inner: vec![err.into()],
},
);
return;
}
}
};
// Abort if the file is empty.
if file.metadata().is_ok_and(|m| m.len() == 0) {
log::warn!(
"Not reading plugin file because it's empty: {}",
plugin_path.display()
);
return;
}
// Read the contents of the plugin file
let contents = match PluginCacheFile::read_from(&mut file, span) {
Ok(contents) => contents,
Err(err) => {
log::warn!("Failed to read plugin cache file: {err:?}");
report_error_new(
engine_state,
&ShellError::GenericError {
error: format!(
"Error while reading plugin cache file: {}",
plugin_path.display()
),
msg: "plugin path defined here".into(),
span,
help: Some(
"you might try deleting the file and registering all of your \
plugins again"
.into(),
),
inner: vec![],
},
);
return;
}
};
if let Ok(contents) = std::fs::read(&plugin_path) {
perf(
&format!("read plugin file {}", &plug_path),
&format!("read plugin file {}", plugin_path.display()),
start_time,
file!(),
line!(),
@ -53,16 +147,18 @@ pub fn read_plugin_file(
engine_state.get_config().use_ansi_coloring,
);
start_time = std::time::Instant::now();
eval_source(
engine_state,
stack,
&contents,
&plugin_filename,
PipelineData::empty(),
false,
);
let mut working_set = StateWorkingSet::new(engine_state);
nu_plugin::load_plugin_file(&mut working_set, &contents, span);
if let Err(err) = engine_state.merge_delta(working_set.render()) {
report_error_new(engine_state, &err);
return;
}
perf(
&format!("eval_source plugin file {}", &plug_path),
&format!("load plugin file {}", plugin_path.display()),
start_time,
file!(),
line!(),
@ -70,7 +166,6 @@ pub fn read_plugin_file(
engine_state.get_config().use_ansi_coloring,
);
}
}
}
#[cfg(feature = "plugin")]
@ -79,15 +174,30 @@ pub fn add_plugin_file(
plugin_file: Option<Spanned<String>>,
storage_path: &str,
) {
use std::path::Path;
let working_set = StateWorkingSet::new(engine_state);
let cwd = working_set.get_cwd();
if let Some(plugin_file) = plugin_file {
if let Ok(path) = canonicalize_with(&plugin_file.item, cwd) {
engine_state.plugin_signatures = Some(path)
let path = Path::new(&plugin_file.item);
let path_dir = path.parent().unwrap_or(path);
// Just try to canonicalize the directory of the plugin file first.
if let Ok(path_dir) = canonicalize_with(path_dir, &cwd) {
// Try to canonicalize the actual filename, but it's ok if that fails. The file doesn't
// have to exist.
let path = path_dir.join(path.file_name().unwrap_or(path.as_os_str()));
let path = canonicalize_with(&path, &cwd).unwrap_or(path);
engine_state.plugin_path = Some(path)
} else {
let e = ParseError::FileNotFound(plugin_file.item, plugin_file.span);
report_error(&working_set, &e);
// It's an error if the directory for the plugin file doesn't exist.
report_error(
&working_set,
&ParseError::FileNotFound(
path_dir.to_string_lossy().into_owned(),
plugin_file.span,
),
);
}
} else if let Some(mut plugin_path) = nu_path::config_dir() {
// Path to store plugins signatures
@ -95,7 +205,7 @@ pub fn add_plugin_file(
let mut plugin_path = canonicalize_with(&plugin_path, &cwd).unwrap_or(plugin_path);
plugin_path.push(PLUGIN_FILE);
let plugin_path = canonicalize_with(&plugin_path, &cwd).unwrap_or(plugin_path);
engine_state.plugin_signatures = Some(plugin_path);
engine_state.plugin_path = Some(plugin_path);
}
}
@ -151,3 +261,129 @@ pub(crate) fn get_history_path(storage_path: &str, mode: HistoryFileFormat) -> O
history_path
})
}
#[cfg(feature = "plugin")]
pub fn migrate_old_plugin_file(engine_state: &EngineState, storage_path: &str) -> bool {
use nu_protocol::{
report_error_new, PluginCacheItem, PluginCacheItemData, PluginExample, PluginIdentity,
PluginSignature, ShellError,
};
use std::collections::BTreeMap;
let start_time = std::time::Instant::now();
let cwd = engine_state.current_work_dir();
let Some(config_dir) = nu_path::config_dir().and_then(|mut dir| {
dir.push(storage_path);
nu_path::canonicalize_with(dir, &cwd).ok()
}) else {
return false;
};
let Ok(old_plugin_file_path) = nu_path::canonicalize_with(OLD_PLUGIN_FILE, &config_dir) else {
return false;
};
let old_contents = match std::fs::read(&old_plugin_file_path) {
Ok(old_contents) => old_contents,
Err(err) => {
report_error_new(
engine_state,
&ShellError::GenericError {
error: "Can't read old plugin file to migrate".into(),
msg: "".into(),
span: None,
help: Some(err.to_string()),
inner: vec![],
},
);
return false;
}
};
// Make a copy of the engine state, because we'll read the newly generated file
let mut engine_state = engine_state.clone();
let mut stack = Stack::new();
if !eval_source(
&mut engine_state,
&mut stack,
&old_contents,
&old_plugin_file_path.to_string_lossy(),
PipelineData::Empty,
false,
) {
return false;
}
// Now that the plugin commands are loaded, we just have to generate the file
let mut contents = PluginCacheFile::new();
let mut groups = BTreeMap::<PluginIdentity, Vec<PluginSignature>>::new();
for decl in engine_state.plugin_decls() {
if let Some(identity) = decl.plugin_identity() {
groups
.entry(identity.clone())
.or_default()
.push(PluginSignature {
sig: decl.signature(),
examples: decl
.examples()
.into_iter()
.map(PluginExample::from)
.collect(),
})
}
}
for (identity, commands) in groups {
contents.upsert_plugin(PluginCacheItem {
name: identity.name().to_owned(),
filename: identity.filename().to_owned(),
shell: identity.shell().map(|p| p.to_owned()),
data: PluginCacheItemData::Valid { commands },
});
}
// Write the new file
let new_plugin_file_path = config_dir.join(PLUGIN_FILE);
if let Err(err) = std::fs::File::create(&new_plugin_file_path)
.map_err(|e| e.into())
.and_then(|file| contents.write_to(file, None))
{
report_error_new(
&engine_state,
&ShellError::GenericError {
error: "Failed to save migrated plugin file".into(),
msg: "".into(),
span: None,
help: Some("ensure `$nu.plugin-path` is writable".into()),
inner: vec![err],
},
);
return false;
}
if engine_state.is_interactive {
eprintln!(
"Your old plugin.nu file has been migrated to the new format: {}",
new_plugin_file_path.display()
);
eprintln!(
"The plugin.nu file has not been removed. If `plugin list` looks okay, \
you may do so manually."
);
}
perf(
"migrate old plugin file",
start_time,
file!(),
line!(),
column!(),
engine_state.get_config().use_ansi_coloring,
);
true
}

View File

@ -32,4 +32,6 @@ pub use validation::NuValidator;
#[cfg(feature = "plugin")]
pub use config_files::add_plugin_file;
#[cfg(feature = "plugin")]
pub use config_files::migrate_old_plugin_file;
#[cfg(feature = "plugin")]
pub use config_files::read_plugin_file;

View File

@ -71,13 +71,3 @@ pub use try_::Try;
pub use use_::Use;
pub use version::Version;
pub use while_::While;
mod plugin;
mod plugin_list;
mod plugin_stop;
mod register;
pub use plugin::PluginCommand;
pub use plugin_list::PluginList;
pub use plugin_stop::PluginStop;
pub use register::Register;

View File

@ -63,9 +63,6 @@ pub fn create_default_context() -> EngineState {
While,
};
//#[cfg(feature = "plugin")]
bind_command!(PluginCommand, PluginList, PluginStop, Register,);
working_set.render()
};

View File

@ -0,0 +1,20 @@
[package]
authors = ["The Nushell Project Developers"]
description = "Commands for managing Nushell plugins."
edition = "2021"
license = "MIT"
name = "nu-cmd-plugin"
repository = "https://github.com/nushell/nushell/tree/main/crates/nu-cmd-plugin"
version = "0.92.3"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nu-engine = { path = "../nu-engine", version = "0.92.3" }
nu-path = { path = "../nu-path", version = "0.92.3" }
nu-protocol = { path = "../nu-protocol", version = "0.92.3", features = ["plugin"] }
nu-plugin = { path = "../nu-plugin", version = "0.92.3" }
itertools = { workspace = true }
[dev-dependencies]

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 - 2023 The Nushell Project Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,3 @@
# nu-cmd-plugin
This crate implements Nushell commands related to plugin management.

View File

@ -0,0 +1,5 @@
mod plugin;
mod register;
pub use plugin::*;
pub use register::Register;

View File

@ -0,0 +1,148 @@
use std::sync::Arc;
use nu_engine::{command_prelude::*, current_dir};
use nu_plugin::{GetPlugin, PersistentPlugin};
use nu_protocol::{PluginCacheItem, PluginGcConfig, PluginIdentity, RegisteredPlugin};
use crate::util::modify_plugin_file;
#[derive(Clone)]
pub struct PluginAdd;
impl Command for PluginAdd {
fn name(&self) -> &str {
"plugin add"
}
fn signature(&self) -> Signature {
Signature::build(self.name())
.input_output_type(Type::Nothing, Type::Nothing)
// This matches the option to `nu`
.named(
"plugin-config",
SyntaxShape::Filepath,
"Use a plugin cache file other than the one set in `$nu.plugin-path`",
None,
)
.named(
"shell",
SyntaxShape::Filepath,
"Use an additional shell program (cmd, sh, python, etc.) to run the plugin",
Some('s'),
)
.required(
"filename",
SyntaxShape::Filepath,
"Path to the executable for the plugin",
)
.category(Category::Plugin)
}
fn usage(&self) -> &str {
"Add a plugin to the plugin cache file."
}
fn extra_usage(&self) -> &str {
r#"
This does not load the plugin commands into the scope - see `register` for that.
Instead, it runs the plugin to get its command signatures, and then edits the
plugin cache file (by default, `$nu.plugin-path`). The changes will be
apparent the next time `nu` is next launched with that plugin cache file.
"#
.trim()
}
fn search_terms(&self) -> Vec<&str> {
vec!["plugin", "add", "register", "load", "signature"]
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
example: "plugin add nu_plugin_inc",
description: "Run the `nu_plugin_inc` plugin from the current directory or $env.NU_PLUGIN_DIRS and install its signatures.",
result: None,
},
Example {
example: "plugin add --plugin-config polars.msgpackz nu_plugin_polars",
description: "Run the `nu_plugin_polars` plugin from the current directory or $env.NU_PLUGIN_DIRS, and install its signatures to the \"polars.msgpackz\" plugin cache file.",
result: None,
},
]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let filename: Spanned<String> = call.req(engine_state, stack, 0)?;
let shell: Option<Spanned<String>> = call.get_flag(engine_state, stack, "shell")?;
let cwd = current_dir(engine_state, stack)?;
// Check the current directory, or fall back to NU_PLUGIN_DIRS
let filename_expanded = match nu_path::canonicalize_with(&filename.item, &cwd) {
Ok(path) => path,
Err(err) => {
// Try to find it in NU_PLUGIN_DIRS first, before giving up
let mut found = None;
if let Some(nu_plugin_dirs) = stack.get_env_var(engine_state, "NU_PLUGIN_DIRS") {
for dir in nu_plugin_dirs.into_list().unwrap_or(vec![]) {
if let Ok(path) = nu_path::canonicalize_with(dir.as_str()?, &cwd)
.and_then(|dir| nu_path::canonicalize_with(&filename.item, dir))
{
found = Some(path);
break;
}
}
}
found.ok_or(err.into_spanned(filename.span))?
}
};
let shell_expanded = shell
.as_ref()
.map(|s| {
nu_path::canonicalize_with(&s.item, &cwd).map_err(|err| err.into_spanned(s.span))
})
.transpose()?;
// Parse the plugin filename so it can be used to spawn the plugin
let identity = PluginIdentity::new(filename_expanded, shell_expanded).map_err(|_| {
ShellError::GenericError {
error: "Plugin filename is invalid".into(),
msg: "plugin executable files must start with `nu_plugin_`".into(),
span: Some(filename.span),
help: None,
inner: vec![],
}
})?;
let custom_path = call.get_flag(engine_state, stack, "plugin-config")?;
// Start the plugin manually, to get the freshest signatures and to not affect engine
// state. Provide a GC config that will stop it ASAP
let plugin = Arc::new(PersistentPlugin::new(
identity,
PluginGcConfig {
enabled: true,
stop_after: 0,
},
));
let interface = plugin.clone().get_plugin(Some((engine_state, stack)))?;
let commands = interface.get_signature()?;
modify_plugin_file(engine_state, stack, call.head, custom_path, |contents| {
// Update the file with the received signatures
let item = PluginCacheItem::new(plugin.identity(), commands);
contents.upsert_plugin(item);
Ok(())
})?;
Ok(Value::nothing(call.head).into_pipeline_data())
}
}

View File

@ -22,7 +22,7 @@ impl Command for PluginList {
("commands".into(), Type::List(Type::String.into())),
]),
)
.category(Category::Core)
.category(Category::Plugin)
}
fn usage(&self) -> &str {

View File

@ -1,5 +1,15 @@
use nu_engine::{command_prelude::*, get_full_help};
mod add;
mod list;
mod rm;
mod stop;
pub use add::PluginAdd;
pub use list::PluginList;
pub use rm::PluginRm;
pub use stop::PluginStop;
#[derive(Clone)]
pub struct PluginCommand;
@ -11,7 +21,7 @@ impl Command for PluginCommand {
fn signature(&self) -> Signature {
Signature::build("plugin")
.input_output_types(vec![(Type::Nothing, Type::Nothing)])
.category(Category::Core)
.category(Category::Plugin)
}
fn usage(&self) -> &str {
@ -54,6 +64,16 @@ impl Command for PluginCommand {
description: "Stop the plugin named `inc`.",
result: None,
},
Example {
example: "plugin add nu_plugin_inc",
description: "Run the `nu_plugin_inc` plugin from the current directory and install its signatures.",
result: None,
},
Example {
example: "plugin rm inc",
description: "Remove the installed signatures for the `inc` plugin.",
result: None,
},
]
}
}

View File

@ -0,0 +1,100 @@
use nu_engine::command_prelude::*;
use crate::util::modify_plugin_file;
#[derive(Clone)]
pub struct PluginRm;
impl Command for PluginRm {
fn name(&self) -> &str {
"plugin rm"
}
fn signature(&self) -> Signature {
Signature::build(self.name())
.input_output_type(Type::Nothing, Type::Nothing)
// This matches the option to `nu`
.named(
"plugin-config",
SyntaxShape::Filepath,
"Use a plugin cache file other than the one set in `$nu.plugin-path`",
None,
)
.switch(
"force",
"Don't cause an error if the plugin name wasn't found in the file",
Some('f'),
)
.required(
"name",
SyntaxShape::String,
"The name of the plugin to remove (not the filename)",
)
.category(Category::Plugin)
}
fn usage(&self) -> &str {
"Remove a plugin from the plugin cache file."
}
fn extra_usage(&self) -> &str {
r#"
This does not remove the plugin commands from the current scope or from `plugin
list` in the current shell. It instead removes the plugin from the plugin
cache file (by default, `$nu.plugin-path`). The changes will be apparent the
next time `nu` is launched with that plugin cache file.
This can be useful for removing an invalid plugin signature, if it can't be
fixed with `plugin add`.
"#
.trim()
}
fn search_terms(&self) -> Vec<&str> {
vec!["plugin", "rm", "remove", "delete", "signature"]
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
example: "plugin rm inc",
description: "Remove the installed signatures for the `inc` plugin.",
result: None,
},
Example {
example: "plugin rm --plugin-config polars.msgpackz polars",
description: "Remove the installed signatures for the `polars` plugin from the \"polars.msgpackz\" plugin cache file.",
result: None,
},
]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let name: Spanned<String> = call.req(engine_state, stack, 0)?;
let custom_path = call.get_flag(engine_state, stack, "plugin-config")?;
let force = call.has_flag(engine_state, stack, "force")?;
modify_plugin_file(engine_state, stack, call.head, custom_path, |contents| {
if !force && !contents.plugins.iter().any(|p| p.name == name.item) {
Err(ShellError::GenericError {
error: format!("Failed to remove the `{}` plugin", name.item),
msg: "couldn't find a plugin with this name in the cache file".into(),
span: Some(name.span),
help: None,
inner: vec![],
})
} else {
contents.remove_plugin(&name.item);
Ok(())
}
})?;
Ok(Value::nothing(call.head).into_pipeline_data())
}
}

View File

@ -16,7 +16,7 @@ impl Command for PluginStop {
SyntaxShape::String,
"The name of the plugin to stop.",
)
.category(Category::Core)
.category(Category::Plugin)
}
fn usage(&self) -> &str {

View File

@ -31,7 +31,7 @@ impl Command for Register {
"path of shell used to run plugin (cmd, sh, python, etc)",
Some('s'),
)
.category(Category::Core)
.category(Category::Plugin)
}
fn extra_usage(&self) -> &str {
@ -39,6 +39,10 @@ impl Command for Register {
https://www.nushell.sh/book/thinking_in_nu.html"#
}
fn search_terms(&self) -> Vec<&str> {
vec!["plugin", "add", "register"]
}
fn is_parser_keyword(&self) -> bool {
true
}

View File

@ -0,0 +1,31 @@
use crate::*;
use nu_protocol::engine::{EngineState, StateWorkingSet};
pub fn add_plugin_command_context(mut engine_state: EngineState) -> EngineState {
let delta = {
let mut working_set = StateWorkingSet::new(&engine_state);
macro_rules! bind_command {
( $( $command:expr ),* $(,)? ) => {
$( working_set.add_decl(Box::new($command)); )*
};
}
bind_command!(
PluginCommand,
PluginAdd,
PluginList,
PluginRm,
PluginStop,
Register,
);
working_set.render()
};
if let Err(err) = engine_state.merge_delta(delta) {
eprintln!("Error creating default context: {err:?}");
}
engine_state
}

View File

@ -0,0 +1,8 @@
//! Nushell commands for managing plugins.
mod commands;
mod default_context;
mod util;
pub use commands::*;
pub use default_context::*;

View File

@ -0,0 +1,50 @@
use std::fs::{self, File};
use nu_engine::{command_prelude::*, current_dir};
use nu_protocol::PluginCacheFile;
pub(crate) fn modify_plugin_file(
engine_state: &EngineState,
stack: &mut Stack,
span: Span,
custom_path: Option<Spanned<String>>,
operate: impl FnOnce(&mut PluginCacheFile) -> Result<(), ShellError>,
) -> Result<(), ShellError> {
let cwd = current_dir(engine_state, stack)?;
let plugin_cache_file_path = if let Some(ref custom_path) = custom_path {
nu_path::expand_path_with(&custom_path.item, cwd, true)
} else {
engine_state
.plugin_path
.clone()
.ok_or_else(|| ShellError::GenericError {
error: "Plugin cache file not set".into(),
msg: "pass --plugin-config explicitly here".into(),
span: Some(span),
help: Some("you may be running `nu` with --no-config-file".into()),
inner: vec![],
})?
};
// Try to read the plugin file if it exists
let mut contents = if fs::metadata(&plugin_cache_file_path).is_ok_and(|m| m.len() > 0) {
PluginCacheFile::read_from(
File::open(&plugin_cache_file_path).map_err(|err| err.into_spanned(span))?,
Some(span),
)?
} else {
PluginCacheFile::default()
};
// Do the operation
operate(&mut contents)?;
// Save the modified file on success
contents.write_to(
File::create(&plugin_cache_file_path).map_err(|err| err.into_spanned(span))?,
Some(span),
)?;
Ok(())
}

View File

@ -3550,7 +3550,8 @@ pub fn parse_where(working_set: &mut StateWorkingSet, lite_command: &LiteCommand
pub fn parse_register(working_set: &mut StateWorkingSet, lite_command: &LiteCommand) -> Pipeline {
use nu_plugin::{get_signature, PersistentPlugin, PluginDeclaration};
use nu_protocol::{
engine::Stack, IntoSpanned, PluginIdentity, PluginSignature, RegisteredPlugin,
engine::Stack, IntoSpanned, PluginCacheItem, PluginIdentity, PluginSignature,
RegisteredPlugin,
};
let spans = &lite_command.parts;
@ -3743,10 +3744,10 @@ pub fn parse_register(working_set: &mut StateWorkingSet, lite_command: &LiteComm
)
});
if signatures.is_ok() {
// mark plugins file as dirty only when the user is registering plugins
// and not when we evaluate plugin.nu on shell startup
working_set.mark_plugins_file_dirty();
if let Ok(ref signatures) = signatures {
// Add the loaded plugin to the delta
working_set
.update_plugin_cache(PluginCacheItem::new(&identity, signatures.clone()));
}
signatures

View File

@ -78,10 +78,10 @@ pub use serializers::{json::JsonSerializer, msgpack::MsgPackSerializer};
// Used by other nu crates.
#[doc(hidden)]
pub use plugin::{
create_plugin_signature, get_signature, serve_plugin_io, EngineInterfaceManager, GetPlugin,
Interface, InterfaceManager, PersistentPlugin, PluginDeclaration,
PluginExecutionCommandContext, PluginExecutionContext, PluginInterface, PluginInterfaceManager,
PluginSource, ServePluginError,
create_plugin_signature, get_signature, load_plugin_cache_item, load_plugin_file,
serve_plugin_io, EngineInterfaceManager, GetPlugin, Interface, InterfaceManager,
PersistentPlugin, PluginDeclaration, PluginExecutionCommandContext, PluginExecutionContext,
PluginInterface, PluginInterfaceManager, PluginSource, ServePluginError,
};
#[doc(hidden)]
pub use protocol::{PluginCustomValue, PluginInput, PluginOutput};

View File

@ -750,15 +750,7 @@ impl PluginInterface {
help: Some(format!(
"the plugin may have experienced an error. Try registering the plugin again \
with `{}`",
if let Some(shell) = self.state.source.shell() {
format!(
"register --shell '{}' '{}'",
shell.display(),
self.state.source.filename().display(),
)
} else {
format!("register '{}'", self.state.source.filename().display())
}
self.state.source.identity.register_command(),
)),
inner: vec![],
})?;

View File

@ -23,8 +23,9 @@ use std::{
use nu_engine::documentation::get_flags_section;
use nu_protocol::{
ast::Operator, CustomValue, IntoSpanned, LabeledError, PipelineData, PluginSignature,
ShellError, Spanned, Value,
ast::Operator, engine::StateWorkingSet, report_error_new, CustomValue, IntoSpanned,
LabeledError, PipelineData, PluginCacheFile, PluginCacheItem, PluginCacheItemData,
PluginIdentity, PluginSignature, ShellError, Span, Spanned, Value,
};
use thiserror::Error;
@ -919,3 +920,79 @@ pub fn get_plugin_encoding(
}
})
}
/// Load the definitions from the plugin file into the engine state
#[doc(hidden)]
pub fn load_plugin_file(
working_set: &mut StateWorkingSet,
plugin_cache_file: &PluginCacheFile,
span: Option<Span>,
) {
for plugin in &plugin_cache_file.plugins {
// Any errors encountered should just be logged.
if let Err(err) = load_plugin_cache_item(working_set, plugin, span) {
report_error_new(working_set.permanent_state, &err)
}
}
}
/// Load a definition from the plugin file into the engine state
#[doc(hidden)]
pub fn load_plugin_cache_item(
working_set: &mut StateWorkingSet,
plugin: &PluginCacheItem,
span: Option<Span>,
) -> Result<(), ShellError> {
let identity =
PluginIdentity::new(plugin.filename.clone(), plugin.shell.clone()).map_err(|_| {
ShellError::GenericError {
error: "Invalid plugin filename in plugin cache file".into(),
msg: "loaded from here".into(),
span,
help: Some(format!(
"the filename for `{}` is not a valid nushell plugin: {}",
plugin.name,
plugin.filename.display()
)),
inner: vec![],
}
})?;
match &plugin.data {
PluginCacheItemData::Valid { commands } => {
// Find garbage collection config for the plugin
let gc_config = working_set
.get_config()
.plugin_gc
.get(identity.name())
.clone();
// Add it to / get it from the working set
let plugin = working_set.find_or_create_plugin(&identity, || {
Arc::new(PersistentPlugin::new(identity.clone(), gc_config.clone()))
});
// Downcast the plugin to `PersistentPlugin` - we generally expect this to succeed.
// The trait object only exists so that nu-protocol can contain plugins without knowing
// anything about their implementation, but we only use `PersistentPlugin` in practice.
let plugin: Arc<PersistentPlugin> =
plugin
.as_any()
.downcast()
.map_err(|_| ShellError::NushellFailed {
msg: "encountered unexpected RegisteredPlugin type".into(),
})?;
// Create the declarations from the commands
for signature in commands {
let decl = PluginDeclaration::new(plugin.clone(), signature.clone());
working_set.add_decl(Box::new(decl));
}
Ok(())
}
PluginCacheItemData::Invalid => Err(ShellError::PluginCacheDataInvalid {
plugin_name: identity.name().to_owned(),
register_command: identity.register_command(),
}),
}
}

View File

@ -17,6 +17,7 @@ nu-utils = { path = "../nu-utils", version = "0.92.3" }
nu-path = { path = "../nu-path", version = "0.92.3" }
nu-system = { path = "../nu-system", version = "0.92.3" }
brotli = { workspace = true, optional = true }
byte-unit = { version = "5.1", features = [ "serde" ] }
chrono = { workspace = true, features = [ "serde", "std", "unstable-locales" ], default-features = false }
chrono-humanize = { workspace = true }
@ -25,19 +26,25 @@ indexmap = { workspace = true }
lru = { workspace = true }
miette = { workspace = true, features = ["fancy-no-backtrace"] }
num-format = { workspace = true }
rmp-serde = { workspace = true, optional = true }
serde = { workspace = true, default-features = false }
serde_json = { workspace = true, optional = true }
thiserror = "1.0"
typetag = "0.2"
[features]
plugin = ["serde_json"]
plugin = [
"brotli",
"rmp-serde",
"serde_json",
]
[dev-dependencies]
serde_json = { workspace = true }
strum = "0.26"
strum_macros = "0.26"
nu-test-support = { path = "../nu-test-support", version = "0.92.3" }
pretty_assertions = "1.0"
rstest = { workspace = true }
[package.metadata.docs.rs]

View File

@ -24,7 +24,7 @@ use std::{
type PoisonDebuggerError<'a> = PoisonError<MutexGuard<'a, Box<dyn Debugger>>>;
#[cfg(feature = "plugin")]
use crate::RegisteredPlugin;
use crate::{PluginCacheFile, PluginCacheItem, RegisteredPlugin};
pub static PWD_ENV: &str = "PWD";
@ -92,7 +92,7 @@ pub struct EngineState {
pub repl_state: Arc<Mutex<ReplState>>,
pub table_decl_id: Option<usize>,
#[cfg(feature = "plugin")]
pub plugin_signatures: Option<PathBuf>,
pub plugin_path: Option<PathBuf>,
#[cfg(feature = "plugin")]
plugins: Vec<Arc<dyn RegisteredPlugin>>,
config_path: HashMap<String, PathBuf>,
@ -155,7 +155,7 @@ impl EngineState {
})),
table_decl_id: None,
#[cfg(feature = "plugin")]
plugin_signatures: None,
plugin_path: None,
#[cfg(feature = "plugin")]
plugins: vec![],
config_path: HashMap::new(),
@ -255,7 +255,7 @@ impl EngineState {
if let Some(existing) = self
.plugins
.iter_mut()
.find(|p| p.identity() == plugin.identity())
.find(|p| p.identity().name() == plugin.identity().name())
{
// Stop the existing plugin, so that the new plugin definitely takes over
existing.stop()?;
@ -267,10 +267,10 @@ impl EngineState {
}
#[cfg(feature = "plugin")]
if delta.plugins_changed {
if !delta.plugin_cache_items.is_empty() {
// Update the plugin file with the new signatures.
if self.plugin_signatures.is_some() {
self.update_plugin_file()?;
if self.plugin_path.is_some() {
self.update_plugin_file(std::mem::take(&mut delta.plugin_cache_items))?;
}
}
@ -480,94 +480,59 @@ impl EngineState {
}
#[cfg(feature = "plugin")]
pub fn update_plugin_file(&self) -> Result<(), ShellError> {
use std::io::Write;
use crate::{PluginExample, PluginSignature};
pub fn update_plugin_file(
&self,
updated_items: Vec<PluginCacheItem>,
) -> Result<(), ShellError> {
// Updating the signatures plugin file with the added signatures
self.plugin_signatures
use std::fs::File;
let plugin_path = self
.plugin_path
.as_ref()
.ok_or_else(|| ShellError::PluginFailedToLoad {
msg: "Plugin file not found".into(),
})
.and_then(|plugin_path| {
// Always create the file, which will erase previous signatures
std::fs::File::create(plugin_path.as_path()).map_err(|err| {
ShellError::PluginFailedToLoad {
msg: err.to_string(),
}
})
})
.and_then(|mut plugin_file| {
// Plugin definitions with parsed signature
self.plugin_decls().try_for_each(|decl| {
// A successful plugin registration already includes the plugin filename
// No need to check the None option
let identity = decl.plugin_identity().expect("plugin should have identity");
let mut file_name = identity
.filename()
.to_str()
.expect("path was checked during registration as a str")
.to_string();
.ok_or_else(|| ShellError::GenericError {
error: "Plugin file path not set".into(),
msg: "".into(),
span: None,
help: Some("you may be running nu with --no-config-file".into()),
inner: vec![],
})?;
// Fix files or folders with quotes
if file_name.contains('\'')
|| file_name.contains('"')
|| file_name.contains(' ')
{
file_name = format!("`{file_name}`");
}
let sig = decl.signature();
let examples = decl
.examples()
.into_iter()
.map(PluginExample::from)
.collect();
let sig_with_examples = PluginSignature::new(sig, examples);
serde_json::to_string_pretty(&sig_with_examples)
.map(|signature| {
// Extracting the possible path to the shell used to load the plugin
let shell_str = identity
.shell()
.map(|path| {
format!(
"-s {}",
path.to_str().expect(
"shell path was checked during registration as a str"
)
)
})
.unwrap_or_default();
// Each signature is stored in the plugin file with the shell and signature
// This information will be used when loading the plugin
// information when nushell starts
format!("register {file_name} {shell_str} {signature}\n\n")
})
.map_err(|err| ShellError::PluginFailedToLoad {
msg: err.to_string(),
})
.and_then(|line| {
plugin_file.write_all(line.as_bytes()).map_err(|err| {
ShellError::PluginFailedToLoad {
msg: err.to_string(),
}
})
})
.and_then(|_| {
plugin_file.flush().map_err(|err| ShellError::GenericError {
error: "Error flushing plugin file".into(),
msg: format! {"{err}"},
// Read the current contents of the plugin file if it exists
let mut contents = match File::open(plugin_path.as_path()) {
Ok(mut plugin_file) => PluginCacheFile::read_from(&mut plugin_file, None),
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
Ok(PluginCacheFile::default())
} else {
Err(ShellError::GenericError {
error: "Failed to open plugin file".into(),
msg: "".into(),
span: None,
help: None,
inner: vec![],
})
})
})
inner: vec![err.into()],
})
}
}
}?;
// Update the given signatures
for item in updated_items {
contents.upsert_plugin(item);
}
// Write it to the same path
let plugin_file =
File::create(plugin_path.as_path()).map_err(|err| ShellError::GenericError {
error: "Failed to write plugin file".into(),
msg: "".into(),
span: None,
help: None,
inner: vec![err.into()],
})?;
contents.write_to(plugin_file, None)
}
/// Update plugins with new garbage collection config
#[cfg(feature = "plugin")]

View File

@ -9,7 +9,7 @@ use crate::{
use std::sync::Arc;
#[cfg(feature = "plugin")]
use crate::RegisteredPlugin;
use crate::{PluginCacheItem, RegisteredPlugin};
/// A delta (or change set) between the current global state and a possible future global state. Deltas
/// can be applied to the global state to update it to contain both previous state and the state held
@ -24,9 +24,9 @@ pub struct StateDelta {
pub(super) usage: Usage,
pub scope: Vec<ScopeFrame>,
#[cfg(feature = "plugin")]
pub(super) plugins_changed: bool, // marks whether plugin file should be updated
#[cfg(feature = "plugin")]
pub(super) plugins: Vec<Arc<dyn RegisteredPlugin>>,
#[cfg(feature = "plugin")]
pub(super) plugin_cache_items: Vec<PluginCacheItem>,
}
impl StateDelta {
@ -48,9 +48,9 @@ impl StateDelta {
scope: vec![scope_frame],
usage: Usage::new(),
#[cfg(feature = "plugin")]
plugins_changed: false,
#[cfg(feature = "plugin")]
plugins: vec![],
#[cfg(feature = "plugin")]
plugin_cache_items: vec![],
}
}

View File

@ -15,7 +15,7 @@ use std::{
};
#[cfg(feature = "plugin")]
use crate::{PluginIdentity, RegisteredPlugin};
use crate::{PluginCacheItem, PluginIdentity, RegisteredPlugin};
/// A temporary extension to the global state. This handles bridging between the global state and the
/// additional declarations and scope changes that are not yet part of the global scope.
@ -159,11 +159,6 @@ impl<'a> StateWorkingSet<'a> {
.insert(name, decl_id)
}
#[cfg(feature = "plugin")]
pub fn mark_plugins_file_dirty(&mut self) {
self.delta.plugins_changed = true;
}
#[cfg(feature = "plugin")]
pub fn find_or_create_plugin(
&mut self,
@ -186,6 +181,11 @@ impl<'a> StateWorkingSet<'a> {
}
}
#[cfg(feature = "plugin")]
pub fn update_plugin_cache(&mut self, item: PluginCacheItem) {
self.delta.plugin_cache_items.push(item);
}
pub fn merge_predecl(&mut self, name: &[u8]) -> Option<DeclId> {
self.move_predecls_to_overlay();

View File

@ -750,6 +750,19 @@ pub enum ShellError {
span: Span,
},
/// The cached plugin data (in `$nu.plugin-path`) for a plugin is invalid.
///
/// ## Resolution
///
/// `register` the plugin again to update the data, or remove it.
#[error("The cached plugin data for `{plugin_name}` is invalid")]
#[diagnostic(code(nu::shell::plugin_cache_data_invalid))]
PluginCacheDataInvalid {
plugin_name: String,
#[help("try registering the plugin again with `{}`")]
register_command: String,
},
/// A plugin failed to load.
///
/// ## Resolution

View File

@ -116,7 +116,7 @@ pub fn create_nu_constant(engine_state: &EngineState, span: Span) -> Result<Valu
{
record.push(
"plugin-path",
if let Some(path) = &engine_state.plugin_signatures {
if let Some(path) = &engine_state.plugin_path {
let canon_plugin_path = canonicalize_path(engine_state, path);
Value::string(canon_plugin_path.to_string_lossy(), span)
} else {
@ -124,7 +124,7 @@ pub fn create_nu_constant(engine_state: &EngineState, span: Span) -> Result<Valu
config_path.clone().map_or_else(
|e| e,
|mut path| {
path.push("plugin.nu");
path.push("plugin.msgpackz");
let canonical_plugin_path = canonicalize_path(engine_state, &path);
Value::string(canonical_plugin_path.to_string_lossy(), span)
},

View File

@ -13,7 +13,7 @@ pub struct Example<'a> {
// and `description` fields, because these information is fetched from plugin, a third party
// binary, nushell have no way to construct it directly.
#[cfg(feature = "plugin")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginExample {
pub example: String,
pub description: String,

View File

@ -0,0 +1,175 @@
use std::{
io::{Read, Write},
path::PathBuf,
};
use serde::{Deserialize, Serialize};
use crate::{PluginIdentity, PluginSignature, ShellError, Span};
// This has a big impact on performance
const BUFFER_SIZE: usize = 65536;
// Chose settings at the low end, because we're just trying to get the maximum speed
const COMPRESSION_QUALITY: u32 = 1;
const WIN_SIZE: u32 = 20; // recommended 20-22
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginCacheFile {
/// The Nushell version that last updated the file.
pub nushell_version: String,
/// The installed plugins.
pub plugins: Vec<PluginCacheItem>,
}
impl Default for PluginCacheFile {
fn default() -> Self {
Self::new()
}
}
impl PluginCacheFile {
/// Create a new, empty plugin cache file.
pub fn new() -> PluginCacheFile {
PluginCacheFile {
nushell_version: env!("CARGO_PKG_VERSION").to_owned(),
plugins: vec![],
}
}
/// Read the plugin cache file from a reader, e.g. [`File`](std::fs::File).
pub fn read_from(
reader: impl Read,
error_span: Option<Span>,
) -> Result<PluginCacheFile, ShellError> {
// Format is brotli compressed messagepack
let brotli_reader = brotli::Decompressor::new(reader, BUFFER_SIZE);
rmp_serde::from_read(brotli_reader).map_err(|err| ShellError::GenericError {
error: format!("Failed to load plugin file: {err}"),
msg: "plugin file load attempted here".into(),
span: error_span,
help: Some(
"it may be corrupt. Try deleting it and registering your plugins again".into(),
),
inner: vec![],
})
}
/// Write the plugin cache file to a writer, e.g. [`File`](std::fs::File).
///
/// The `nushell_version` will be updated to the current version before writing.
pub fn write_to(
&mut self,
writer: impl Write,
error_span: Option<Span>,
) -> Result<(), ShellError> {
// Update the Nushell version before writing
self.nushell_version = env!("CARGO_PKG_VERSION").to_owned();
// Format is brotli compressed messagepack
let mut brotli_writer =
brotli::CompressorWriter::new(writer, BUFFER_SIZE, COMPRESSION_QUALITY, WIN_SIZE);
rmp_serde::encode::write_named(&mut brotli_writer, self)
.map_err(|err| err.to_string())
.and_then(|_| brotli_writer.flush().map_err(|err| err.to_string()))
.map_err(|err| ShellError::GenericError {
error: "Failed to save plugin file".into(),
msg: "plugin file save attempted here".into(),
span: error_span,
help: Some(err.to_string()),
inner: vec![],
})
}
/// Insert or update a plugin in the plugin cache file.
pub fn upsert_plugin(&mut self, item: PluginCacheItem) {
if let Some(existing_item) = self.plugins.iter_mut().find(|p| p.name == item.name) {
*existing_item = item;
} else {
self.plugins.push(item);
// Sort the plugins for consistency
self.plugins
.sort_by(|item1, item2| item1.name.cmp(&item2.name));
}
}
/// Remove a plugin from the plugin cache file by name.
pub fn remove_plugin(&mut self, name: &str) {
self.plugins.retain_mut(|item| item.name != name)
}
}
/// A single plugin definition from a [`PluginCacheFile`].
///
/// Contains the information necessary for the [`PluginIdentity`], as well as possibly valid data
/// about the plugin including the cached command signatures.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginCacheItem {
/// The name of the plugin, as would show in `plugin list`. This does not include the file
/// extension or the `nu_plugin_` prefix.
pub name: String,
/// The path to the file.
pub filename: PathBuf,
/// The shell program used to run the plugin, if applicable.
pub shell: Option<PathBuf>,
/// Additional data that might be invalid so that we don't fail to load the whole plugin file
/// if there's a deserialization error.
#[serde(flatten)]
pub data: PluginCacheItemData,
}
impl PluginCacheItem {
/// Create a [`PluginCacheItem`] from an identity and signatures.
pub fn new(identity: &PluginIdentity, mut commands: Vec<PluginSignature>) -> PluginCacheItem {
// Sort the commands for consistency
commands.sort_by(|cmd1, cmd2| cmd1.sig.name.cmp(&cmd2.sig.name));
PluginCacheItem {
name: identity.name().to_owned(),
filename: identity.filename().to_owned(),
shell: identity.shell().map(|p| p.to_owned()),
data: PluginCacheItemData::Valid { commands },
}
}
}
/// Possibly valid data about a plugin in a [`PluginCacheFile`]. If deserialization fails, it will
/// be `Invalid`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PluginCacheItemData {
Valid {
/// Signatures and examples for each command provided by the plugin.
commands: Vec<PluginSignature>,
},
#[serde(
serialize_with = "serialize_invalid",
deserialize_with = "deserialize_invalid"
)]
Invalid,
}
fn serialize_invalid<S>(serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
().serialize(serializer)
}
fn deserialize_invalid<'de, D>(deserializer: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(())
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,120 @@
use super::{PluginCacheFile, PluginCacheItem, PluginCacheItemData};
use crate::{
Category, PluginExample, PluginSignature, ShellError, Signature, SyntaxShape, Type, Value,
};
use pretty_assertions::assert_eq;
use std::io::Cursor;
fn foo_plugin() -> PluginCacheItem {
PluginCacheItem {
name: "foo".into(),
filename: "/path/to/nu_plugin_foo".into(),
shell: None,
data: PluginCacheItemData::Valid {
commands: vec![PluginSignature {
sig: Signature::new("foo")
.input_output_type(Type::Int, Type::List(Box::new(Type::Int)))
.category(Category::Experimental),
examples: vec![PluginExample {
example: "16 | foo".into(),
description: "powers of two up to 16".into(),
result: Some(Value::test_list(vec![
Value::test_int(2),
Value::test_int(4),
Value::test_int(8),
Value::test_int(16),
])),
}],
}],
},
}
}
fn bar_plugin() -> PluginCacheItem {
PluginCacheItem {
name: "bar".into(),
filename: "/path/to/nu_plugin_bar".into(),
shell: None,
data: PluginCacheItemData::Valid {
commands: vec![PluginSignature {
sig: Signature::new("bar")
.usage("overwrites files with random data")
.switch("force", "ignore errors", Some('f'))
.required(
"path",
SyntaxShape::Filepath,
"file to overwrite with random data",
)
.category(Category::Experimental),
examples: vec![],
}],
},
}
}
#[test]
fn roundtrip() -> Result<(), ShellError> {
let mut plugin_cache_file = PluginCacheFile {
nushell_version: env!("CARGO_PKG_VERSION").to_owned(),
plugins: vec![foo_plugin(), bar_plugin()],
};
let mut output = vec![];
plugin_cache_file.write_to(&mut output, None)?;
let read_file = PluginCacheFile::read_from(Cursor::new(&output[..]), None)?;
assert_eq!(plugin_cache_file, read_file);
Ok(())
}
#[test]
fn roundtrip_invalid() -> Result<(), ShellError> {
let mut plugin_cache_file = PluginCacheFile {
nushell_version: env!("CARGO_PKG_VERSION").to_owned(),
plugins: vec![PluginCacheItem {
name: "invalid".into(),
filename: "/path/to/nu_plugin_invalid".into(),
shell: None,
data: PluginCacheItemData::Invalid,
}],
};
let mut output = vec![];
plugin_cache_file.write_to(&mut output, None)?;
let read_file = PluginCacheFile::read_from(Cursor::new(&output[..]), None)?;
assert_eq!(plugin_cache_file, read_file);
Ok(())
}
#[test]
fn upsert_new() {
let mut file = PluginCacheFile::new();
file.plugins.push(foo_plugin());
file.upsert_plugin(bar_plugin());
assert_eq!(2, file.plugins.len());
}
#[test]
fn upsert_replace() {
let mut file = PluginCacheFile::new();
file.plugins.push(foo_plugin());
let mut mutated_foo = foo_plugin();
mutated_foo.shell = Some("/bin/sh".into());
file.upsert_plugin(mutated_foo);
assert_eq!(1, file.plugins.len());
assert_eq!(Some("/bin/sh".into()), file.plugins[0].shell);
}

View File

@ -88,6 +88,19 @@ impl PluginIdentity {
PluginIdentity::new(format!(r"/fake/path/nu_plugin_{name}"), None)
.expect("fake plugin identity path is invalid")
}
/// A command that could be used to register the plugin, for suggesting in errors.
pub fn register_command(&self) -> String {
if let Some(shell) = self.shell() {
format!(
"register --shell '{}' '{}'",
shell.display(),
self.filename().display(),
)
} else {
format!("register '{}'", self.filename().display())
}
}
}
#[test]

View File

@ -1,7 +1,9 @@
mod cache_file;
mod identity;
mod registered;
mod signature;
pub use cache_file::*;
pub use identity::*;
pub use registered::*;
pub use signature::*;

View File

@ -2,7 +2,7 @@ use crate::{PluginExample, Signature};
use serde::{Deserialize, Serialize};
/// A simple wrapper for Signature that includes examples.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginSignature {
pub sig: Signature,
pub examples: Vec<PluginExample>,

View File

@ -56,6 +56,7 @@ pub enum Category {
Network,
Path,
Platform,
Plugin,
Random,
Shells,
Strings,
@ -90,6 +91,7 @@ impl std::fmt::Display for Category {
Category::Network => "network",
Category::Path => "path",
Category::Platform => "platform",
Category::Plugin => "plugin",
Category::Random => "random",
Category::Shells => "shells",
Category::Strings => "strings",

View File

@ -202,11 +202,11 @@ macro_rules! nu_with_std {
#[macro_export]
macro_rules! nu_with_plugins {
(cwd: $cwd:expr, plugins: [$(($plugin_name:expr)),+$(,)?], $command:expr) => {{
(cwd: $cwd:expr, plugins: [$(($plugin_name:expr)),*$(,)?], $command:expr) => {{
nu_with_plugins!(
cwd: $cwd,
envs: Vec::<(&str, &str)>::new(),
plugins: [$(($plugin_name)),+],
plugins: [$(($plugin_name)),*],
$command
)
}};
@ -222,10 +222,10 @@ macro_rules! nu_with_plugins {
(
cwd: $cwd:expr,
envs: $envs:expr,
plugins: [$(($plugin_name:expr)),+$(,)?],
plugins: [$(($plugin_name:expr)),*$(,)?],
$command:expr
) => {{
$crate::macros::nu_with_plugin_run_test($cwd, $envs, &[$($plugin_name),+], $command)
$crate::macros::nu_with_plugin_run_test($cwd, $envs, &[$($plugin_name),*], $command)
}};
(cwd: $cwd:expr, envs: $envs:expr, plugin: ($plugin_name:expr), $command:expr) => {{
$crate::macros::nu_with_plugin_run_test($cwd, $envs, &[$plugin_name], $command)
@ -329,13 +329,15 @@ where
});
let temp = tempdir().expect("couldn't create a temporary directory");
let [temp_config_file, temp_env_config_file, temp_plugin_file] =
["config.nu", "env.nu", "plugin.nu"].map(|name| {
let [temp_config_file, temp_env_config_file] = ["config.nu", "env.nu"].map(|name| {
let temp_file = temp.path().join(name);
std::fs::File::create(&temp_file).expect("couldn't create temporary config file");
temp_file
});
// We don't have to write the plugin cache file, it's ok for it to not exist
let temp_plugin_file = temp.path().join("plugin.msgpackz");
crate::commands::ensure_plugins_built();
let registrations: String = plugins

View File

@ -340,7 +340,7 @@ impl Command for Nu {
signature = signature.named(
"plugin-config",
SyntaxShape::String,
"start with an alternate plugin signature file",
"start with an alternate plugin cache file",
None,
);
}

View File

@ -214,7 +214,7 @@ pub(crate) fn setup_config(
);
let result = catch_unwind(AssertUnwindSafe(|| {
#[cfg(feature = "plugin")]
read_plugin_file(engine_state, stack, plugin_file, NUSHELL_FOLDER);
read_plugin_file(engine_state, plugin_file, NUSHELL_FOLDER);
read_config_file(engine_state, stack, env_file, true);
read_config_file(engine_state, stack, config_file, false);

View File

@ -43,6 +43,8 @@ use std::{
fn get_engine_state() -> EngineState {
let engine_state = nu_cmd_lang::create_default_context();
#[cfg(feature = "plugin")]
let engine_state = nu_cmd_plugin::add_plugin_command_context(engine_state);
let engine_state = nu_command::add_shell_command_context(engine_state);
let engine_state = nu_cmd_extra::add_extra_command_context(engine_state);
#[cfg(feature = "dataframe")]

View File

@ -30,12 +30,7 @@ pub(crate) fn run_commands(
// if the --no-config-file(-n) flag is passed, do not load plugin, env, or config files
if parsed_nu_cli_args.no_config_file.is_none() {
#[cfg(feature = "plugin")]
read_plugin_file(
engine_state,
&mut stack,
parsed_nu_cli_args.plugin_file,
NUSHELL_FOLDER,
);
read_plugin_file(engine_state, parsed_nu_cli_args.plugin_file, NUSHELL_FOLDER);
perf(
"read plugins",
@ -155,12 +150,7 @@ pub(crate) fn run_file(
if parsed_nu_cli_args.no_config_file.is_none() {
let start_time = std::time::Instant::now();
#[cfg(feature = "plugin")]
read_plugin_file(
engine_state,
&mut stack,
parsed_nu_cli_args.plugin_file,
NUSHELL_FOLDER,
);
read_plugin_file(engine_state, parsed_nu_cli_args.plugin_file, NUSHELL_FOLDER);
perf(
"read plugins",
start_time,

View File

@ -117,7 +117,7 @@ fn test_config_path_helper(playground: &mut Playground, config_dir_nushell: Path
#[cfg(feature = "plugin")]
{
let plugin_path = config_dir_nushell.join("plugin.nu");
let plugin_path = config_dir_nushell.join("plugin.msgpackz");
let canon_plugin_path =
adjust_canonicalization(std::fs::canonicalize(&plugin_path).unwrap_or(plugin_path));
let actual = run(playground, "$nu.plugin-path");
@ -161,7 +161,7 @@ fn test_default_symlink_config_path_broken_symlink_config_files() {
"history.txt",
"history.sqlite3",
"login.nu",
"plugin.nu",
"plugin.msgpackz",
] {
let fake_file = fake_dir.join(config_file);
File::create(playground.cwd().join(&fake_file)).unwrap();
@ -194,7 +194,7 @@ fn test_default_config_path_symlinked_config_files() {
"history.txt",
"history.sqlite3",
"login.nu",
"plugin.nu",
"plugin.msgpackz",
] {
let empty_file = playground.cwd().join(format!("empty-{config_file}"));
File::create(&empty_file).unwrap();

211
tests/plugins/cache_file.rs Normal file
View File

@ -0,0 +1,211 @@
use std::{
fs::File,
path::PathBuf,
process::{Command, Stdio},
};
use nu_protocol::{PluginCacheFile, PluginCacheItem, PluginCacheItemData};
use nu_test_support::{fs::Stub, nu, nu_with_plugins, playground::Playground};
fn example_plugin_path() -> PathBuf {
nu_test_support::commands::ensure_plugins_built();
let bins_path = nu_test_support::fs::binaries();
nu_path::canonicalize_with(
if cfg!(windows) {
"nu_plugin_example.exe"
} else {
"nu_plugin_example"
},
bins_path,
)
.expect("nu_plugin_example not found")
}
#[test]
fn plugin_add_then_restart_nu() {
let result = nu_with_plugins!(
cwd: ".",
plugins: [],
&format!("
plugin add '{}'
(
^$nu.current-exe
--config $nu.config-path
--env-config $nu.env-path
--plugin-config $nu.plugin-path
--commands 'plugin list | get name | to json --raw'
)
", example_plugin_path().display())
);
assert!(result.status.success());
assert_eq!(r#"["example"]"#, result.out);
}
#[test]
fn plugin_add_to_custom_path() {
let example_plugin_path = example_plugin_path();
Playground::setup("plugin add to custom path", |dirs, _playground| {
let result = nu!(
cwd: dirs.test(),
&format!("
plugin add --plugin-config test-plugin-file.msgpackz '{}'
", example_plugin_path.display())
);
assert!(result.status.success());
let contents = PluginCacheFile::read_from(
File::open(dirs.test().join("test-plugin-file.msgpackz"))
.expect("failed to open plugin file"),
None,
)
.expect("failed to read plugin file");
assert_eq!(1, contents.plugins.len());
assert_eq!("example", contents.plugins[0].name);
})
}
#[test]
fn plugin_rm_then_restart_nu() {
let result = nu_with_plugins!(
cwd: ".",
plugin: ("nu_plugin_example"),
r#"
plugin rm example
^$nu.current-exe --config $nu.config-path --env-config $nu.env-path --plugin-config $nu.plugin-path --commands 'plugin list | get name | to json --raw'
"#
);
assert!(result.status.success());
assert_eq!(r#"[]"#, result.out);
}
#[test]
fn plugin_rm_not_found() {
let result = nu_with_plugins!(
cwd: ".",
plugins: [],
r#"
plugin rm example
"#
);
assert!(!result.status.success());
assert!(result.err.contains("example"));
}
#[test]
fn plugin_rm_from_custom_path() {
let example_plugin_path = example_plugin_path();
Playground::setup("plugin rm from custom path", |dirs, _playground| {
let file = File::create(dirs.test().join("test-plugin-file.msgpackz"))
.expect("failed to create file");
let mut contents = PluginCacheFile::new();
contents.upsert_plugin(PluginCacheItem {
name: "example".into(),
filename: example_plugin_path,
shell: None,
data: PluginCacheItemData::Valid { commands: vec![] },
});
contents.upsert_plugin(PluginCacheItem {
name: "foo".into(),
// this doesn't exist, but it should be ok
filename: dirs.test().join("nu_plugin_foo"),
shell: None,
data: PluginCacheItemData::Valid { commands: vec![] },
});
contents
.write_to(file, None)
.expect("failed to write plugin file");
let result = nu!(
cwd: dirs.test(),
"plugin rm --plugin-config test-plugin-file.msgpackz example",
);
assert!(result.status.success());
assert!(result.err.trim().is_empty());
// Check the contents after running
let contents = PluginCacheFile::read_from(
File::open(dirs.test().join("test-plugin-file.msgpackz")).expect("failed to open file"),
None,
)
.expect("failed to read file");
assert!(!contents.plugins.iter().any(|p| p.name == "example"));
// Shouldn't remove anything else
assert!(contents.plugins.iter().any(|p| p.name == "foo"));
})
}
/// Running nu with a test plugin file that fails to parse on one plugin should just cause a warning
/// but the others should be loaded
#[test]
fn warning_on_invalid_plugin_item() {
let example_plugin_path = example_plugin_path();
Playground::setup("warning on invalid plugin item", |dirs, playground| {
playground.with_files(vec![
Stub::FileWithContent("config.nu", ""),
Stub::FileWithContent("env.nu", ""),
]);
let file = File::create(dirs.test().join("test-plugin-file.msgpackz"))
.expect("failed to create file");
let mut contents = PluginCacheFile::new();
contents.upsert_plugin(PluginCacheItem {
name: "example".into(),
filename: example_plugin_path,
shell: None,
data: PluginCacheItemData::Valid { commands: vec![] },
});
contents.upsert_plugin(PluginCacheItem {
name: "badtest".into(),
// this doesn't exist, but it should be ok
filename: dirs.test().join("nu_plugin_badtest"),
shell: None,
data: PluginCacheItemData::Invalid,
});
contents
.write_to(file, None)
.expect("failed to write plugin file");
let result = Command::new(nu_test_support::fs::executable_path())
.current_dir(dirs.test())
.args([
"--no-std-lib",
"--config",
"config.nu",
"--env-config",
"env.nu",
"--plugin-config",
"test-plugin-file.msgpackz",
"--commands",
"plugin list | get name | to json --raw",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to run nu");
let out = String::from_utf8_lossy(&result.stdout).trim().to_owned();
let err = String::from_utf8_lossy(&result.stderr).trim().to_owned();
println!("=== stdout\n{out}\n=== stderr\n{err}");
// The code should still execute successfully
assert!(result.status.success());
// The "example" plugin should be unaffected
assert_eq!(r#"["example"]"#, out);
// The warning should be in there
assert!(err.contains("cached plugin data"));
assert!(err.contains("badtest"));
})
}

View File

@ -1,3 +1,4 @@
mod cache_file;
mod config;
mod core_inc;
mod custom_values;