feat: add --print-current, --output-format (closes #981)

This commit is contained in:
Loric ANDRE 2026-02-18 22:14:49 +01:00
parent 5c11314bb6
commit d7117fbbc2
10 changed files with 228 additions and 56 deletions

4
Cargo.lock generated
View file

@ -222,9 +222,9 @@ dependencies = [
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"

View file

@ -132,8 +132,12 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
print_cmd: opts.print_cmd, print_cmd: opts.print_cmd,
print_score: opts.print_score, print_score: opts.print_score,
print_header: opts.print_header, print_header: opts.print_header,
print_current: opts.print_current,
output_ending: String::from(if opts.print0 { "\0" } else { "\n" }), output_ending: String::from(if opts.print0 { "\0" } else { "\n" }),
strip_ansi: opts.ansi && !opts.no_strip_ansi, strip_ansi: opts.ansi && !opts.no_strip_ansi,
output_format: opts.output_format.clone(),
delimiter: opts.delimiter.clone(),
replstr: opts.replstr.clone(),
}; };
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@ -159,38 +163,62 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
return Ok(130); return Ok(130);
} }
// output query // Output
if bin_options.print_query { if let Some(ref output_format) = bin_options.output_format {
print!("{}{}", result.query, bin_options.output_ending); print!(
} "{}{}",
skim::printf(
if bin_options.print_cmd { output_format,
print!("{}{}", result.cmd, bin_options.output_ending); &bin_options.delimiter,
} &bin_options.replstr,
result.selected_items.iter().map(|x| x.item.clone()),
if bin_options.print_header { result.current,
print!("{}{}", result.header, bin_options.output_ending); &result.query,
} &result.cmd,
true
if let Event::Action(Action::Accept(Some(accept_key))) = result.final_event { ),
print!("{}{}", accept_key, bin_options.output_ending); bin_options.output_ending
} );
} else {
for item in &result.selected_items { if bin_options.print_query {
if bin_options.strip_ansi { print!("{}{}", result.query, bin_options.output_ending);
print!(
"{}{}",
skim::helper::item::strip_ansi(&item.output()).0,
bin_options.output_ending
);
} else {
print!("{}{}", item.output(), bin_options.output_ending);
} }
if bin_options.print_score {
print!("{}{}", item.rank[0], bin_options.output_ending); if bin_options.print_cmd {
print!("{}{}", result.cmd, bin_options.output_ending);
}
if bin_options.print_header {
print!("{}{}", result.header, bin_options.output_ending);
}
if bin_options.print_current {
if let Some(ref current) = result.current {
print!("{}{}", current.output(), bin_options.output_ending);
} else {
print!("{}", bin_options.output_ending);
}
}
if let Event::Action(Action::Accept(Some(accept_key))) = result.final_event {
print!("{}{}", accept_key, bin_options.output_ending);
}
for item in &result.selected_items {
if bin_options.strip_ansi {
print!(
"{}{}",
skim::helper::item::strip_ansi(&item.output()).0,
bin_options.output_ending
);
} else {
print!("{}{}", item.output(), bin_options.output_ending);
}
if bin_options.print_score {
print!("{}{}", item.rank[0], bin_options.output_ending);
}
} }
} }
std::io::stdout().flush()?; std::io::stdout().flush()?;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@ -243,5 +271,9 @@ pub struct BinOptions {
print_cmd: bool, print_cmd: bool,
print_score: bool, print_score: bool,
print_header: bool, print_header: bool,
print_current: bool,
strip_ansi: bool, strip_ansi: bool,
output_format: Option<String>,
delimiter: regex::Regex,
replstr: String,
} }

View file

@ -44,6 +44,7 @@ pub use crate::output::SkimOutput;
pub use crate::skim::*; pub use crate::skim::*;
pub use crate::skim_item::SkimItem; pub use crate::skim_item::SkimItem;
use crate::tui::Size; use crate::tui::Size;
pub use util::printf;
pub mod binds; pub mod binds;
mod engine; mod engine;

View file

@ -599,6 +599,16 @@ pub struct SkimOptions {
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
pub print_header: bool, pub print_header: bool,
/// Print the current (highlighted) item as the first line (after print-header)
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
pub print_current: bool,
/// Set the output format
/// If set, overrides all `print_` options
/// Will be expanded the same way as preview or commands
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
pub output_format: Option<String>,
/// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on /// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))] #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))]
pub no_strip_ansi: bool, pub no_strip_ansi: bool,
@ -906,6 +916,7 @@ impl Default for SkimOptions {
listen: None, listen: None,
remote: None, remote: None,
print_header: false, print_header: false,
print_current: false,
disabled: false, disabled: false,
tac: Default::default(), tac: Default::default(),
min_query_length: Default::default(), min_query_length: Default::default(),
@ -964,6 +975,7 @@ impl Default for SkimOptions {
print_query: Default::default(), print_query: Default::default(),
print_cmd: Default::default(), print_cmd: Default::default(),
print_score: Default::default(), print_score: Default::default(),
output_format: Default::default(),
select_1: Default::default(), select_1: Default::default(),
exit_0: Default::default(), exit_0: Default::default(),
sync: Default::default(), sync: Default::default(),

View file

@ -1,3 +1,4 @@
use crate::SkimItem;
use crate::item::MatchedItem; use crate::item::MatchedItem;
use crate::tui::Event; use crate::tui::Event;
use std::sync::Arc; use std::sync::Arc;
@ -26,6 +27,9 @@ pub struct SkimOutput {
/// The selected items. /// The selected items.
pub selected_items: Vec<Arc<MatchedItem>>, pub selected_items: Vec<Arc<MatchedItem>>,
/// The current item
pub current: Option<Arc<dyn SkimItem>>,
/// The header /// The header
pub header: String, pub header: String,
} }

View file

@ -394,6 +394,7 @@ where
query: self.app.input.to_string(), query: self.app.input.to_string(),
is_abort, is_abort,
selected_items: self.app.results(), selected_items: self.app.results(),
current: self.app.item_list.selected(),
header: self.app.header.header.clone(), header: self.app.header.header.clone(),
} }
} }

