Skip to content

Commit 70bcfad

Browse files
committed
feat: --write-to-stdout to write result to stdout (UNIX only)
1 parent 5cc5800 commit 70bcfad

5 files changed

Lines changed: 118 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ duration-string = { version = "0.5.3", features = ["serde"] }
1717
derive_builder = "0.20.2"
1818
dirs = "6.0.0"
1919
futures = "0.3.32"
20+
libc = "0.2.177"
2021
serde = "1.0.228"
2122
termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] }
2223
tokio = { version = "1.49.0", features = ["full"] }

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,12 @@ cargo install jnv
111111

112112
```bash
113113
cat data.json | jnv
114+
114115
# or
115116
jnv data.json
117+
118+
# or write current result to stdout on exit (UNIX only)
119+
cat data.json | jnv --write-to-stdout | some-command
116120
```
117121

118122
## Keymap
@@ -180,6 +184,7 @@ Arguments:
180184
Options:
181185
-c, --config <CONFIG_FILE> Path to the configuration file.
182186
--default-filter <DEFAULT_FILTER> Default jq filter to apply to the input data
187+
--write-to-stdout Write the current JSON result to stdout when exiting
183188
-h, --help Print help (see more with '--help')
184189
-V, --version Print version
185190
```

src/main.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
#[cfg(unix)]
2+
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
13
use std::{
24
fs::File,
3-
io::{self, Read, Write},
5+
io::{self, IsTerminal, Read, Write},
46
path::PathBuf,
57
};
68

@@ -70,6 +72,12 @@ pub struct Args {
7072
"
7173
)]
7274
default_filter: Option<String>,
75+
76+
#[arg(
77+
long = "write-to-stdout",
78+
help = "Write the current JSON result to stdout when exiting"
79+
)]
80+
write_to_stdout: bool,
7381
}
7482

7583
/// Parses the input based on the provided arguments.
@@ -141,6 +149,80 @@ fn determine_config_file(config_path: Option<PathBuf>) -> anyhow::Result<PathBuf
141149
Ok(default_path)
142150
}
143151

152+
struct StdoutRedirect {
153+
#[cfg(unix)]
154+
saved_stdout: Option<OwnedFd>,
155+
}
156+
157+
impl StdoutRedirect {
158+
fn for_tui(write_to_stdout: bool) -> anyhow::Result<Self> {
159+
if !write_to_stdout || io::stdout().is_terminal() {
160+
return Ok(Self {
161+
#[cfg(unix)]
162+
saved_stdout: None,
163+
});
164+
}
165+
166+
#[cfg(unix)]
167+
{
168+
let tty = File::options()
169+
.read(true)
170+
.write(true)
171+
.open("/dev/tty")
172+
.map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?;
173+
174+
let saved_fd = unsafe { libc::dup(libc::STDOUT_FILENO) };
175+
if saved_fd < 0 {
176+
return Err(anyhow!(
177+
"Failed to duplicate stdout: {}",
178+
io::Error::last_os_error()
179+
));
180+
}
181+
182+
let redirected = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDOUT_FILENO) };
183+
if redirected < 0 {
184+
let _ = unsafe { libc::close(saved_fd) };
185+
return Err(anyhow!(
186+
"Failed to redirect stdout to /dev/tty: {}",
187+
io::Error::last_os_error()
188+
));
189+
}
190+
191+
Ok(Self {
192+
saved_stdout: Some(unsafe { OwnedFd::from_raw_fd(saved_fd) }),
193+
})
194+
}
195+
196+
#[cfg(not(unix))]
197+
{
198+
Err(anyhow!(
199+
"`--write-to-stdout` with piped stdout is not supported on this platform"
200+
))
201+
}
202+
}
203+
204+
fn restore(&mut self) -> anyhow::Result<()> {
205+
#[cfg(unix)]
206+
if let Some(saved_stdout) = self.saved_stdout.take() {
207+
let restored = unsafe { libc::dup2(saved_stdout.as_raw_fd(), libc::STDOUT_FILENO) };
208+
if restored < 0 {
209+
return Err(anyhow!(
210+
"Failed to restore stdout: {}",
211+
io::Error::last_os_error()
212+
));
213+
}
214+
}
215+
216+
Ok(())
217+
}
218+
}
219+
220+
impl Drop for StdoutRedirect {
221+
fn drop(&mut self) {
222+
let _ = self.restore();
223+
}
224+
}
225+
144226
#[tokio::main]
145227
async fn main() -> anyhow::Result<()> {
146228
let args = Args::parse();
@@ -194,17 +276,31 @@ async fn main() -> anyhow::Result<()> {
194276
config.keybinds.on_editor.clone(),
195277
);
196278

279+
let mut stdout_redirect = StdoutRedirect::for_tui(args.write_to_stdout)?;
280+
197281
// TODO: put all logics here.
198-
prompt::run(
282+
let maybe_output = prompt::run(
199283
item,
200284
config.reactivity_control,
201285
provider,
202286
editor,
203287
loading_suggestions_task,
204288
config.no_hint,
205289
config.keybinds,
290+
args.write_to_stdout,
206291
)
207-
.await?;
292+
.await;
293+
294+
stdout_redirect.restore()?;
295+
let maybe_output = maybe_output?;
296+
297+
if let Some(output) = maybe_output {
298+
let mut stdout = io::stdout();
299+
stdout.write_all(output.as_bytes())?;
300+
if !output.ends_with('\n') {
301+
stdout.write_all(b"\n")?;
302+
}
303+
}
208304

209305
Ok(())
210306
}

src/prompt.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ pub async fn run<T: ViewProvider + SearchProvider>(
123123
loading_suggestions_task: JoinHandle<anyhow::Result<()>>,
124124
no_hint: bool,
125125
keybinds: Keybinds,
126-
) -> anyhow::Result<()> {
126+
write_to_stdout: bool,
127+
) -> anyhow::Result<Option<String>> {
127128
enable_raw_mode()?;
128129
execute!(io::stdout(), cursor::Hide)?;
129130

@@ -385,11 +386,11 @@ pub async fn run<T: ViewProvider + SearchProvider>(
385386
})
386387
};
387388

389+
let shared_visualizer = Arc::new(Mutex::new(initializing.await?));
388390
let processor_task: JoinHandle<anyhow::Result<()>> = {
389391
let shared_renderer = shared_renderer.clone();
390392
let shared_editor = shared_editor.clone();
391-
let visualizer = initializing.await?;
392-
let shared_visualizer = Arc::new(Mutex::new(visualizer));
393+
let shared_visualizer = shared_visualizer.clone();
393394
tokio::spawn(async move {
394395
loop {
395396
tokio::select! {
@@ -460,6 +461,13 @@ pub async fn run<T: ViewProvider + SearchProvider>(
460461

461462
main_task.await??;
462463

464+
let output = if write_to_stdout {
465+
let visualizer = shared_visualizer.lock().await;
466+
Some(visualizer.content_to_copy().await)
467+
} else {
468+
None
469+
};
470+
463471
loading_suggestions_task.abort();
464472
spinning.abort();
465473
query_debouncer.abort();
@@ -470,5 +478,5 @@ pub async fn run<T: ViewProvider + SearchProvider>(
470478
execute!(io::stdout(), cursor::Show, DisableMouseCapture)?;
471479
disable_raw_mode()?;
472480

473-
Ok(())
481+
Ok(output)
474482
}

0 commit comments

Comments
 (0)