From d5677625a79b613bf955f23e70f251c79b3bfca2 Mon Sep 17 00:00:00 2001 From: Eric Hodel Date: Tue, 21 Nov 2023 18:48:39 -0800 Subject: [PATCH] Add is-terminal to determine if stdin/out/err are a terminal (#10970) # Description I'm not sure if "is-terminal" is the best name for this command as there is also "term size". Uses [`is_terminal()`](https://doc.rust-lang.org/stable/std/io/trait.IsTerminal.html#tymethod.is_terminal) which is cross-platform. Possible alternative names: * `term is-tty --stdout` * `term is-tty stdout` * `term is-terminal stdout` If multiple streams are provided an error is returned. The error span covers all arguments as the incompatible one is not known. This may be new? Fixes #10517 # User-Facing Changes * Add `is-terminal` to check if stdin, stdout, or stderr are a terminal (TTY) # Tests + Formatting The nu tests always redirect stdin, stdout, and stderr so a positive test case is not possible without extra work - :green_circle: `toolkit fmt` - :green_circle: `toolkit clippy` - :green_circle: `toolkit test` - :green_circle: `toolkit test stdlib` # After Submitting The new command will be added automatically --------- Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com> --- crates/nu-command/src/default_context.rs | 1 + crates/nu-command/src/platform/is_terminal.rs | 78 +++++++++++++++++++ crates/nu-command/src/platform/mod.rs | 2 + crates/nu-command/tests/commands/mod.rs | 1 + crates/nu-command/tests/commands/terminal.rs | 24 ++++++ 5 files changed, 106 insertions(+) create mode 100644 crates/nu-command/src/platform/is_terminal.rs create mode 100644 crates/nu-command/tests/commands/terminal.rs diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 6a88738a9f..6ae3ca2866 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -225,6 +225,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState { Input, InputList, InputListen, + IsTerminal, Kill, Sleep, TermSize, diff --git a/crates/nu-command/src/platform/is_terminal.rs b/crates/nu-command/src/platform/is_terminal.rs new file mode 100644 index 0000000000..e4be227bad --- /dev/null +++ b/crates/nu-command/src/platform/is_terminal.rs @@ -0,0 +1,78 @@ +use nu_protocol::{ + ast::Call, + engine::{Command, EngineState, Stack}, + span, Category, Example, PipelineData, ShellError, Signature, Type, Value, +}; +use std::io::IsTerminal as _; + +#[derive(Clone)] +pub struct IsTerminal; + +impl Command for IsTerminal { + fn name(&self) -> &str { + "is-terminal" + } + + fn signature(&self) -> Signature { + Signature::build("is-terminal") + .input_output_type(Type::Nothing, Type::Bool) + .switch("stdin", "Check if stdin is a terminal", Some('i')) + .switch("stdout", "Check if stdout is a terminal", Some('o')) + .switch("stderr", "Check if stderr is a terminal", Some('e')) + .category(Category::Platform) + } + + fn usage(&self) -> &str { + "Check if stdin, stdout, or stderr is a terminal" + } + + fn examples(&self) -> Vec { + vec![Example { + description: r#"Return "terminal attached" if standard input is attached to a terminal, and "no terminal" if not."#, + example: r#"if (is-terminal --stdin) { "terminal attached" } else { "no terminal" }"#, + result: Some(Value::test_string("terminal attached")), + }] + } + + fn search_terms(&self) -> Vec<&str> { + vec!["input", "output", "stdin", "stdout", "stderr", "tty"] + } + + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + let stdin = call.has_flag("stdin"); + let stdout = call.has_flag("stdout"); + let stderr = call.has_flag("stderr"); + + let is_terminal = match (stdin, stdout, stderr) { + (true, false, false) => std::io::stdin().is_terminal(), + (false, true, false) => std::io::stdout().is_terminal(), + (false, false, true) => std::io::stderr().is_terminal(), + (false, false, false) => { + return Err(ShellError::MissingParameter { + param_name: "one of --stdin, --stdout, --stderr".into(), + span: call.head, + }); + } + _ => { + let spans: Vec<_> = call.arguments.iter().map(|arg| arg.span()).collect(); + let span = span(&spans); + + return Err(ShellError::IncompatibleParametersSingle { + msg: "Only one stream may be checked".into(), + span, + }); + } + }; + + Ok(PipelineData::Value( + Value::bool(is_terminal, call.head), + None, + )) + } +} diff --git a/crates/nu-command/src/platform/mod.rs b/crates/nu-command/src/platform/mod.rs index d6a6b3a5ad..63d3026bfa 100644 --- a/crates/nu-command/src/platform/mod.rs +++ b/crates/nu-command/src/platform/mod.rs @@ -3,6 +3,7 @@ mod clear; mod dir_info; mod du; mod input; +mod is_terminal; mod kill; mod sleep; mod term_size; @@ -15,6 +16,7 @@ pub use du::Du; pub use input::Input; pub use input::InputList; pub use input::InputListen; +pub use is_terminal::IsTerminal; pub use kill::Kill; pub use sleep::Sleep; pub use term_size::TermSize; diff --git a/crates/nu-command/tests/commands/mod.rs b/crates/nu-command/tests/commands/mod.rs index 5f698ad087..9373801ef9 100644 --- a/crates/nu-command/tests/commands/mod.rs +++ b/crates/nu-command/tests/commands/mod.rs @@ -100,6 +100,7 @@ mod split_row; mod str_; mod table; mod take; +mod terminal; mod to_text; mod touch; mod transpose; diff --git a/crates/nu-command/tests/commands/terminal.rs b/crates/nu-command/tests/commands/terminal.rs new file mode 100644 index 0000000000..04275e6c54 --- /dev/null +++ b/crates/nu-command/tests/commands/terminal.rs @@ -0,0 +1,24 @@ +use nu_test_support::{nu, pipeline}; + +// Inside nu! stdout is piped so it won't be a terminal +#[test] +fn is_terminal_stdout_piped() { + let actual = nu!(pipeline( + r#" + is-terminal --stdout + "# + )); + + assert_eq!(actual.out, "false"); +} + +#[test] +fn is_terminal_two_streams() { + let actual = nu!(pipeline( + r#" + is-terminal --stdin --stderr + "# + )); + + assert!(actual.err.contains("Only one stream may be checked")); +}