View file

@ -1,5 +1,8 @@
use ratatui::text::Line; use ratatui::text::Line;
use std::borrow::Cow; use std::{
borrow::Cow,
fmt::{Debug, Display},
};
use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext}; use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext};
@ -93,3 +96,18 @@ impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
Cow::Borrowed(self.as_ref()) Cow::Borrowed(self.as_ref())
} }
} }
impl Display for dyn SkimItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text())
}
}
impl Debug for dyn SkimItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"SkimItem {{ text: {}, index: {} }}",
self.text(),
self.get_index()
))
}
}

View file

@ -170,6 +170,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
// Build args to send to downstream sk invocation // Build args to send to downstream sk invocation
let mut tmux_shell_cmd = String::new(); let mut tmux_shell_cmd = String::new();
let mut prev_is_tmux_flag = false; let mut prev_is_tmux_flag = false;
let mut prev_is_output_format_flag = false;
// We keep argv[0] to use in the popup's command // We keep argv[0] to use in the popup's command
for arg in std::env::args() { for arg in std::env::args() {
debug!("Got arg {arg}"); debug!("Got arg {arg}");
@ -178,6 +179,9 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
if !arg.starts_with("-") { if !arg.starts_with("-") {
continue; continue;
} }
} else if prev_is_output_format_flag {
prev_is_output_format_flag = false;
continue;
} }
if arg == "--tmux" { if arg == "--tmux" {
debug!("Found tmux arg, skipping this and the next"); debug!("Found tmux arg, skipping this and the next");
@ -186,9 +190,30 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
} else if arg.starts_with("--tmux") { } else if arg.starts_with("--tmux") {
debug!("Found equal tmux arg, skipping"); debug!("Found equal tmux arg, skipping");
continue; continue;
} else if arg == "--output-format" {
debug!("Found output format arg, skipping this and the next");
prev_is_output_format_flag = true;
continue;
} else if arg.starts_with("--output-format") {
debug!("Found equal output format arg, skipping");
continue;
} }
push_quoted_arg(&mut tmux_shell_cmd, &arg); push_quoted_arg(&mut tmux_shell_cmd, &arg);
} }
// Always add all --print-xxx flags to the child sk command so that the output
// is fully structured and can be parsed unconditionally below, regardless of
// which flags the user originally passed.
for flag in &[
"--print-query",
"--print-cmd",
"--print-header",
"--print-current",
"--print-score",
] {
tmux_shell_cmd.push_str(&format!(" {flag}"));
}
tmux_shell_cmd = tmux_shell_cmd.replace("--output-format", "");
if has_piped_input { if has_piped_input {
tmux_shell_cmd.push_str(&format!(" <{}", tmp_stdin.display())); tmux_shell_cmd.push_str(&format!(" <{}", tmp_stdin.display()));
} }
@ -238,42 +263,48 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
let mut stdout = stdout_bytes.split(output_ending); let mut stdout = stdout_bytes.split(output_ending);
let _ = std::fs::remove_dir_all(temp_dir); let _ = std::fs::remove_dir_all(temp_dir);
let query_str = if opts.print_query && status.success() { // The child sk process always runs with --print-query, --print-cmd, --print-header,
stdout.next().expect("Not enough lines to unpack in downstream result") // and --print-score, so we always read those lines unconditionally.
let query_str = if status.success() {
stdout.next().unwrap_or_default()
} else { } else {
"" ""
}; };
let command_str = if opts.print_cmd && status.success() { let command_str = if status.success() {
stdout.next().expect("Not enough lines to unpack in downstream result") stdout.next().unwrap_or_default()
} else { } else {
"" ""
}; };
let header = if opts.print_header && status.success() { let header = if status.success() {
stdout.next().expect("Not enough lines to unpack in downstream result") stdout.next().unwrap_or_default()
} else { } else {
"" ""
} }
.to_string(); .to_string();
let current: Option<Arc<dyn SkimItem>> = if status.success() {
let line = stdout.next().unwrap_or_default();
if line.is_empty() {
None
} else {
Some(Arc::new(SkimTmuxOutput { line: line.to_string() }))
}
} else {
None
};
let mut output_lines: Vec<Arc<MatchedItem>> = vec![]; let mut output_lines: Vec<Arc<MatchedItem>> = vec![];
while let Some(line) = stdout.next() { while let Some(line) = stdout.next() {
debug!("Adding output line: {line}"); debug!("Adding output line: {line}");
let mut item = MatchedItem { // --print-score is always enabled in the child, so every item is followed by its score.
let score: i32 = stdout.next().unwrap_or_default().parse().unwrap_or_default();
let item = MatchedItem {
item: Arc::new(SkimTmuxOutput { line: line.to_string() }), item: Arc::new(SkimTmuxOutput { line: line.to_string() }),
rank: [0; 5], rank: [score, 0, 0, 0, 0],
matched_range: None, matched_range: None,
}; };
if opts.print_score {
item.rank = [
stdout.next().unwrap_or_default().parse().unwrap_or_default(),
0,
0,
0,
0,
];
}
output_lines.push(Arc::new(item)); output_lines.push(Arc::new(item));
} }
@ -294,6 +325,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
query: query_str.to_string(), query: query_str.to_string(),
cmd: command_str.to_string(), cmd: command_str.to_string(),
selected_items: output_lines, selected_items: output_lines,
current,
header, header,
}; };
Some(skim_output) Some(skim_output)

