From 83fd0f8257afed92ef0e33d2b194c6011dd17ac3 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 4 Oct 2026 16:31:31 +0900 Subject: [PATCH 1/2] Accept launch input from stdin --- Cargo.lock | 2 + Cargo.toml | 2 + src/cli.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 2 +- 4 files changed, 219 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c44f47a..d72f7c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,6 +422,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "filedescriptor", "mio", "parking_lot", "rustix 1.1.4", @@ -1862,6 +1863,7 @@ dependencies = [ "clap", "core-foundation", "core-text", + "crossterm 0.29.0", "flate2", "fontdb", "garde", diff --git a/Cargo.toml b/Cargo.toml index 4e6c6f1..3062da0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ unicode-segmentation = "=1.13.3" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10.1" core-text = "22.0.0" +# Use the polling backend so keyboard input works after reading piped stdin. +crossterm = { version = "0.29.0", default-features = false, features = ["use-dev-tty"] } [target.'cfg(target_os = "linux")'.dependencies] yeslogic-fontconfig-sys = { version = "6.0.1", features = ["dlopen"] } diff --git a/src/cli.rs b/src/cli.rs index 1fb5671..96c97a0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,5 @@ +use std::io::{self, Read}; + use clap::{Parser, ValueEnum}; use crate::{graphics::GraphicsMode, unicode::CodePoint}; @@ -13,6 +15,12 @@ pub enum LaunchTarget { Sequence(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +enum InputSource { + Target(LaunchTarget), + Stdin { literal: bool }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchOptions { target: Option, @@ -47,17 +55,17 @@ impl LaunchOptions { #[derive(Debug, Parser)] #[command(version)] struct Cli { - /// Text or code point to inspect + /// Text or code point to inspect, or - to read stdin #[arg( value_name = "INPUT", - value_parser = parse_input, + value_parser = parse_input_source, conflicts_with_all = ["text", "demo"] )] - input: Option, + input: Option, - /// Treat the input as literal text, without code point notation parsing - #[arg(short, long, value_name = "TEXT", value_parser = parse_text, conflicts_with = "demo")] - text: Option, + /// Treat the input as literal text, without code point notation parsing; - reads stdin + #[arg(short, long, value_name = "TEXT", value_parser = parse_text_source, conflicts_with = "demo")] + text: Option, /// Control glyph preview graphics #[arg(short, long, value_enum, default_value_t, value_name = "MODE")] @@ -106,18 +114,56 @@ pub enum Demo { GlyphDisabled, } -pub fn parse() -> LaunchOptions { - Cli::parse().into() +pub fn parse() -> io::Result { + Cli::parse().resolve(io::stdin().lock()) } -impl From for LaunchOptions { - fn from(cli: Cli) -> Self { - Self { - target: cli.input.or(cli.text), - demo: cli.demo, - graphics: cli.graphics, - print_default_config: cli.print_default_config, - } +impl Cli { + fn resolve(self, stdin: impl Read) -> io::Result { + let target = match self.input.or(self.text) { + None => None, + Some(InputSource::Target(target)) => Some(target), + Some(InputSource::Stdin { literal }) => { + let input = io::read_to_string(stdin).map_err(|error| { + io::Error::new(error.kind(), format!("failed to read stdin: {error}")) + })?; + let target = if literal { + parse_text(&input) + } else { + parse_input(&input) + } + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid input from stdin: {error}"), + ) + })?; + Some(target) + } + }; + + Ok(LaunchOptions { + target, + demo: self.demo, + graphics: self.graphics, + print_default_config: self.print_default_config, + }) + } +} + +fn parse_input_source(input: &str) -> Result { + if input == "-" { + Ok(InputSource::Stdin { literal: false }) + } else { + parse_input(input).map(InputSource::Target) + } +} + +fn parse_text_source(input: &str) -> Result { + if input == "-" { + Ok(InputSource::Stdin { literal: true }) + } else { + parse_text(input).map(InputSource::Target) } } @@ -175,8 +221,17 @@ mod tests { use super::*; + struct UnusedStdin; + + impl Read for UnusedStdin { + fn read(&mut self, _: &mut [u8]) -> io::Result { + panic!("stdin must not be read unless requested"); + } + } + fn try_parse(arguments: &[&str]) -> Result { - Cli::try_parse_from(arguments).map(Into::into) + Cli::try_parse_from(arguments) + .map(|cli| cli.resolve(UnusedStdin).expect("stdin is not requested")) } #[test] @@ -283,6 +338,146 @@ mod tests { ); } + #[rstest] + #[case("A", 0x0041)] + #[case("1", 0x0031)] + #[case("→", 0x2192)] + #[case("あ", 0x3042)] + #[case("U+2192", 0x2192)] + #[case("0x1F600", 0x1f600)] + #[case("41", 0x0041)] + #[case("U+D800", 0xd800)] + #[case("-", 0x002d)] + #[case(" ", 0x0020)] + #[case("\n", 0x000a)] + #[case("\r", 0x000d)] + fn reads_code_points_from_stdin(#[case] input: &str, #[case] expected: u32) { + let options = Cli::try_parse_from(["sauva", "-"]) + .unwrap() + .resolve(input.as_bytes()) + .unwrap(); + + assert_eq!( + options.target(), + Some(&LaunchTarget::CodePoint(CodePoint::new(expected).unwrap())) + ); + } + + #[rstest] + #[case("41")] + #[case("U+2192")] + #[case("U+GG")] + #[case("0000041")] + fn literal_stdin_bypasses_code_point_notation_parsing(#[case] input: &str) { + for text_option in ["--text", "-t"] { + let options = Cli::try_parse_from(["sauva", text_option, "-"]) + .unwrap() + .resolve(input.as_bytes()) + .unwrap(); + + assert_eq!( + options.target(), + Some(&LaunchTarget::Sequence(input.to_owned())) + ); + } + } + + #[rstest] + #[case(" \tA\r\n ")] + #[case("A\nB\n")] + #[case("A\n\n")] + #[case("q\u{0301}\u{0323}")] + #[case("❤️\n")] + fn preserves_stdin_text_verbatim(#[case] source: &str) { + for arguments in [ + &["sauva", "-"][..], + &["sauva", "--text", "-"], + &["sauva", "-t", "-"], + ] { + let options = Cli::try_parse_from(arguments) + .unwrap() + .resolve(source.as_bytes()) + .unwrap(); + + assert_eq!( + options.target(), + Some(&LaunchTarget::Sequence(source.to_owned())) + ); + } + } + + #[test] + fn reads_stdin_text_longer_than_a_code_point_notation() { + let source = "hello".repeat(100); + let options = Cli::try_parse_from(["sauva", "-"]) + .unwrap() + .resolve(source.as_bytes()) + .unwrap(); + + assert_eq!(options.target(), Some(&LaunchTarget::Sequence(source))); + } + + #[rstest] + #[case("")] + #[case("U+GG")] + #[case("U+2192\n")] + #[case("U+110000")] + fn rejects_invalid_stdin_input(#[case] input: &str) { + let error = Cli::try_parse_from(["sauva", "-"]) + .unwrap() + .resolve(input.as_bytes()) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("invalid input from stdin")); + } + + #[test] + fn rejects_empty_literal_stdin() { + let error = Cli::try_parse_from(["sauva", "--text", "-"]) + .unwrap() + .resolve(io::empty()) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("text must not be empty")); + } + + #[rstest] + #[case(&["sauva", "-"])] + #[case(&["sauva", "--text", "-"])] + fn rejects_non_utf8_stdin(#[case] arguments: &[&str]) { + let error = Cli::try_parse_from(arguments) + .unwrap() + .resolve([0xff].as_slice()) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("failed to read stdin")); + } + + #[rstest] + #[case(&["sauva", "-", "--demo", "glyph-basic"])] + #[case(&["sauva", "--text", "-", "--demo", "glyph-basic"])] + #[case(&["sauva", "-", "--text", "-"])] + #[case(&["sauva", "-", "--print-default-config"])] + #[case(&["sauva", "--text", "-", "--print-default-config"])] + fn stdin_requests_respect_argument_conflicts(#[case] arguments: &[&str]) { + let error = Cli::try_parse_from(arguments).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn hyphen_can_be_inspected_with_code_point_notation() { + let options = try_parse(&["sauva", "U+002D"]).unwrap(); + + assert_eq!( + options.target(), + Some(&LaunchTarget::CodePoint(CodePoint::from('-'))) + ); + } + #[rstest] #[case("U+")] #[case("0x")] @@ -347,7 +542,9 @@ mod tests { assert!(help.contains("Usage: sauva [OPTIONS] [INPUT]")); assert!(help.contains("Text or code point to inspect")); + assert!(help.contains("or - to read stdin")); assert!(help.contains("-t, --text ")); + assert!(help.contains("- reads stdin")); assert!(help.contains("--graphics ")); assert!(help.contains("possible values: auto, force, iterm2, off")); assert!(help.contains("--print-default-config")); diff --git a/src/main.rs b/src/main.rs index 444cff6..dafc429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ fn main() -> ExitCode { } fn run() -> Result<(), Box> { - let options = cli::parse(); + let options = cli::parse()?; if options.print_default_config() { print!("{}", config::default_toml()?); return Ok(()); From d2b8bd0460961f6d59bf36151fef97af99953dd6 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 4 Oct 2026 16:35:44 +0900 Subject: [PATCH 2/2] Test and document piped launch input --- README.md | 20 ++++++++++++++++++-- tests/cli_output.rs | 38 +++++++++++++++++++++++++++++++++++++- tests/terminal_e2e.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb88ce1..8d02c5d 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ Sauva - Terminal Unicode Explorer 🪄 Usage: sauva [OPTIONS] [INPUT] Arguments: - [INPUT] Text or code point to inspect + [INPUT] Text or code point to inspect, or - to read stdin Options: - -t, --text Treat the input as literal text, without code point notation parsing + -t, --text Treat the input as literal text, without code point notation parsing; - reads stdin -g, --graphics Control glyph preview graphics [default: auto] [possible values: auto, force, iterm2, off] --print-default-config Print the complete default configuration to standard output -h, --help Print help @@ -87,6 +87,22 @@ sauva --text U+2192 Empty text is rejected. +#### Reading from stdin + +Pass `-` to read the input from stdin. It accepts the same text and code point formats as `INPUT`. Use `--text -` (or `-t -`) to treat stdin as literal text without code point notation parsing. + +``` +printf 'U+2192' | sauva - +printf 'Á👩‍💻' | sauva - +printf '41' | sauva --text - +printf 'A\nB\n' | sauva - +sauva --text - < input.txt +``` + +Sauva reads UTF-8 input until EOF before opening the TUI. All whitespace, including trailing LF and CRLF, is preserved. For code point notation, use `printf` without a trailing newline: `echo U+2192 | sauva -` is rejected because the newline is part of the notation. Use `--text -` to inspect that input as literal text, including its newline. Empty input and invalid UTF-8 are rejected. + +Stdin is read only when `-` is explicitly specified. Keyboard input continues to come from the terminal, so an interactive terminal is still required. Since `-` selects stdin, use `sauva U+002D` to inspect the hyphen character itself. + #### `-g, --graphics ` - `auto` enables the Kitty graphics protocol for detected kitty and Ghostty terminals. diff --git a/tests/cli_output.rs b/tests/cli_output.rs index 5e5565e..b383e83 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -1,4 +1,9 @@ -use std::process::Command; +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use rstest::rstest; #[test] fn prints_default_config_without_loading_local_config() { @@ -17,3 +22,34 @@ fn prints_default_config_without_loading_local_config() { assert!(stdout.contains("[ui]\n")); assert!(stdout.contains("[keybindings.inspector]\n")); } + +#[rstest] +#[case(&["-"], b"", "invalid input from stdin")] +#[case(&["--text", "-"], b"", "text must not be empty")] +#[case(&["-"], b"U+2192\n", "invalid input from stdin")] +#[case(&["-"], &[0xff], "failed to read stdin")] +#[case(&["--text", "-"], &[0xff], "failed to read stdin")] +fn rejects_invalid_stdin_before_starting_the_terminal( + #[case] arguments: &[&str], + #[case] input: &[u8], + #[case] expected_error: &str, +) { + let mut child = Command::new(env!("CARGO_BIN_EXE_sauva")) + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(input).unwrap(); + drop(stdin); + let output = child.wait_with_output().unwrap(); + + assert!(!output.status.success(), "status: {}", output.status); + assert!(output.stdout.is_empty()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.starts_with("sauva: "), "{stderr}"); + assert!(stderr.contains(expected_error), "{stderr}"); +} diff --git a/tests/terminal_e2e.rs b/tests/terminal_e2e.rs index 562bba6..a19ee85 100644 --- a/tests/terminal_e2e.rs +++ b/tests/terminal_e2e.rs @@ -1,5 +1,6 @@ use std::fs; +use rstest::rstest; use tempfile::tempdir; use termlens::{Color, Key, Terminal}; @@ -97,6 +98,41 @@ fn starts_at_the_code_point_from_the_command_line() -> termlens::Result<()> { Ok(()) } +#[rstest] +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[case( + r#"printf 'U+2192' | "$SAUVA_BIN" - --graphics off"#, + &["Identity", "U+2192", "RIGHTWARDS ARROW"] +)] +#[case( + r#"printf '41' | "$SAUVA_BIN" --text - --graphics off"#, + &["2 code points", "U+0034", "U+0031"] +)] +#[case( + r#"printf 'A\nB\n' | "$SAUVA_BIN" - --graphics off"#, + &["4 code points", "U+0041", "U+000A", "U+0042"] +)] +fn starts_from_piped_stdin_and_still_accepts_keys( + #[case] command: &str, + #[case] expected: &[&str], +) -> termlens::Result<()> { + let mut terminal = Terminal::builder() + .size(100, 30) + .env_clear() + .env("SAUVA_BIN", env!("CARGO_BIN_EXE_sauva")) + .args(["-c", command]) + .spawn("/bin/sh")?; + terminal.snapshot_after(|screen| expected.iter().all(|text| screen.contains(text)))?; + + terminal.send(Key::Char('q'))?; + let status = terminal.wait_exit()?; + + assert!(status.success(), "exit status: {status}"); + assert!(!terminal.screen().alternate_screen()); + assert!(terminal.screen().cursor_visible()); + Ok(()) +} + #[test] fn inspects_a_text_sequence_and_preserves_its_position_across_search() -> termlens::Result<()> { let mut terminal = termlens::bin!("sauva", size(100, 30), args(["A→B", "--graphics", "off"]))?;