diff --git a/Cargo.lock b/Cargo.lock index 63e890c002..473d78b4db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 1e60a88ee6..a0c6121267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/crates/nu-cli/Cargo.toml b/crates/nu-cli/Cargo.toml index 0e6a228c4d..24e43519b3 100644 --- a/crates/nu-cli/Cargo.toml +++ b/crates/nu-cli/Cargo.toml @@ -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"] diff --git a/crates/nu-cli/src/config_files.rs b/crates/nu-cli/src/config_files.rs index 3519dfc711..760a3f7e97 100644 --- a/crates/nu-cli/src/config_files.rs +++ b/crates/nu-cli/src/config_files.rs @@ -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>, 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,38 +64,107 @@ 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()); - if let Ok(contents) = std::fs::read(&plugin_path) { - perf( - &format!("read plugin file {}", &plug_path), - start_time, - file!(), - line!(), - column!(), - engine_state.get_config().use_ansi_coloring, - ); - start_time = std::time::Instant::now(); - eval_source( - engine_state, - stack, - &contents, - &plugin_filename, - PipelineData::empty(), - false, - ); - perf( - &format!("eval_source plugin file {}", &plug_path), - start_time, - file!(), - line!(), - column!(), - engine_state.get_config().use_ansi_coloring, + // 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; + } + }; + + perf( + &format!("read plugin file {}", plugin_path.display()), + start_time, + file!(), + line!(), + column!(), + engine_state.get_config().use_ansi_coloring, + ); + start_time = std::time::Instant::now(); + + 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!("load plugin file {}", plugin_path.display()), + start_time, + file!(), + line!(), + column!(), + engine_state.get_config().use_ansi_coloring, + ); } } @@ -79,15 +174,30 @@ pub fn add_plugin_file( plugin_file: Option>, 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::>::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 +} diff --git a/crates/nu-cli/src/lib.rs b/crates/nu-cli/src/lib.rs index d1008d5544..c4342dc3a0 100644 --- a/crates/nu-cli/src/lib.rs +++ b/crates/nu-cli/src/lib.rs @@ -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; diff --git a/crates/nu-cmd-lang/src/core_commands/mod.rs b/crates/nu-cmd-lang/src/core_commands/mod.rs index 5fb6c48cd2..87e42783e1 100644 --- a/crates/nu-cmd-lang/src/core_commands/mod.rs +++ b/crates/nu-cmd-lang/src/core_commands/mod.rs @@ -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; diff --git a/crates/nu-cmd-lang/src/default_context.rs b/crates/nu-cmd-lang/src/default_context.rs index f08717d4bd..2e1681af74 100644 --- a/crates/nu-cmd-lang/src/default_context.rs +++ b/crates/nu-cmd-lang/src/default_context.rs @@ -63,9 +63,6 @@ pub fn create_default_context() -> EngineState { While, }; - //#[cfg(feature = "plugin")] - bind_command!(PluginCommand, PluginList, PluginStop, Register,); - working_set.render() }; diff --git a/crates/nu-cmd-plugin/Cargo.toml b/crates/nu-cmd-plugin/Cargo.toml new file mode 100644 index 0000000000..1df99fe266 --- /dev/null +++ b/crates/nu-cmd-plugin/Cargo.toml @@ -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] diff --git a/crates/nu-cmd-plugin/LICENSE b/crates/nu-cmd-plugin/LICENSE new file mode 100644 index 0000000000..ae174e8595 --- /dev/null +++ b/crates/nu-cmd-plugin/LICENSE @@ -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. diff --git a/crates/nu-cmd-plugin/README.md b/crates/nu-cmd-plugin/README.md new file mode 100644 index 0000000000..0c62ad146e --- /dev/null +++ b/crates/nu-cmd-plugin/README.md @@ -0,0 +1,3 @@ +# nu-cmd-plugin + +This crate implements Nushell commands related to plugin management. diff --git a/crates/nu-cmd-plugin/src/commands/mod.rs b/crates/nu-cmd-plugin/src/commands/mod.rs new file mode 100644 index 0000000000..3e927747f1 --- /dev/null +++ b/crates/nu-cmd-plugin/src/commands/mod.rs @@ -0,0 +1,5 @@ +mod plugin; +mod register; + +pub use plugin::*; +pub use register::Register; diff --git a/crates/nu-cmd-plugin/src/commands/plugin/add.rs b/crates/nu-cmd-plugin/src/commands/plugin/add.rs new file mode 100644 index 0000000000..156b80f1f1 --- /dev/null +++ b/crates/nu-cmd-plugin/src/commands/plugin/add.rs @@ -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 { + 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 { + let filename: Spanned = call.req(engine_state, stack, 0)?; + let shell: Option> = 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()) + } +} diff --git a/crates/nu-cmd-lang/src/core_commands/plugin_list.rs b/crates/nu-cmd-plugin/src/commands/plugin/list.rs similarity index 98% rename from crates/nu-cmd-lang/src/core_commands/plugin_list.rs rename to crates/nu-cmd-plugin/src/commands/plugin/list.rs index 80f7e56a00..dfdb4bb3ee 100644 --- a/crates/nu-cmd-lang/src/core_commands/plugin_list.rs +++ b/crates/nu-cmd-plugin/src/commands/plugin/list.rs @@ -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 { diff --git a/crates/nu-cmd-lang/src/core_commands/plugin.rs b/crates/nu-cmd-plugin/src/commands/plugin/mod.rs similarity index 69% rename from crates/nu-cmd-lang/src/core_commands/plugin.rs rename to crates/nu-cmd-plugin/src/commands/plugin/mod.rs index 7ac6bf09eb..7ec2d77731 100644 --- a/crates/nu-cmd-lang/src/core_commands/plugin.rs +++ b/crates/nu-cmd-plugin/src/commands/plugin/mod.rs @@ -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, + }, ] } } diff --git a/crates/nu-cmd-plugin/src/commands/plugin/rm.rs b/crates/nu-cmd-plugin/src/commands/plugin/rm.rs new file mode 100644 index 0000000000..9845c35342 --- /dev/null +++ b/crates/nu-cmd-plugin/src/commands/plugin/rm.rs @@ -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 { + 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 { + let name: Spanned = 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()) + } +} diff --git a/crates/nu-cmd-lang/src/core_commands/plugin_stop.rs b/crates/nu-cmd-plugin/src/commands/plugin/stop.rs similarity index 97% rename from crates/nu-cmd-lang/src/core_commands/plugin_stop.rs rename to crates/nu-cmd-plugin/src/commands/plugin/stop.rs index 7171fbf339..54f4cdf90e 100644 --- a/crates/nu-cmd-lang/src/core_commands/plugin_stop.rs +++ b/crates/nu-cmd-plugin/src/commands/plugin/stop.rs @@ -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 { diff --git a/crates/nu-cmd-lang/src/core_commands/register.rs b/crates/nu-cmd-plugin/src/commands/register.rs similarity index 93% rename from crates/nu-cmd-lang/src/core_commands/register.rs rename to crates/nu-cmd-plugin/src/commands/register.rs index 09ccaf866f..df0fe62361 100644 --- a/crates/nu-cmd-lang/src/core_commands/register.rs +++ b/crates/nu-cmd-plugin/src/commands/register.rs @@ -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 } diff --git a/crates/nu-cmd-plugin/src/default_context.rs b/crates/nu-cmd-plugin/src/default_context.rs new file mode 100644 index 0000000000..32f48ad037 --- /dev/null +++ b/crates/nu-cmd-plugin/src/default_context.rs @@ -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 +} diff --git a/crates/nu-cmd-plugin/src/lib.rs b/crates/nu-cmd-plugin/src/lib.rs new file mode 100644 index 0000000000..d6fb5bbda1 --- /dev/null +++ b/crates/nu-cmd-plugin/src/lib.rs @@ -0,0 +1,8 @@ +//! Nushell commands for managing plugins. + +mod commands; +mod default_context; +mod util; + +pub use commands::*; +pub use default_context::*; diff --git a/crates/nu-cmd-plugin/src/util.rs b/crates/nu-cmd-plugin/src/util.rs new file mode 100644 index 0000000000..c728c12e89 --- /dev/null +++ b/crates/nu-cmd-plugin/src/util.rs @@ -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>, + 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(()) +} diff --git a/crates/nu-parser/src/parse_keywords.rs b/crates/nu-parser/src/parse_keywords.rs index 7fece081a5..537d5c3f80 100644 --- a/crates/nu-parser/src/parse_keywords.rs +++ b/crates/nu-parser/src/parse_keywords.rs @@ -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 diff --git a/crates/nu-plugin/src/lib.rs b/crates/nu-plugin/src/lib.rs index f3e4bf9ce5..7ed3b67004 100644 --- a/crates/nu-plugin/src/lib.rs +++ b/crates/nu-plugin/src/lib.rs @@ -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}; diff --git a/crates/nu-plugin/src/plugin/interface/plugin.rs b/crates/nu-plugin/src/plugin/interface/plugin.rs index 5be513a2f1..9e773665a0 100644 --- a/crates/nu-plugin/src/plugin/interface/plugin.rs +++ b/crates/nu-plugin/src/plugin/interface/plugin.rs @@ -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![], })?; diff --git a/crates/nu-plugin/src/plugin/mod.rs b/crates/nu-plugin/src/plugin/mod.rs index 0345873404..e59b64a38f 100644 --- a/crates/nu-plugin/src/plugin/mod.rs +++ b/crates/nu-plugin/src/plugin/mod.rs @@ -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, +) { + 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, +) -> 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 = + 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(), + }), + } +} diff --git a/crates/nu-protocol/Cargo.toml b/crates/nu-protocol/Cargo.toml index 0486d89149..a491a38e74 100644 --- a/crates/nu-protocol/Cargo.toml +++ b/crates/nu-protocol/Cargo.toml @@ -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] diff --git a/crates/nu-protocol/src/engine/engine_state.rs b/crates/nu-protocol/src/engine/engine_state.rs index eca752f1e2..a870c99b44 100644 --- a/crates/nu-protocol/src/engine/engine_state.rs +++ b/crates/nu-protocol/src/engine/engine_state.rs @@ -24,7 +24,7 @@ use std::{ type PoisonDebuggerError<'a> = PoisonError>>; #[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>, pub table_decl_id: Option, #[cfg(feature = "plugin")] - pub plugin_signatures: Option, + pub plugin_path: Option, #[cfg(feature = "plugin")] plugins: Vec>, config_path: HashMap, @@ -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,93 +480,58 @@ 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, + ) -> 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}`"); - } + // 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![err.into()], + }) + } + } + }?; - 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(); + // Update the given signatures + for item in updated_items { + contents.upsert_plugin(item); + } - // 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}"}, - span: None, - help: None, - inner: vec![], - }) - }) - }) - }) + // 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 diff --git a/crates/nu-protocol/src/engine/state_delta.rs b/crates/nu-protocol/src/engine/state_delta.rs index 3cfc4384ee..657c10ee11 100644 --- a/crates/nu-protocol/src/engine/state_delta.rs +++ b/crates/nu-protocol/src/engine/state_delta.rs @@ -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, #[cfg(feature = "plugin")] - pub(super) plugins_changed: bool, // marks whether plugin file should be updated - #[cfg(feature = "plugin")] pub(super) plugins: Vec>, + #[cfg(feature = "plugin")] + pub(super) plugin_cache_items: Vec, } 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![], } } diff --git a/crates/nu-protocol/src/engine/state_working_set.rs b/crates/nu-protocol/src/engine/state_working_set.rs index 4340521224..9070fea925 100644 --- a/crates/nu-protocol/src/engine/state_working_set.rs +++ b/crates/nu-protocol/src/engine/state_working_set.rs @@ -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 { self.move_predecls_to_overlay(); diff --git a/crates/nu-protocol/src/errors/shell_error.rs b/crates/nu-protocol/src/errors/shell_error.rs index a97f36951c..61559232e5 100644 --- a/crates/nu-protocol/src/errors/shell_error.rs +++ b/crates/nu-protocol/src/errors/shell_error.rs @@ -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 diff --git a/crates/nu-protocol/src/eval_const.rs b/crates/nu-protocol/src/eval_const.rs index ff57ac9e85..a3992ec945 100644 --- a/crates/nu-protocol/src/eval_const.rs +++ b/crates/nu-protocol/src/eval_const.rs @@ -116,7 +116,7 @@ pub fn create_nu_constant(engine_state: &EngineState, span: Span) -> Result Result { // 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, diff --git a/crates/nu-protocol/src/plugin/cache_file/mod.rs b/crates/nu-protocol/src/plugin/cache_file/mod.rs new file mode 100644 index 0000000000..58d16f342c --- /dev/null +++ b/crates/nu-protocol/src/plugin/cache_file/mod.rs @@ -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, +} + +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, + ) -> Result { + // 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, + ) -> 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, + + /// 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) -> 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, + }, + #[serde( + serialize_with = "serialize_invalid", + deserialize_with = "deserialize_invalid" + )] + Invalid, +} + +fn serialize_invalid(serializer: S) -> Result +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; diff --git a/crates/nu-protocol/src/plugin/cache_file/tests.rs b/crates/nu-protocol/src/plugin/cache_file/tests.rs new file mode 100644 index 0000000000..56ded46948 --- /dev/null +++ b/crates/nu-protocol/src/plugin/cache_file/tests.rs @@ -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); +} diff --git a/crates/nu-protocol/src/plugin/identity.rs b/crates/nu-protocol/src/plugin/identity.rs index 16da443532..e969335497 100644 --- a/crates/nu-protocol/src/plugin/identity.rs +++ b/crates/nu-protocol/src/plugin/identity.rs @@ -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] diff --git a/crates/nu-protocol/src/plugin/mod.rs b/crates/nu-protocol/src/plugin/mod.rs index 5367e460d6..46bdbc2fd1 100644 --- a/crates/nu-protocol/src/plugin/mod.rs +++ b/crates/nu-protocol/src/plugin/mod.rs @@ -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::*; diff --git a/crates/nu-protocol/src/plugin/signature.rs b/crates/nu-protocol/src/plugin/signature.rs index 1e4802cf56..83197b18bb 100644 --- a/crates/nu-protocol/src/plugin/signature.rs +++ b/crates/nu-protocol/src/plugin/signature.rs @@ -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, diff --git a/crates/nu-protocol/src/signature.rs b/crates/nu-protocol/src/signature.rs index 05949f36d8..7f3a48cc35 100644 --- a/crates/nu-protocol/src/signature.rs +++ b/crates/nu-protocol/src/signature.rs @@ -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", diff --git a/crates/nu-test-support/src/macros.rs b/crates/nu-test-support/src/macros.rs index 0b689ea107..6d8b8a161f 100644 --- a/crates/nu-test-support/src/macros.rs +++ b/crates/nu-test-support/src/macros.rs @@ -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,12 +329,14 @@ 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_file = temp.path().join(name); - std::fs::File::create(&temp_file).expect("couldn't create temporary config file"); - temp_file - }); + 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(); diff --git a/src/command.rs b/src/command.rs index 049b9606ca..60eb1568f3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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, ); } diff --git a/src/config_files.rs b/src/config_files.rs index af58d1f233..3eb918796b 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -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); diff --git a/src/main.rs b/src/main.rs index 689f846722..cbc195db48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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")] diff --git a/src/run.rs b/src/run.rs index eb6af3f0ab..81274df3de 100644 --- a/src/run.rs +++ b/src/run.rs @@ -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, diff --git a/src/tests/test_config_path.rs b/src/tests/test_config_path.rs index a8579c8bc0..534ac38a27 100644 --- a/src/tests/test_config_path.rs +++ b/src/tests/test_config_path.rs @@ -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(); diff --git a/tests/plugins/cache_file.rs b/tests/plugins/cache_file.rs new file mode 100644 index 0000000000..c167390820 --- /dev/null +++ b/tests/plugins/cache_file.rs @@ -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")); + }) +} diff --git a/tests/plugins/mod.rs b/tests/plugins/mod.rs index 7092b3a50d..26db8376a9 100644 --- a/tests/plugins/mod.rs +++ b/tests/plugins/mod.rs @@ -1,3 +1,4 @@ +mod cache_file; mod config; mod core_inc; mod custom_values;