View file

@ -168,13 +168,48 @@ pub fn printf(
} }
} }
s => { s => {
if let Some(range) = FieldRange::from_str(s) { let (is_plus, stripped) = match s.strip_prefix('+') {
let replacement = Some(x) => (true, x),
get_string_by_field(delimiter, &item_text, &range).unwrap_or_default(); None => (false, s),
replaced.push_str(&escape_arg(replacement, true)); };
if is_plus {
let mut quote_individually = false;
let (stripped, delim) = stripped.rsplit_once(':').unwrap_or_else(|| {
quote_individually = quote_args;
(stripped, " ")
});
if let Some(range) = FieldRange::from_str(stripped) {
let expanded = selected
.clone()
.map(|i| {
escape_arg(
get_string_by_field(delimiter, &strip_ansi(&i.output()).0, &range)
.unwrap_or_default(),
quote_individually,
)
})
.reduce(|a: String, b| a.to_owned() + delim + b.as_str())
.unwrap_or_default();
if quote_args && !quote_individually {
replaced.push_str(&format!("'{}'", expanded));
} else {
replaced.push_str(&expanded);
}
} else {
log::warn!("Failed to build multi-item field range from {content}");
replaced.push_str(&format!("{{{s}}}"));
}
} else { } else {
log::warn!("Failed to build field range from {content}"); if let Some(range) = FieldRange::from_str(stripped) {
replaced.push_str(&format!("{{{s}}}")); let replacement =
get_string_by_field(delimiter, &item_text, &range).unwrap_or_default();
replaced.push_str(&escape_arg(replacement, true));
} else {
log::warn!("Failed to build field range from {content}");
replaced.push_str(&format!("{{{s}}}"));
}
} }
} }
} }

View file

@ -50,10 +50,45 @@ fn tmux_vanilla() -> Result<()> {
let cmd = get_tmux_cmd(&outfile)?; let cmd = get_tmux_cmd(&outfile)?;
assert!(cmd.starts_with("display-popup")); assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E")); assert!(cmd.contains("-E"));
assert!(cmd.contains("--print-query"));
assert!(cmd.contains("--print-cmd"));
assert!(cmd.contains("--print-header"));
assert!(cmd.contains("--print-current"));
assert!(cmd.contains("--print-score"));
assert!(!cmd.contains("<")); assert!(!cmd.contains("<"));
Ok(()) Ok(())
} }
#[test]
fn tmux_output_format() -> Result<()> {
let mut tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
tmux.start_sk(
None,
&[
"--tmux",
"--output-format",
"output-format",
"--output-format=output-format",
],
)?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E"));
assert!(cmd.contains("--print-query"));
assert!(cmd.contains("--print-cmd"));
assert!(cmd.contains("--print-header"));
assert!(cmd.contains("--print-current"));
assert!(cmd.contains("--print-score"));
assert!(cmd.contains("--print-score"));
assert!(!cmd.contains("output-format"));
assert!(!cmd.contains("<"));
Ok(())
}
#[test] #[test]
fn tmux_stdin() -> Result<()> { fn tmux_stdin() -> Result<()> {
let mut tmux = TmuxController::new()?; let mut tmux = TmuxController::new()?;
@ -95,7 +130,9 @@ fn tmux_quote_zsh() -> Result<()> {
println!("{cmd}"); println!("{cmd}");
assert!(cmd.starts_with("display-popup")); assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E")); assert!(cmd.contains("-E"));
assert!(cmd.contains("sk --bind $'ctrl-a:reload(ls /foo*)' >")); assert!(cmd.contains(
"sk --bind $'ctrl-a:reload(ls /foo*)' --print-query --print-cmd --print-header --print-current --print-score >"
));
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;")); assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
Ok(()) Ok(())