diff --git a/Cargo.lock b/Cargo.lock index 299e67a51e..bbc2c6ab51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,6 +1101,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + [[package]] name = "difflib" version = "0.4.0" @@ -3069,10 +3075,24 @@ dependencies = [ "semver", "serde", "serde_json", + "thiserror", "typetag", "windows 0.52.0", ] +[[package]] +name = "nu-plugin-test-support" +version = "0.91.1" +dependencies = [ + "difference", + "nu-engine", + "nu-parser", + "nu-plugin", + "nu-protocol", + "serde", + "typetag", +] + [[package]] name = "nu-pretty-hex" version = "0.91.1" diff --git a/Cargo.toml b/Cargo.toml index 841eadd713..8db64dbdb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/nu-pretty-hex", "crates/nu-protocol", "crates/nu-plugin", + "crates/nu-plugin-test-support", "crates/nu_plugin_inc", "crates/nu_plugin_gstat", "crates/nu_plugin_example", diff --git a/crates/nu-parser/src/parse_keywords.rs b/crates/nu-parser/src/parse_keywords.rs index 4a67c98be8..c2b7c7b218 100644 --- a/crates/nu-parser/src/parse_keywords.rs +++ b/crates/nu-parser/src/parse_keywords.rs @@ -3736,7 +3736,7 @@ pub fn parse_register(working_set: &mut StateWorkingSet, lite_command: &LiteComm for signature in signatures { // create plugin command declaration (need struct impl Command) // store declaration in working set - let plugin_decl = PluginDeclaration::new(&plugin, signature); + let plugin_decl = PluginDeclaration::new(plugin.clone(), signature); working_set.add_decl(Box::new(plugin_decl)); } diff --git a/crates/nu-plugin-test-support/Cargo.toml b/crates/nu-plugin-test-support/Cargo.toml new file mode 100644 index 0000000000..21036eebab --- /dev/null +++ b/crates/nu-plugin-test-support/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nu-plugin-test-support" +version = "0.91.1" +edition = "2021" +license = "MIT" +repository = "https://github.com/nushell/nushell/tree/main/crates/nu-plugin-test-support" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +nu-engine = { path = "../nu-engine", version = "0.91.1", features = ["plugin"] } +nu-protocol = { path = "../nu-protocol", version = "0.91.1", features = ["plugin"] } +nu-parser = { path = "../nu-parser", version = "0.91.1", features = ["plugin"] } +nu-plugin = { path = "../nu-plugin", version = "0.91.1" } +difference = "2.0" + +[dev-dependencies] +typetag = "0.2" +serde = "1.0" diff --git a/crates/nu-plugin-test-support/LICENSE b/crates/nu-plugin-test-support/LICENSE new file mode 100644 index 0000000000..ae174e8595 --- /dev/null +++ b/crates/nu-plugin-test-support/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-plugin-test-support/README.md b/crates/nu-plugin-test-support/README.md new file mode 100644 index 0000000000..f13ed95a57 --- /dev/null +++ b/crates/nu-plugin-test-support/README.md @@ -0,0 +1,4 @@ +# nu-plugin-test-support + +This crate provides helpers for running tests on plugin commands, and is intended to be included in +the `dev-dependencies` of plugin crates for testing. diff --git a/crates/nu-plugin-test-support/src/fake_persistent_plugin.rs b/crates/nu-plugin-test-support/src/fake_persistent_plugin.rs new file mode 100644 index 0000000000..40bba951c2 --- /dev/null +++ b/crates/nu-plugin-test-support/src/fake_persistent_plugin.rs @@ -0,0 +1,71 @@ +use std::{ + any::Any, + sync::{Arc, OnceLock}, +}; + +use nu_plugin::{GetPlugin, PluginInterface}; +use nu_protocol::{ + engine::{EngineState, Stack}, + PluginGcConfig, PluginIdentity, RegisteredPlugin, ShellError, +}; + +pub struct FakePersistentPlugin { + identity: PluginIdentity, + plugin: OnceLock, +} + +impl FakePersistentPlugin { + pub fn new(identity: PluginIdentity) -> FakePersistentPlugin { + FakePersistentPlugin { + identity, + plugin: OnceLock::new(), + } + } + + pub fn initialize(&self, interface: PluginInterface) { + self.plugin.set(interface).unwrap_or_else(|_| { + panic!("Tried to initialize an already initialized FakePersistentPlugin"); + }) + } +} + +impl RegisteredPlugin for FakePersistentPlugin { + fn identity(&self) -> &PluginIdentity { + &self.identity + } + + fn is_running(&self) -> bool { + true + } + + fn pid(&self) -> Option { + None + } + + fn set_gc_config(&self, _gc_config: &PluginGcConfig) { + // We don't have a GC + } + + fn stop(&self) -> Result<(), ShellError> { + // We can't stop + Ok(()) + } + + fn as_any(self: Arc) -> Arc { + self + } +} + +impl GetPlugin for FakePersistentPlugin { + fn get_plugin( + self: Arc, + _context: Option<(&EngineState, &mut Stack)>, + ) -> Result { + self.plugin + .get() + .cloned() + .ok_or_else(|| ShellError::PluginFailedToLoad { + msg: "FakePersistentPlugin was not initialized".into(), + }) + } +} diff --git a/crates/nu-plugin-test-support/src/fake_register.rs b/crates/nu-plugin-test-support/src/fake_register.rs new file mode 100644 index 0000000000..fa05f7fbf4 --- /dev/null +++ b/crates/nu-plugin-test-support/src/fake_register.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use nu_plugin::{Plugin, PluginDeclaration}; +use nu_protocol::{engine::StateWorkingSet, RegisteredPlugin, ShellError}; + +use crate::{fake_persistent_plugin::FakePersistentPlugin, spawn_fake_plugin::spawn_fake_plugin}; + +/// Register all of the commands from the plugin into the [`StateWorkingSet`] +pub fn fake_register( + working_set: &mut StateWorkingSet, + name: &str, + plugin: Arc, +) -> Result, ShellError> { + let reg_plugin = spawn_fake_plugin(name, plugin.clone())?; + let reg_plugin_clone = reg_plugin.clone(); + + for command in plugin.commands() { + let signature = command.signature(); + let decl = PluginDeclaration::new(reg_plugin.clone(), signature); + working_set.add_decl(Box::new(decl)); + } + + let identity = reg_plugin.identity().clone(); + working_set.find_or_create_plugin(&identity, move || reg_plugin); + + Ok(reg_plugin_clone) +} diff --git a/crates/nu-plugin-test-support/src/lib.rs b/crates/nu-plugin-test-support/src/lib.rs new file mode 100644 index 0000000000..e46449d259 --- /dev/null +++ b/crates/nu-plugin-test-support/src/lib.rs @@ -0,0 +1,71 @@ +//! Test support for [Nushell](https://nushell.sh) plugins. +//! +//! # Example +//! +//! ```rust +//! use std::sync::Arc; +//! +//! use nu_plugin::*; +//! use nu_plugin_test_support::PluginTest; +//! use nu_protocol::{PluginSignature, PipelineData, Type, Span, Value, LabeledError}; +//! use nu_protocol::IntoInterruptiblePipelineData; +//! +//! struct LowercasePlugin; +//! struct Lowercase; +//! +//! impl PluginCommand for Lowercase { +//! type Plugin = LowercasePlugin; +//! +//! fn signature(&self) -> PluginSignature { +//! PluginSignature::build("lowercase") +//! .usage("Convert each string in a stream to lowercase") +//! .input_output_type(Type::List(Type::String.into()), Type::List(Type::String.into())) +//! } +//! +//! fn run( +//! &self, +//! plugin: &LowercasePlugin, +//! engine: &EngineInterface, +//! call: &EvaluatedCall, +//! input: PipelineData, +//! ) -> Result { +//! let span = call.head; +//! Ok(input.map(move |value| { +//! value.as_str() +//! .map(|string| Value::string(string.to_lowercase(), span)) +//! // Errors in a stream should be returned as values. +//! .unwrap_or_else(|err| Value::error(err, span)) +//! }, None)?) +//! } +//! } +//! +//! impl Plugin for LowercasePlugin { +//! fn commands(&self) -> Vec>> { +//! vec![Box::new(Lowercase)] +//! } +//! } +//! +//! fn test_lowercase() -> Result<(), LabeledError> { +//! let input = vec![Value::test_string("FooBar")].into_pipeline_data(None); +//! let output = PluginTest::new("lowercase", LowercasePlugin.into())? +//! .eval_with("lowercase", input)? +//! .into_value(Span::test_data()); +//! +//! assert_eq!( +//! Value::test_list(vec![ +//! Value::test_string("foobar") +//! ]), +//! output +//! ); +//! Ok(()) +//! } +//! # +//! # test_lowercase().unwrap(); +//! ``` + +mod fake_persistent_plugin; +mod fake_register; +mod plugin_test; +mod spawn_fake_plugin; + +pub use plugin_test::PluginTest; diff --git a/crates/nu-plugin-test-support/src/plugin_test.rs b/crates/nu-plugin-test-support/src/plugin_test.rs new file mode 100644 index 0000000000..4eac6d81a2 --- /dev/null +++ b/crates/nu-plugin-test-support/src/plugin_test.rs @@ -0,0 +1,264 @@ +use std::{convert::Infallible, sync::Arc}; + +use difference::Changeset; +use nu_engine::eval_block; +use nu_parser::parse; +use nu_plugin::{Plugin, PluginCommand, PluginCustomValue, PluginSource}; +use nu_protocol::{ + debugger::WithoutDebug, + engine::{EngineState, Stack, StateWorkingSet}, + report_error_new, LabeledError, PipelineData, PluginExample, ShellError, Span, Value, +}; + +use crate::fake_register::fake_register; + +/// An object through which plugins can be tested. +pub struct PluginTest { + engine_state: EngineState, + source: Arc, + entry_num: usize, +} + +impl PluginTest { + /// Create a new test for the given `plugin` named `name`. + /// + /// # Example + /// + /// ```rust,no_run + /// # use nu_plugin_test_support::PluginTest; + /// # use nu_protocol::ShellError; + /// # use nu_plugin::*; + /// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result { + /// PluginTest::new("my_plugin", MyPlugin.into()) + /// # } + /// ``` + pub fn new( + name: &str, + plugin: Arc, + ) -> Result { + let mut engine_state = EngineState::new(); + let mut working_set = StateWorkingSet::new(&engine_state); + + let reg_plugin = fake_register(&mut working_set, name, plugin)?; + let source = Arc::new(PluginSource::new(reg_plugin)); + + engine_state.merge_delta(working_set.render())?; + + Ok(PluginTest { + engine_state, + source, + entry_num: 1, + }) + } + + /// Get the [`EngineState`]. + pub fn engine_state(&self) -> &EngineState { + &self.engine_state + } + + /// Get a mutable reference to the [`EngineState`]. + pub fn engine_state_mut(&mut self) -> &mut EngineState { + &mut self.engine_state + } + + /// Evaluate some Nushell source code with the plugin commands in scope with the given input to + /// the pipeline. + /// + /// # Example + /// + /// ```rust,no_run + /// # use nu_plugin_test_support::PluginTest; + /// # use nu_protocol::{ShellError, Span, Value, IntoInterruptiblePipelineData}; + /// # use nu_plugin::*; + /// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> { + /// let result = PluginTest::new("my_plugin", MyPlugin.into())? + /// .eval_with( + /// "my-command", + /// vec![Value::test_int(42)].into_pipeline_data(None) + /// )? + /// .into_value(Span::test_data()); + /// assert_eq!(Value::test_string("42"), result); + /// # Ok(()) + /// # } + /// ``` + pub fn eval_with( + &mut self, + nu_source: &str, + input: PipelineData, + ) -> Result { + let mut working_set = StateWorkingSet::new(&self.engine_state); + let fname = format!("entry #{}", self.entry_num); + self.entry_num += 1; + + // Parse the source code + let block = parse(&mut working_set, Some(&fname), nu_source.as_bytes(), false); + + // Check for parse errors + let error = if !working_set.parse_errors.is_empty() { + // ShellError doesn't have ParseError, use LabeledError to contain it. + let mut error = LabeledError::new("Example failed to parse"); + error.inner.extend( + working_set + .parse_errors + .iter() + .map(LabeledError::from_diagnostic), + ); + Some(ShellError::LabeledError(error.into())) + } else { + None + }; + + // Merge into state + self.engine_state.merge_delta(working_set.render())?; + + // Return error if set. We merge the delta even if we have errors so that printing the error + // based on the engine state still works. + if let Some(error) = error { + return Err(error); + } + + // Serialize custom values in the input + let source = self.source.clone(); + let input = input.map( + move |mut value| match PluginCustomValue::serialize_custom_values_in(&mut value) { + Ok(()) => { + // Make sure to mark them with the source so they pass correctly, too. + PluginCustomValue::add_source(&mut value, &source); + value + } + Err(err) => Value::error(err, value.span()), + }, + None, + )?; + + // Eval the block with the input + let mut stack = Stack::new().capture(); + eval_block::(&self.engine_state, &mut stack, &block, input)?.map( + |mut value| { + // Make sure to deserialize custom values + match PluginCustomValue::deserialize_custom_values_in(&mut value) { + Ok(()) => value, + Err(err) => Value::error(err, value.span()), + } + }, + None, + ) + } + + /// Evaluate some Nushell source code with the plugin commands in scope. + /// + /// # Example + /// + /// ```rust,no_run + /// # use nu_plugin_test_support::PluginTest; + /// # use nu_protocol::{ShellError, Span, Value, IntoInterruptiblePipelineData}; + /// # use nu_plugin::*; + /// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> { + /// let result = PluginTest::new("my_plugin", MyPlugin.into())? + /// .eval("42 | my-command")? + /// .into_value(Span::test_data()); + /// assert_eq!(Value::test_string("42"), result); + /// # Ok(()) + /// # } + /// ``` + pub fn eval(&mut self, nu_source: &str) -> Result { + self.eval_with(nu_source, PipelineData::Empty) + } + + /// Test a list of plugin examples. Prints an error for each failing example. + /// + /// See [`.test_command_examples()`] for easier usage of this method on a command's examples. + /// + /// # Example + /// + /// ```rust,no_run + /// # use nu_plugin_test_support::PluginTest; + /// # use nu_protocol::{ShellError, PluginExample, Value}; + /// # use nu_plugin::*; + /// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> { + /// PluginTest::new("my_plugin", MyPlugin.into())? + /// .test_examples(&[ + /// PluginExample { + /// example: "my-command".into(), + /// description: "Run my-command".into(), + /// result: Some(Value::test_string("my-command output")), + /// }, + /// ]) + /// # } + /// ``` + pub fn test_examples(&mut self, examples: &[PluginExample]) -> Result<(), ShellError> { + let mut failed = false; + + for example in examples { + let mut failed_header = || { + failed = true; + eprintln!("\x1b[1mExample:\x1b[0m {}", example.example); + eprintln!("\x1b[1mDescription:\x1b[0m {}", example.description); + }; + if let Some(expectation) = &example.result { + match self.eval(&example.example) { + Ok(data) => { + let mut value = data.into_value(Span::test_data()); + + // Set all of the spans in the value to test_data() to avoid unnecessary + // differences when printing + let _: Result<(), Infallible> = value.recurse_mut(&mut |here| { + here.set_span(Span::test_data()); + Ok(()) + }); + + // Check for equality with the result + if *expectation != value { + // If they're not equal, print a diff of the debug format + let expectation_formatted = format!("{:#?}", expectation); + let value_formatted = format!("{:#?}", value); + let diff = + Changeset::new(&expectation_formatted, &value_formatted, "\n"); + failed_header(); + eprintln!("\x1b[1mResult:\x1b[0m {diff}"); + } else { + println!("{:?}, {:?}", expectation, value); + } + } + Err(err) => { + // Report the error + failed_header(); + report_error_new(&self.engine_state, &err); + } + } + } + } + + if !failed { + Ok(()) + } else { + Err(ShellError::GenericError { + error: "Some examples failed. See the error output for details".into(), + msg: "".into(), + span: None, + help: None, + inner: vec![], + }) + } + } + + /// Test examples from a command. + /// + /// # Example + /// + /// ```rust,no_run + /// # use nu_plugin_test_support::PluginTest; + /// # use nu_protocol::ShellError; + /// # use nu_plugin::*; + /// # fn test(MyPlugin: impl Plugin + Send + 'static, MyCommand: impl PluginCommand) -> Result<(), ShellError> { + /// PluginTest::new("my_plugin", MyPlugin.into())? + /// .test_command_examples(&MyCommand) + /// # } + /// ``` + pub fn test_command_examples( + &mut self, + command: &impl PluginCommand, + ) -> Result<(), ShellError> { + self.test_examples(&command.signature().examples) + } +} diff --git a/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs b/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs new file mode 100644 index 0000000000..e29dea5e1c --- /dev/null +++ b/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs @@ -0,0 +1,77 @@ +use std::sync::{mpsc, Arc}; + +use nu_plugin::{ + InterfaceManager, Plugin, PluginInput, PluginInterfaceManager, PluginOutput, PluginRead, + PluginSource, PluginWrite, +}; +use nu_protocol::{PluginIdentity, ShellError}; + +use crate::fake_persistent_plugin::FakePersistentPlugin; + +struct FakePluginRead(mpsc::Receiver); +struct FakePluginWrite(mpsc::Sender); + +impl PluginRead for FakePluginRead { + fn read(&mut self) -> Result, ShellError> { + Ok(self.0.recv().ok()) + } +} + +impl PluginWrite for FakePluginWrite { + fn write(&self, data: &T) -> Result<(), ShellError> { + self.0 + .send(data.clone()) + .map_err(|err| ShellError::IOError { + msg: err.to_string(), + }) + } + + fn flush(&self) -> Result<(), ShellError> { + Ok(()) + } +} + +fn fake_plugin_channel() -> (FakePluginRead, FakePluginWrite) { + let (tx, rx) = mpsc::channel(); + (FakePluginRead(rx), FakePluginWrite(tx)) +} + +/// Spawn a plugin on another thread and return the registration +pub(crate) fn spawn_fake_plugin( + name: &str, + plugin: Arc, +) -> Result, ShellError> { + let (input_read, input_write) = fake_plugin_channel::(); + let (output_read, output_write) = fake_plugin_channel::(); + + let identity = PluginIdentity::new_fake(name); + let reg_plugin = Arc::new(FakePersistentPlugin::new(identity.clone())); + let source = Arc::new(PluginSource::new(reg_plugin.clone())); + let mut manager = PluginInterfaceManager::new(source, input_write); + + // Set up the persistent plugin with the interface before continuing + let interface = manager.get_interface(); + interface.hello()?; + reg_plugin.initialize(interface); + + // Start the interface reader on another thread + std::thread::Builder::new() + .name(format!("fake plugin interface reader ({name})")) + .spawn(move || manager.consume_all(output_read).expect("Plugin read error"))?; + + // Start the plugin on another thread + let name_string = name.to_owned(); + std::thread::Builder::new() + .name(format!("fake plugin runner ({name})")) + .spawn(move || { + nu_plugin::serve_plugin_io( + &*plugin, + &name_string, + move || input_read, + move || output_write, + ) + .expect("Plugin runner error") + })?; + + Ok(reg_plugin) +} diff --git a/crates/nu-plugin-test-support/tests/custom_value/mod.rs b/crates/nu-plugin-test-support/tests/custom_value/mod.rs new file mode 100644 index 0000000000..1fad1e2f9e --- /dev/null +++ b/crates/nu-plugin-test-support/tests/custom_value/mod.rs @@ -0,0 +1,129 @@ +use std::cmp::Ordering; + +use nu_plugin::{EngineInterface, EvaluatedCall, Plugin, SimplePluginCommand}; +use nu_plugin_test_support::PluginTest; +use nu_protocol::{ + CustomValue, LabeledError, PipelineData, PluginExample, PluginSignature, ShellError, Span, + Type, Value, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialOrd, Ord, PartialEq, Eq)] +struct CustomU32(u32); + +impl CustomU32 { + pub fn into_value(self, span: Span) -> Value { + Value::custom_value(Box::new(self), span) + } +} + +#[typetag::serde] +impl CustomValue for CustomU32 { + fn clone_value(&self, span: Span) -> Value { + self.clone().into_value(span) + } + + fn type_name(&self) -> String { + "CustomU32".into() + } + + fn to_base_value(&self, span: Span) -> Result { + Ok(Value::int(self.0 as i64, span)) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn partial_cmp(&self, other: &Value) -> Option { + other + .as_custom_value() + .ok() + .and_then(|cv| cv.as_any().downcast_ref::()) + .and_then(|other_u32| PartialOrd::partial_cmp(self, other_u32)) + } +} + +struct CustomU32Plugin; +struct IntoU32; +struct IntoIntFromU32; + +impl Plugin for CustomU32Plugin { + fn commands(&self) -> Vec>> { + vec![Box::new(IntoU32), Box::new(IntoIntFromU32)] + } +} + +impl SimplePluginCommand for IntoU32 { + type Plugin = CustomU32Plugin; + + fn signature(&self) -> PluginSignature { + PluginSignature::build("into u32") + .input_output_type(Type::Int, Type::Custom("CustomU32".into())) + .plugin_examples(vec![PluginExample { + example: "340 | into u32".into(), + description: "Make a u32".into(), + result: Some(CustomU32(340).into_value(Span::test_data())), + }]) + } + + fn run( + &self, + _plugin: &Self::Plugin, + _engine: &EngineInterface, + call: &EvaluatedCall, + input: &Value, + ) -> Result { + let value: i64 = input.as_int()?; + let value_u32 = u32::try_from(value).map_err(|err| { + LabeledError::new(format!("Not a valid u32: {value}")) + .with_label(err.to_string(), input.span()) + })?; + Ok(CustomU32(value_u32).into_value(call.head)) + } +} + +impl SimplePluginCommand for IntoIntFromU32 { + type Plugin = CustomU32Plugin; + + fn signature(&self) -> PluginSignature { + PluginSignature::build("into int from u32") + .input_output_type(Type::Custom("CustomU32".into()), Type::Int) + } + + fn run( + &self, + _plugin: &Self::Plugin, + _engine: &EngineInterface, + call: &EvaluatedCall, + input: &Value, + ) -> Result { + let value: &CustomU32 = input + .as_custom_value()? + .as_any() + .downcast_ref() + .ok_or_else(|| ShellError::TypeMismatch { + err_message: "expected CustomU32".into(), + span: input.span(), + })?; + Ok(Value::int(value.0 as i64, call.head)) + } +} + +#[test] +fn test_into_u32_examples() -> Result<(), ShellError> { + PluginTest::new("custom_u32", CustomU32Plugin.into())?.test_command_examples(&IntoU32) +} + +#[test] +fn test_into_int_from_u32() -> Result<(), ShellError> { + let result = PluginTest::new("custom_u32", CustomU32Plugin.into())? + .eval_with( + "into int from u32", + PipelineData::Value(CustomU32(42).into_value(Span::test_data()), None), + )? + .into_value(Span::test_data()); + assert_eq!(Value::test_int(42), result); + Ok(()) +} diff --git a/crates/nu-plugin-test-support/tests/hello/mod.rs b/crates/nu-plugin-test-support/tests/hello/mod.rs new file mode 100644 index 0000000000..b18404a24d --- /dev/null +++ b/crates/nu-plugin-test-support/tests/hello/mod.rs @@ -0,0 +1,65 @@ +//! Extended from `nu-plugin` examples. + +use nu_plugin::*; +use nu_plugin_test_support::PluginTest; +use nu_protocol::{LabeledError, PluginExample, PluginSignature, ShellError, Type, Value}; + +struct HelloPlugin; +struct Hello; + +impl Plugin for HelloPlugin { + fn commands(&self) -> Vec>> { + vec![Box::new(Hello)] + } +} + +impl SimplePluginCommand for Hello { + type Plugin = HelloPlugin; + + fn signature(&self) -> PluginSignature { + PluginSignature::build("hello") + .input_output_type(Type::Nothing, Type::String) + .plugin_examples(vec![PluginExample { + example: "hello".into(), + description: "Print a friendly greeting".into(), + result: Some(Value::test_string("Hello, World!")), + }]) + } + + fn run( + &self, + _plugin: &HelloPlugin, + _engine: &EngineInterface, + call: &EvaluatedCall, + _input: &Value, + ) -> Result { + Ok(Value::string("Hello, World!".to_owned(), call.head)) + } +} + +#[test] +fn test_specified_examples() -> Result<(), ShellError> { + PluginTest::new("hello", HelloPlugin.into())?.test_command_examples(&Hello) +} + +#[test] +fn test_an_error_causing_example() -> Result<(), ShellError> { + let result = PluginTest::new("hello", HelloPlugin.into())?.test_examples(&[PluginExample { + example: "hello --unknown-flag".into(), + description: "Run hello with an unknown flag".into(), + result: Some(Value::test_string("Hello, World!")), + }]); + assert!(result.is_err()); + Ok(()) +} + +#[test] +fn test_an_example_with_the_wrong_result() -> Result<(), ShellError> { + let result = PluginTest::new("hello", HelloPlugin.into())?.test_examples(&[PluginExample { + example: "hello".into(), + description: "Run hello but the example result is wrong".into(), + result: Some(Value::test_string("Goodbye, World!")), + }]); + assert!(result.is_err()); + Ok(()) +} diff --git a/crates/nu-plugin-test-support/tests/lowercase/mod.rs b/crates/nu-plugin-test-support/tests/lowercase/mod.rs new file mode 100644 index 0000000000..95c30427f3 --- /dev/null +++ b/crates/nu-plugin-test-support/tests/lowercase/mod.rs @@ -0,0 +1,76 @@ +use nu_plugin::*; +use nu_plugin_test_support::PluginTest; +use nu_protocol::{ + IntoInterruptiblePipelineData, LabeledError, PipelineData, PluginExample, PluginSignature, + ShellError, Span, Type, Value, +}; + +struct LowercasePlugin; +struct Lowercase; + +impl PluginCommand for Lowercase { + type Plugin = LowercasePlugin; + + fn signature(&self) -> PluginSignature { + PluginSignature::build("lowercase") + .usage("Convert each string in a stream to lowercase") + .input_output_type( + Type::List(Type::String.into()), + Type::List(Type::String.into()), + ) + .plugin_examples(vec![PluginExample { + example: r#"[Hello wORLD] | lowercase"#.into(), + description: "Lowercase a list of strings".into(), + result: Some(Value::test_list(vec![ + Value::test_string("hello"), + Value::test_string("world"), + ])), + }]) + } + + fn run( + &self, + _plugin: &LowercasePlugin, + _engine: &EngineInterface, + call: &EvaluatedCall, + input: PipelineData, + ) -> Result { + let span = call.head; + Ok(input.map( + move |value| { + value + .as_str() + .map(|string| Value::string(string.to_lowercase(), span)) + // Errors in a stream should be returned as values. + .unwrap_or_else(|err| Value::error(err, span)) + }, + None, + )?) + } +} + +impl Plugin for LowercasePlugin { + fn commands(&self) -> Vec>> { + vec![Box::new(Lowercase)] + } +} + +#[test] +fn test_lowercase_using_eval_with() -> Result<(), ShellError> { + let result = PluginTest::new("lowercase", LowercasePlugin.into())?.eval_with( + "lowercase", + vec![Value::test_string("HeLlO wOrLd")].into_pipeline_data(None), + )?; + + assert_eq!( + Value::test_list(vec![Value::test_string("hello world")]), + result.into_value(Span::test_data()) + ); + + Ok(()) +} + +#[test] +fn test_lowercase_examples() -> Result<(), ShellError> { + PluginTest::new("lowercase", LowercasePlugin.into())?.test_command_examples(&Lowercase) +} diff --git a/crates/nu-plugin-test-support/tests/main.rs b/crates/nu-plugin-test-support/tests/main.rs new file mode 100644 index 0000000000..29dd675ba8 --- /dev/null +++ b/crates/nu-plugin-test-support/tests/main.rs @@ -0,0 +1,3 @@ +mod custom_value; +mod hello; +mod lowercase; diff --git a/crates/nu-plugin/Cargo.toml b/crates/nu-plugin/Cargo.toml index 54df0482c0..8f2e557de1 100644 --- a/crates/nu-plugin/Cargo.toml +++ b/crates/nu-plugin/Cargo.toml @@ -22,6 +22,7 @@ log = "0.4" miette = { workspace = true } semver = "1.0" typetag = "0.2" +thiserror = "1.0" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.52", features = [ diff --git a/crates/nu-plugin/README.md b/crates/nu-plugin/README.md new file mode 100644 index 0000000000..0fb3c2deac --- /dev/null +++ b/crates/nu-plugin/README.md @@ -0,0 +1,6 @@ +# nu-plugin + +This crate provides the API for [Nushell](https://nushell.sh/) plugins. See +[the book](https://www.nushell.sh/contributor-book/plugins.html) for more information on how to get +started. + diff --git a/crates/nu-plugin/src/lib.rs b/crates/nu-plugin/src/lib.rs index e1a79104a8..b051195093 100644 --- a/crates/nu-plugin/src/lib.rs +++ b/crates/nu-plugin/src/lib.rs @@ -62,14 +62,21 @@ mod serializers; mod util; pub use plugin::{ - serve_plugin, EngineInterface, Plugin, PluginCommand, PluginEncoder, SimplePluginCommand, + serve_plugin, EngineInterface, Plugin, PluginCommand, PluginEncoder, PluginRead, PluginWrite, + SimplePluginCommand, }; pub use protocol::EvaluatedCall; pub use serializers::{json::JsonSerializer, msgpack::MsgPackSerializer}; // Used by other nu crates. #[doc(hidden)] -pub use plugin::{get_signature, PersistentPlugin, PluginDeclaration}; +pub use plugin::{ + get_signature, serve_plugin_io, EngineInterfaceManager, GetPlugin, Interface, InterfaceManager, + PersistentPlugin, PluginDeclaration, PluginExecutionCommandContext, PluginExecutionContext, + PluginInterface, PluginInterfaceManager, PluginSource, ServePluginError, +}; +#[doc(hidden)] +pub use protocol::{PluginCustomValue, PluginInput, PluginOutput}; #[doc(hidden)] pub use serializers::EncodingType; @@ -77,4 +84,4 @@ pub use serializers::EncodingType; #[doc(hidden)] pub use plugin::Encoder; #[doc(hidden)] -pub use protocol::{PluginCallResponse, PluginOutput}; +pub use protocol::PluginCallResponse; diff --git a/crates/nu-plugin/src/plugin/context.rs b/crates/nu-plugin/src/plugin/context.rs index f50fb1159b..f0c654078c 100644 --- a/crates/nu-plugin/src/plugin/context.rs +++ b/crates/nu-plugin/src/plugin/context.rs @@ -14,7 +14,10 @@ use nu_protocol::{ use crate::util::MutableCow; /// Object safe trait for abstracting operations required of the plugin context. -pub(crate) trait PluginExecutionContext: Send + Sync { +/// +/// This is not a public API. +#[doc(hidden)] +pub trait PluginExecutionContext: Send + Sync { /// The interrupt signal, if present fn ctrlc(&self) -> Option<&Arc>; /// Get engine configuration @@ -43,7 +46,10 @@ pub(crate) trait PluginExecutionContext: Send + Sync { } /// The execution context of a plugin command. Can be borrowed. -pub(crate) struct PluginExecutionCommandContext<'a> { +/// +/// This is not a public API. +#[doc(hidden)] +pub struct PluginExecutionCommandContext<'a> { identity: Arc, engine_state: Cow<'a, EngineState>, stack: MutableCow<'a, Stack>, diff --git a/crates/nu-plugin/src/plugin/declaration.rs b/crates/nu-plugin/src/plugin/declaration.rs index 3f57908f0d..2936739cbb 100644 --- a/crates/nu-plugin/src/plugin/declaration.rs +++ b/crates/nu-plugin/src/plugin/declaration.rs @@ -1,4 +1,4 @@ -use super::{PersistentPlugin, PluginExecutionCommandContext, PluginSource}; +use super::{GetPlugin, PluginExecutionCommandContext, PluginSource}; use crate::protocol::{CallInfo, EvaluatedCall}; use std::sync::Arc; @@ -6,7 +6,7 @@ use nu_engine::get_eval_expression; use nu_protocol::engine::{Command, EngineState, Stack}; use nu_protocol::{ast::Call, PluginSignature, Signature}; -use nu_protocol::{Example, PipelineData, PluginIdentity, RegisteredPlugin, ShellError}; +use nu_protocol::{Example, PipelineData, PluginIdentity, ShellError}; #[doc(hidden)] // Note: not for plugin authors / only used in nu-parser #[derive(Clone)] @@ -17,7 +17,7 @@ pub struct PluginDeclaration { } impl PluginDeclaration { - pub fn new(plugin: &Arc, signature: PluginSignature) -> Self { + pub fn new(plugin: Arc, signature: PluginSignature) -> Self { Self { name: signature.sig.name.clone(), signature, @@ -88,13 +88,7 @@ impl Command for PluginDeclaration { .and_then(|p| { // Set the garbage collector config from the local config before running p.set_gc_config(engine_config.plugin_gc.get(p.identity().name())); - p.get(|| { - // We need the current environment variables for `python` based plugins. Or - // we'll likely have a problem when a plugin is implemented in a virtual Python - // environment. - let stack = &mut stack.start_capture(); - nu_engine::env::env_to_strings(engine_state, stack) - }) + p.get_plugin(Some((engine_state, stack))) }) .map_err(|err| { let decl = engine_state.get_decl(call.decl_id); diff --git a/crates/nu-plugin/src/plugin/interface.rs b/crates/nu-plugin/src/plugin/interface.rs index acfe714851..f3c1bf8e39 100644 --- a/crates/nu-plugin/src/plugin/interface.rs +++ b/crates/nu-plugin/src/plugin/interface.rs @@ -22,11 +22,10 @@ use crate::{ mod stream; mod engine; -pub use engine::EngineInterface; -pub(crate) use engine::{EngineInterfaceManager, ReceivedPluginCall}; +pub use engine::{EngineInterface, EngineInterfaceManager, ReceivedPluginCall}; mod plugin; -pub(crate) use plugin::{PluginInterface, PluginInterfaceManager}; +pub use plugin::{PluginInterface, PluginInterfaceManager}; use self::stream::{StreamManager, StreamManagerHandle, StreamWriter, WriteStreamMessage}; @@ -45,7 +44,10 @@ const LIST_STREAM_HIGH_PRESSURE: i32 = 100; const RAW_STREAM_HIGH_PRESSURE: i32 = 50; /// Read input/output from the stream. -pub(crate) trait PluginRead { +/// +/// This is not a public API. +#[doc(hidden)] +pub trait PluginRead { /// Returns `Ok(None)` on end of stream. fn read(&mut self) -> Result, ShellError>; } @@ -72,7 +74,10 @@ where /// Write input/output to the stream. /// /// The write should be atomic, without interference from other threads. -pub(crate) trait PluginWrite: Send + Sync { +/// +/// This is not a public API. +#[doc(hidden)] +pub trait PluginWrite: Send + Sync { fn write(&self, data: &T) -> Result<(), ShellError>; /// Flush any internal buffers, if applicable. @@ -136,7 +141,10 @@ where /// /// There is typically one [`InterfaceManager`] consuming input from a background thread, and /// managing shared state. -pub(crate) trait InterfaceManager { +/// +/// This is not a public API. +#[doc(hidden)] +pub trait InterfaceManager { /// The corresponding interface type. type Interface: Interface + 'static; @@ -218,7 +226,10 @@ pub(crate) trait InterfaceManager { /// [`EngineInterface`] for the API from the plugin side to the engine. /// /// There can be multiple copies of the interface managed by a single [`InterfaceManager`]. -pub(crate) trait Interface: Clone + Send { +/// +/// This is not a public API. +#[doc(hidden)] +pub trait Interface: Clone + Send { /// The output message type, which must be capable of encapsulating a [`StreamMessage`]. type Output: From; @@ -338,7 +349,7 @@ where /// [`PipelineDataWriter::write()`] to write all of the data contained within the streams. #[derive(Default)] #[must_use] -pub(crate) enum PipelineDataWriter { +pub enum PipelineDataWriter { #[default] None, ListStream(StreamWriter, ListStream), diff --git a/crates/nu-plugin/src/plugin/interface/engine.rs b/crates/nu-plugin/src/plugin/interface/engine.rs index 6a477b8dd8..10df2baf86 100644 --- a/crates/nu-plugin/src/plugin/interface/engine.rs +++ b/crates/nu-plugin/src/plugin/interface/engine.rs @@ -30,8 +30,11 @@ use crate::sequence::Sequence; /// With each call, an [`EngineInterface`] is included that can be provided to the plugin code /// and should be used to send the response. The interface sent includes the [`PluginCallId`] for /// sending associated messages with the correct context. +/// +/// This is not a public API. #[derive(Debug)] -pub(crate) enum ReceivedPluginCall { +#[doc(hidden)] +pub enum ReceivedPluginCall { Signature { engine: EngineInterface, }, @@ -76,8 +79,11 @@ impl std::fmt::Debug for EngineInterfaceState { } /// Manages reading and dispatching messages for [`EngineInterface`]s. +/// +/// This is not a public API. #[derive(Debug)] -pub(crate) struct EngineInterfaceManager { +#[doc(hidden)] +pub struct EngineInterfaceManager { /// Shared state state: Arc, /// Channel to send received PluginCalls to. This is removed after `Goodbye` is received. diff --git a/crates/nu-plugin/src/plugin/interface/plugin.rs b/crates/nu-plugin/src/plugin/interface/plugin.rs index 5d1288166f..69f73a373b 100644 --- a/crates/nu-plugin/src/plugin/interface/plugin.rs +++ b/crates/nu-plugin/src/plugin/interface/plugin.rs @@ -104,8 +104,11 @@ struct PluginCallState { } /// Manages reading and dispatching messages for [`PluginInterface`]s. +/// +/// This is not a public API. #[derive(Debug)] -pub(crate) struct PluginInterfaceManager { +#[doc(hidden)] +pub struct PluginInterfaceManager { /// Shared state state: Arc, /// Manages stream messages and state @@ -125,7 +128,7 @@ pub(crate) struct PluginInterfaceManager { } impl PluginInterfaceManager { - pub(crate) fn new( + pub fn new( source: Arc, writer: impl PluginWrite + 'static, ) -> PluginInterfaceManager { @@ -152,7 +155,7 @@ impl PluginInterfaceManager { /// Add a garbage collector to this plugin. The manager will notify the garbage collector about /// the state of the plugin so that it can be automatically cleaned up if the plugin is /// inactive. - pub(crate) fn set_garbage_collector(&mut self, gc: Option) { + pub fn set_garbage_collector(&mut self, gc: Option) { self.gc = gc; } @@ -359,14 +362,14 @@ impl PluginInterfaceManager { /// True if there are no other copies of the state (which would mean there are no interfaces /// and no stream readers/writers) - pub(crate) fn is_finished(&self) -> bool { + pub fn is_finished(&self) -> bool { Arc::strong_count(&self.state) < 2 } /// Loop on input from the given reader as long as `is_finished()` is false /// /// Any errors will be propagated to all read streams automatically. - pub(crate) fn consume_all( + pub fn consume_all( &mut self, mut reader: impl PluginRead, ) -> Result<(), ShellError> { @@ -545,8 +548,11 @@ impl InterfaceManager for PluginInterfaceManager { } /// A reference through which a plugin can be interacted with during execution. +/// +/// This is not a public API. #[derive(Debug, Clone)] -pub(crate) struct PluginInterface { +#[doc(hidden)] +pub struct PluginInterface { /// Shared state state: Arc, /// Handle to stream manager @@ -557,7 +563,7 @@ pub(crate) struct PluginInterface { impl PluginInterface { /// Write the protocol info. This should be done after initialization - pub(crate) fn hello(&self) -> Result<(), ShellError> { + pub fn hello(&self) -> Result<(), ShellError> { self.write(PluginInput::Hello(ProtocolInfo::default()))?; self.flush() } @@ -567,14 +573,14 @@ impl PluginInterface { /// /// Note that this is automatically called when the last existing `PluginInterface` is dropped. /// You probably do not need to call this manually. - pub(crate) fn goodbye(&self) -> Result<(), ShellError> { + pub fn goodbye(&self) -> Result<(), ShellError> { self.write(PluginInput::Goodbye)?; self.flush() } /// Write an [`EngineCallResponse`]. Writes the full stream contained in any [`PipelineData`] /// before returning. - pub(crate) fn write_engine_call_response( + pub fn write_engine_call_response( &self, id: EngineCallId, response: EngineCallResponse, @@ -782,7 +788,7 @@ impl PluginInterface { } /// Get the command signatures from the plugin. - pub(crate) fn get_signature(&self) -> Result, ShellError> { + pub fn get_signature(&self) -> Result, ShellError> { match self.plugin_call(PluginCall::Signature, None)? { PluginCallResponse::Signature(sigs) => Ok(sigs), PluginCallResponse::Error(err) => Err(err.into()), @@ -793,7 +799,7 @@ impl PluginInterface { } /// Run the plugin with the given call and execution context. - pub(crate) fn run( + pub fn run( &self, call: CallInfo, context: &mut dyn PluginExecutionContext, @@ -826,7 +832,7 @@ impl PluginInterface { } /// Collapse a custom value to its base value. - pub(crate) fn custom_value_to_base_value( + pub fn custom_value_to_base_value( &self, value: Spanned, ) -> Result { @@ -834,7 +840,7 @@ impl PluginInterface { } /// Follow a numbered cell path on a custom value - e.g. `value.0`. - pub(crate) fn custom_value_follow_path_int( + pub fn custom_value_follow_path_int( &self, value: Spanned, index: Spanned, @@ -843,7 +849,7 @@ impl PluginInterface { } /// Follow a named cell path on a custom value - e.g. `value.column`. - pub(crate) fn custom_value_follow_path_string( + pub fn custom_value_follow_path_string( &self, value: Spanned, column_name: Spanned, @@ -852,7 +858,7 @@ impl PluginInterface { } /// Invoke comparison logic for custom values. - pub(crate) fn custom_value_partial_cmp( + pub fn custom_value_partial_cmp( &self, value: PluginCustomValue, mut other_value: Value, @@ -874,7 +880,7 @@ impl PluginInterface { } /// Invoke functionality for an operator on a custom value. - pub(crate) fn custom_value_operation( + pub fn custom_value_operation( &self, left: Spanned, operator: Spanned, @@ -885,7 +891,7 @@ impl PluginInterface { } /// Notify the plugin about a dropped custom value. - pub(crate) fn custom_value_dropped(&self, value: PluginCustomValue) -> Result<(), ShellError> { + pub fn custom_value_dropped(&self, value: PluginCustomValue) -> Result<(), ShellError> { // Note: the protocol is always designed to have a span with the custom value, but this // operation doesn't support one. self.custom_value_op_expecting_value( diff --git a/crates/nu-plugin/src/plugin/interface/stream.rs b/crates/nu-plugin/src/plugin/interface/stream.rs index 95268cb9e8..ca1e87c54f 100644 --- a/crates/nu-plugin/src/plugin/interface/stream.rs +++ b/crates/nu-plugin/src/plugin/interface/stream.rs @@ -170,7 +170,7 @@ impl FromShellError for Result { /// /// The `signal` contained #[derive(Debug)] -pub(crate) struct StreamWriter { +pub struct StreamWriter { id: StreamId, signal: Arc, writer: W, @@ -308,7 +308,7 @@ impl StreamWriterSignal { /// If `notify_sent()` is called more than `high_pressure_mark` times, it will wait until /// `notify_acknowledge()` is called by another thread enough times to bring the number of /// unacknowledged sent messages below that threshold. - pub fn new(high_pressure_mark: i32) -> StreamWriterSignal { + pub(crate) fn new(high_pressure_mark: i32) -> StreamWriterSignal { assert!(high_pressure_mark > 0); StreamWriterSignal { @@ -329,12 +329,12 @@ impl StreamWriterSignal { /// True if the stream was dropped and the consumer is no longer interested in it. Indicates /// that no more messages should be sent, other than `End`. - pub fn is_dropped(&self) -> Result { + pub(crate) fn is_dropped(&self) -> Result { Ok(self.lock()?.dropped) } /// Notify the writers that the stream has been dropped, so they can stop writing. - pub fn set_dropped(&self) -> Result<(), ShellError> { + pub(crate) fn set_dropped(&self) -> Result<(), ShellError> { let mut state = self.lock()?; state.dropped = true; // Unblock the writers so they can terminate @@ -345,7 +345,7 @@ impl StreamWriterSignal { /// Track that a message has been sent. Returns `Ok(true)` if more messages can be sent, /// or `Ok(false)` if the high pressure mark has been reached and [`.wait_for_drain()`] should /// be called to block. - pub fn notify_sent(&self) -> Result { + pub(crate) fn notify_sent(&self) -> Result { let mut state = self.lock()?; state.unacknowledged = state @@ -359,7 +359,7 @@ impl StreamWriterSignal { } /// Wait for acknowledgements before sending more data. Also returns if the stream is dropped. - pub fn wait_for_drain(&self) -> Result<(), ShellError> { + pub(crate) fn wait_for_drain(&self) -> Result<(), ShellError> { let mut state = self.lock()?; while !state.dropped && state.unacknowledged >= state.high_pressure_mark { state = self @@ -374,7 +374,7 @@ impl StreamWriterSignal { /// Notify the writers that a message has been acknowledged, so they can continue to write /// if they were waiting. - pub fn notify_acknowledged(&self) -> Result<(), ShellError> { + pub(crate) fn notify_acknowledged(&self) -> Result<(), ShellError> { let mut state = self.lock()?; state.unacknowledged = state @@ -390,7 +390,7 @@ impl StreamWriterSignal { } /// A sink for a [`StreamMessage`] -pub(crate) trait WriteStreamMessage { +pub trait WriteStreamMessage { fn write_stream_message(&mut self, msg: StreamMessage) -> Result<(), ShellError>; fn flush(&mut self) -> Result<(), ShellError>; } @@ -413,7 +413,7 @@ impl StreamManagerState { } #[derive(Debug)] -pub(crate) struct StreamManager { +pub struct StreamManager { state: Arc>, } @@ -532,7 +532,7 @@ impl Drop for StreamManager { /// Streams can be registered for reading, returning a [`StreamReader`], or for writing, returning /// a [`StreamWriter`]. #[derive(Debug, Clone)] -pub(crate) struct StreamManagerHandle { +pub struct StreamManagerHandle { state: Weak>, } diff --git a/crates/nu-plugin/src/plugin/mod.rs b/crates/nu-plugin/src/plugin/mod.rs index deda34c931..9bb9dffb77 100644 --- a/crates/nu-plugin/src/plugin/mod.rs +++ b/crates/nu-plugin/src/plugin/mod.rs @@ -1,5 +1,6 @@ use nu_engine::documentation::get_flags_section; use nu_protocol::LabeledError; +use thiserror::Error; use std::cmp::Ordering; use std::collections::HashMap; @@ -13,7 +14,7 @@ use std::{env, thread}; use std::sync::mpsc::TrySendError; use std::sync::{mpsc, Arc, Mutex}; -use crate::plugin::interface::{EngineInterfaceManager, ReceivedPluginCall}; +use crate::plugin::interface::ReceivedPluginCall; use crate::protocol::{CallInfo, CustomValueOp, PluginCustomValue, PluginInput, PluginOutput}; use crate::EncodingType; @@ -29,6 +30,7 @@ use nu_protocol::{ }; use self::gc::PluginGc; +pub use self::interface::{PluginRead, PluginWrite}; mod command; mod context; @@ -40,14 +42,14 @@ mod source; pub use command::{PluginCommand, SimplePluginCommand}; pub use declaration::PluginDeclaration; -pub use interface::EngineInterface; -pub use persistent::PersistentPlugin; +pub use interface::{ + EngineInterface, EngineInterfaceManager, Interface, InterfaceManager, PluginInterface, + PluginInterfaceManager, +}; +pub use persistent::{GetPlugin, PersistentPlugin}; -pub(crate) use context::PluginExecutionCommandContext; -pub(crate) use interface::PluginInterface; -pub(crate) use source::PluginSource; - -use interface::{InterfaceManager, PluginInterfaceManager}; +pub use context::{PluginExecutionCommandContext, PluginExecutionContext}; +pub use source::PluginSource; pub(crate) const OUTPUT_BUFFER_SIZE: usize = 8192; @@ -440,19 +442,6 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) std::process::exit(1) } - // Build commands map, to make running a command easier - let mut commands: HashMap = HashMap::new(); - - for command in plugin.commands() { - if let Some(previous) = commands.insert(command.signature().sig.name.clone(), command) { - eprintln!( - "Plugin `{plugin_name}` warning: command `{}` shadowed by another command with the \ - same name. Check your command signatures", - previous.signature().sig.name - ); - } - } - // tell nushell encoding. // // 1 byte @@ -471,7 +460,114 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) .expect("Failed to tell nushell my encoding when flushing stdout"); } - let mut manager = EngineInterfaceManager::new((stdout, encoder.clone())); + let encoder_clone = encoder.clone(); + + let result = serve_plugin_io( + plugin, + &plugin_name, + move || (std::io::stdin().lock(), encoder_clone), + move || (std::io::stdout(), encoder), + ); + + match result { + Ok(()) => (), + // Write unreported errors to the console + Err(ServePluginError::UnreportedError(err)) => { + eprintln!("Plugin `{plugin_name}` error: {err}"); + std::process::exit(1); + } + Err(_) => std::process::exit(1), + } +} + +/// An error from [`serve_plugin_io()`] +#[derive(Debug, Error)] +pub enum ServePluginError { + /// An error occurred that could not be reported to the engine. + #[error("{0}")] + UnreportedError(#[source] ShellError), + /// An error occurred that could be reported to the engine. + #[error("{0}")] + ReportedError(#[source] ShellError), + /// A version mismatch occurred. + #[error("{0}")] + Incompatible(#[source] ShellError), + /// An I/O error occurred. + #[error("{0}")] + IOError(#[source] ShellError), + /// A thread spawning error occurred. + #[error("{0}")] + ThreadSpawnError(#[source] std::io::Error), +} + +impl From for ServePluginError { + fn from(error: ShellError) -> Self { + match error { + ShellError::IOError { .. } => ServePluginError::IOError(error), + ShellError::PluginFailedToLoad { .. } => ServePluginError::Incompatible(error), + _ => ServePluginError::UnreportedError(error), + } + } +} + +/// Convert result error to ReportedError if it can be reported to the engine. +trait TryToReport { + type T; + fn try_to_report(self, engine: &EngineInterface) -> Result; +} + +impl TryToReport for Result +where + E: Into, +{ + type T = T; + fn try_to_report(self, engine: &EngineInterface) -> Result { + self.map_err(|e| match e.into() { + ServePluginError::UnreportedError(err) => { + if engine.write_response(Err(err.clone())).is_ok() { + ServePluginError::ReportedError(err) + } else { + ServePluginError::UnreportedError(err) + } + } + other => other, + }) + } +} + +/// Serve a plugin on the given input & output. +/// +/// Unlike [`serve_plugin`], this doesn't assume total control over the process lifecycle / stdin / +/// stdout, and can be used for more advanced use cases. +/// +/// This is not a public API. +#[doc(hidden)] +pub fn serve_plugin_io( + plugin: &impl Plugin, + plugin_name: &str, + input: impl FnOnce() -> I + Send + 'static, + output: impl FnOnce() -> O + Send + 'static, +) -> Result<(), ServePluginError> +where + I: PluginRead + 'static, + O: PluginWrite + 'static, +{ + let (error_tx, error_rx) = mpsc::channel(); + + // Build commands map, to make running a command easier + let mut commands: HashMap = HashMap::new(); + + for command in plugin.commands() { + if let Some(previous) = commands.insert(command.signature().sig.name.clone(), command) { + eprintln!( + "Plugin `{plugin_name}` warning: command `{}` shadowed by another command with the \ + same name. Check your command signatures", + previous.signature().sig.name + ); + } + } + + let mut manager = EngineInterfaceManager::new(output()); let call_receiver = manager .take_plugin_call_receiver() // This expect should be totally safe, as we just created the manager @@ -480,54 +576,22 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) // We need to hold on to the interface to keep the manager alive. We can drop it at the end let interface = manager.get_interface(); - // Try an operation that could result in ShellError. Exit if an I/O error is encountered. - // Try to report the error to nushell otherwise, and failing that, panic. - macro_rules! try_or_report { - ($interface:expr, $expr:expr) => (match $expr { - Ok(val) => val, - // Just exit if there is an I/O error. Most likely this just means that nushell - // interrupted us. If not, the error probably happened on the other side too, so we - // don't need to also report it. - Err(ShellError::IOError { .. }) => std::process::exit(1), - // If there is another error, try to send it to nushell and then exit. - Err(err) => { - let _ = $interface.write_response(Err(err.clone())).unwrap_or_else(|_| { - // If we can't send it to nushell, panic with it so at least we get the output - panic!("Plugin `{plugin_name}`: {}", err) - }); - std::process::exit(1) - } - }) - } - // Send Hello message - try_or_report!(interface, interface.hello()); + interface.hello()?; - let plugin_name_clone = plugin_name.clone(); - - // Spawn the reader thread - std::thread::Builder::new() - .name("engine interface reader".into()) - .spawn(move || { - if let Err(err) = manager.consume_all((std::io::stdin().lock(), encoder)) { - // Do our best to report the read error. Most likely there is some kind of - // incompatibility between the plugin and nushell, so it makes more sense to try to - // report it on stderr than to send something. - // - // Don't report a `PluginFailedToLoad` error, as it's probably just from Hello - // version mismatch which the engine side would also report. - - if !matches!(err, ShellError::PluginFailedToLoad { .. }) { - eprintln!("Plugin `{plugin_name_clone}` read error: {err}"); + { + // Spawn the reader thread + let error_tx = error_tx.clone(); + std::thread::Builder::new() + .name("engine interface reader".into()) + .spawn(move || { + // Report the error on the channel if we get an error + if let Err(err) = manager.consume_all(input()) { + let _ = error_tx.send(ServePluginError::from(err)); } - std::process::exit(1); - } - }) - .unwrap_or_else(|err| { - // If we fail to spawn the reader thread, we should exit - eprintln!("Plugin `{plugin_name}` failed to launch: {err}"); - std::process::exit(1); - }); + }) + .map_err(ServePluginError::ThreadSpawnError)?; + } // Handle each Run plugin call on a thread thread::scope(|scope| { @@ -545,8 +609,11 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) }; let write_result = engine .write_response(result) - .and_then(|writer| writer.write()); - try_or_report!(engine, write_result); + .and_then(|writer| writer.write()) + .try_to_report(&engine); + if let Err(err) = write_result { + let _ = error_tx.send(err); + } }; // As an optimization: create one thread that can be reused for Run calls in sequence @@ -558,13 +625,14 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) run(engine, call); } }) - .unwrap_or_else(|err| { - // If we fail to spawn the runner thread, we should exit - eprintln!("Plugin `{plugin_name}` failed to launch: {err}"); - std::process::exit(1); - }); + .map_err(ServePluginError::ThreadSpawnError)?; for plugin_call in call_receiver { + // Check for pending errors + if let Ok(error) = error_rx.try_recv() { + return Err(error); + } + match plugin_call { // Sending the signature back to nushell to create the declaration definition ReceivedPluginCall::Signature { engine } => { @@ -572,7 +640,7 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) .values() .map(|command| command.signature()) .collect(); - try_or_report!(engine, engine.write_signature(sigs)); + engine.write_signature(sigs).try_to_report(&engine)?; } // Run the plugin on a background thread, handling any input or output streams ReceivedPluginCall::Run { engine, call } => { @@ -582,14 +650,10 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) // If the primary thread isn't ready, spawn a secondary thread to do it Err(TrySendError::Full((engine, call))) | Err(TrySendError::Disconnected((engine, call))) => { - let engine_clone = engine.clone(); - try_or_report!( - engine_clone, - thread::Builder::new() - .name("plugin runner (secondary)".into()) - .spawn_scoped(scope, move || run(engine, call)) - .map_err(ShellError::from) - ); + thread::Builder::new() + .name("plugin runner (secondary)".into()) + .spawn_scoped(scope, move || run(engine, call)) + .map_err(ServePluginError::ThreadSpawnError)?; } } } @@ -599,14 +663,23 @@ pub fn serve_plugin(plugin: &impl Plugin, encoder: impl PluginEncoder + 'static) custom_value, op, } => { - try_or_report!(engine, custom_value_op(plugin, &engine, custom_value, op)); + custom_value_op(plugin, &engine, custom_value, op).try_to_report(&engine)?; } } } - }); + + Ok::<_, ServePluginError>(()) + })?; // This will stop the manager drop(interface); + + // Receive any error left on the channel + if let Ok(err) = error_rx.try_recv() { + Err(err) + } else { + Ok(()) + } } fn custom_value_op( diff --git a/crates/nu-plugin/src/plugin/persistent.rs b/crates/nu-plugin/src/plugin/persistent.rs index bd686e2371..574126b256 100644 --- a/crates/nu-plugin/src/plugin/persistent.rs +++ b/crates/nu-plugin/src/plugin/persistent.rs @@ -3,7 +3,10 @@ use std::{ sync::{Arc, Mutex}, }; -use nu_protocol::{PluginGcConfig, PluginIdentity, RegisteredPlugin, ShellError}; +use nu_protocol::{ + engine::{EngineState, Stack}, + PluginGcConfig, PluginIdentity, RegisteredPlugin, ShellError, +}; use super::{create_command, gc::PluginGc, make_plugin_interface, PluginInterface, PluginSource}; @@ -81,6 +84,9 @@ impl PersistentPlugin { // // We hold the lock the whole time to prevent others from trying to spawn and ending // up with duplicate plugins + // + // TODO: We should probably store the envs somewhere, in case we have to launch without + // envs (e.g. from a custom value) let new_running = self.clone().spawn(envs()?, &mutable.gc_config)?; let interface = new_running.interface.clone(); mutable.running = Some(new_running); @@ -126,7 +132,7 @@ impl PersistentPlugin { let pid = child.id(); let interface = - make_plugin_interface(child, Arc::new(PluginSource::new(&self)), Some(gc.clone()))?; + make_plugin_interface(child, Arc::new(PluginSource::new(self)), Some(gc.clone()))?; Ok(RunningPlugin { pid, interface, gc }) } @@ -193,3 +199,38 @@ impl RegisteredPlugin for PersistentPlugin { self } } + +/// Anything that can produce a plugin interface. +/// +/// This is not a public interface. +#[doc(hidden)] +pub trait GetPlugin: RegisteredPlugin { + /// Retrieve or spawn a [`PluginInterface`]. The `context` may be used for determining + /// environment variables to launch the plugin with. + fn get_plugin( + self: Arc, + context: Option<(&EngineState, &mut Stack)>, + ) -> Result; +} + +impl GetPlugin for PersistentPlugin { + fn get_plugin( + self: Arc, + context: Option<(&EngineState, &mut Stack)>, + ) -> Result { + self.get(|| { + // Get envs from the context if provided. + let envs = context + .map(|(engine_state, stack)| { + // We need the current environment variables for `python` based plugins. Or + // we'll likely have a problem when a plugin is implemented in a virtual Python + // environment. + let stack = &mut stack.start_capture(); + nu_engine::env::env_to_strings(engine_state, stack) + }) + .transpose()?; + + Ok(envs.into_iter().flatten()) + }) + } +} diff --git a/crates/nu-plugin/src/plugin/source.rs b/crates/nu-plugin/src/plugin/source.rs index 22d38aabf4..59b23d2e07 100644 --- a/crates/nu-plugin/src/plugin/source.rs +++ b/crates/nu-plugin/src/plugin/source.rs @@ -1,26 +1,31 @@ use std::sync::{Arc, Weak}; -use nu_protocol::{PluginIdentity, RegisteredPlugin, ShellError, Span}; +use nu_protocol::{PluginIdentity, ShellError, Span}; -use super::PersistentPlugin; +use super::GetPlugin; +/// The source of a custom value or plugin command. Includes a weak reference to the persistent +/// plugin so it can be retrieved. +/// +/// This is not a public interface. #[derive(Debug, Clone)] -pub(crate) struct PluginSource { +#[doc(hidden)] +pub struct PluginSource { /// The identity of the plugin pub(crate) identity: Arc, /// A weak reference to the persistent plugin that might hold an interface to the plugin. /// /// This is weak to avoid cyclic references, but it does mean we might fail to upgrade if /// the engine state lost the [`PersistentPlugin`] at some point. - pub(crate) persistent: Weak, + pub(crate) persistent: Weak, } impl PluginSource { - /// Create from an `Arc` - pub(crate) fn new(plugin: &Arc) -> PluginSource { + /// Create from an implementation of `GetPlugin` + pub fn new(plugin: Arc) -> PluginSource { PluginSource { identity: plugin.identity().clone().into(), - persistent: Arc::downgrade(plugin), + persistent: Arc::downgrade(&plugin), } } @@ -31,16 +36,13 @@ impl PluginSource { pub(crate) fn new_fake(name: &str) -> PluginSource { PluginSource { identity: PluginIdentity::new_fake(name).into(), - persistent: Weak::new(), + persistent: Weak::::new(), } } /// Try to upgrade the persistent reference, and return an error referencing `span` as the /// object that referenced it otherwise - pub(crate) fn persistent( - &self, - span: Option, - ) -> Result, ShellError> { + pub(crate) fn persistent(&self, span: Option) -> Result, ShellError> { self.persistent .upgrade() .ok_or_else(|| ShellError::GenericError { diff --git a/crates/nu-plugin/src/protocol/mod.rs b/crates/nu-plugin/src/protocol/mod.rs index 165f76540e..d94285623b 100644 --- a/crates/nu-plugin/src/protocol/mod.rs +++ b/crates/nu-plugin/src/protocol/mod.rs @@ -192,7 +192,10 @@ impl CustomValueOp { } /// Any data sent to the plugin +/// +/// Note: exported for internal use, not public. #[derive(Serialize, Deserialize, Debug, Clone)] +#[doc(hidden)] pub enum PluginInput { /// This must be the first message. Indicates supported protocol Hello(ProtocolInfo), diff --git a/crates/nu-plugin/src/protocol/plugin_custom_value.rs b/crates/nu-plugin/src/protocol/plugin_custom_value.rs index a0d1a2f127..277ede6814 100644 --- a/crates/nu-plugin/src/protocol/plugin_custom_value.rs +++ b/crates/nu-plugin/src/protocol/plugin_custom_value.rs @@ -20,7 +20,10 @@ mod tests; /// appropriate [`PluginSource`](crate::plugin::PluginSource), ensuring that only /// [`PluginCustomData`] is contained within any values sent, and that the `source` of any /// values sent matches the plugin it is being sent to. +/// +/// This is not a public API. #[derive(Clone, Debug, Serialize, Deserialize)] +#[doc(hidden)] pub struct PluginCustomValue { #[serde(flatten)] shared: SerdeArc, @@ -197,12 +200,9 @@ impl PluginCustomValue { }) })?; - // Envs probably should be passed here, but it's likely that the plugin is already running - let empty_envs = std::iter::empty::<(&str, &str)>(); - source .persistent(span) - .and_then(|p| p.get(|| Ok(empty_envs))) + .and_then(|p| p.get_plugin(None)) .map_err(wrap_err) } @@ -237,7 +237,7 @@ impl PluginCustomValue { } /// Add a [`PluginSource`] to all [`PluginCustomValue`]s within a value, recursively. - pub(crate) fn add_source(value: &mut Value, source: &Arc) { + pub fn add_source(value: &mut Value, source: &Arc) { // This can't cause an error. let _: Result<(), Infallible> = value.recurse_mut(&mut |value| { let span = value.span(); @@ -318,7 +318,7 @@ impl PluginCustomValue { /// Convert all plugin-native custom values to [`PluginCustomValue`] within the given `value`, /// recursively. This should only be done on the plugin side. - pub(crate) fn serialize_custom_values_in(value: &mut Value) -> Result<(), ShellError> { + pub fn serialize_custom_values_in(value: &mut Value) -> Result<(), ShellError> { value.recurse_mut(&mut |value| { let span = value.span(); match value { @@ -344,7 +344,7 @@ impl PluginCustomValue { /// Convert all [`PluginCustomValue`]s to plugin-native custom values within the given `value`, /// recursively. This should only be done on the plugin side. - pub(crate) fn deserialize_custom_values_in(value: &mut Value) -> Result<(), ShellError> { + pub fn deserialize_custom_values_in(value: &mut Value) -> Result<(), ShellError> { value.recurse_mut(&mut |value| { let span = value.span(); match value { diff --git a/crates/nu-plugin/src/sequence.rs b/crates/nu-plugin/src/sequence.rs index cd065dbea4..c27e2a1e31 100644 --- a/crates/nu-plugin/src/sequence.rs +++ b/crates/nu-plugin/src/sequence.rs @@ -4,7 +4,7 @@ use nu_protocol::ShellError; /// Implements an atomically incrementing sequential series of numbers #[derive(Debug, Default)] -pub(crate) struct Sequence(AtomicUsize); +pub struct Sequence(AtomicUsize); impl Sequence { /// Return the next available id from a sequence, returning an error on overflow diff --git a/crates/nu-protocol/src/value/mod.rs b/crates/nu-protocol/src/value/mod.rs index 24b21b280a..7e00d51e43 100644 --- a/crates/nu-protocol/src/value/mod.rs +++ b/crates/nu-protocol/src/value/mod.rs @@ -754,9 +754,9 @@ impl Value { } } - /// Update the value with a new span - pub fn with_span(mut self, new_span: Span) -> Value { - match &mut self { + /// Set the value's span to a new span + pub fn set_span(&mut self, new_span: Span) { + match self { Value::Bool { internal_span, .. } | Value::Int { internal_span, .. } | Value::Float { internal_span, .. } @@ -777,7 +777,11 @@ impl Value { | Value::CustomValue { internal_span, .. } => *internal_span = new_span, Value::Error { .. } => (), } + } + /// Update the value with a new span + pub fn with_span(mut self, new_span: Span) -> Value { + self.set_span(new_span); self }