mod tmux;
mod zellij;
use std::borrow::Cow;
use std::fmt::Write as FmtWrite;
use std::io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _};
use std::process::ExitStatus;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use nix::sys::stat::Mode;
use nix::unistd::mkfifo;
use crate::item::{MatchedItem, RankBuilder};
use crate::tui::Event;
use crate::tui::event::Action;
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
use tmux::TmuxPopup;
use zellij::ZellijPopup;
#[derive(Debug, PartialEq, Eq)]
enum PopupWindowDir {
Center,
Top,
Bottom,
Left,
Right,
}
impl From<&str> for PopupWindowDir {
fn from(value: &str) -> Self {
use PopupWindowDir::{Bottom, Center, Left, Right, Top};
match value {
"top" => Top,
"bottom" => Bottom,
"left" => Left,
"right" => Right,
_ => Center, }
}
}
trait SkimPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
where
Self: Sized;
fn add_env(&mut self, key: &str, value: &str);
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
}
struct SkimPopupOutput {
line: String,
}
impl SkimItem for SkimPopupOutput {
fn text(&self) -> Cow<'_, str> {
Cow::from(&self.line)
}
}
#[must_use]
pub fn check_env() -> bool {
std::env::var("_SKIM_POPUP").is_err() && (tmux::is_available() || zellij::is_available())
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
let temp_dir = tempfile::tempdir().ok()?;
debug!("Created temp dir {temp_dir:?}");
let tmp_stdout = temp_dir.path().join("stdout");
let tmp_stdin = temp_dir.path().join("stdin");
let has_piped_input = !std::io::stdin().is_terminal();
let mut stdin_reader = BufReader::new(std::io::stdin());
let line_ending = if opts.read0 { b'\0' } else { b'\n' };
let stop_reading = Arc::new(AtomicBool::new(false));
let _stdin_handle = if has_piped_input {
debug!("Reading stdin and piping to fifo");
let stdin_path_str = tmp_stdin
.to_str()
.unwrap_or_else(|| panic!("Failed to convert stdin path to string"));
mkfifo(stdin_path_str, Mode::S_IRUSR | Mode::S_IWUSR)
.unwrap_or_else(|e| panic!("Failed to create fifo {}: {}", tmp_stdin.display(), e));
let tmp_stdin_clone = tmp_stdin.clone();
let stop_flag = Arc::clone(&stop_reading);
Some(thread::spawn(move || {
debug!("Opening fifo for writing (may block until reader starts)");
let stdin_f = std::fs::File::create(tmp_stdin_clone.clone())
.unwrap_or_else(|e| panic!("Failed to open fifo {}: {}", tmp_stdin_clone.display(), e));
debug!("Fifo opened for writing");
let mut stdin_writer = BufWriter::new(stdin_f);
loop {
if stop_flag.load(Ordering::Relaxed) {
debug!("Stop signal received, exiting stdin reader thread");
break;
}
let mut buf = vec![];
match stdin_reader.read_until(line_ending, &mut buf) {
Ok(0) => break,
Ok(n) => {
debug!("Read {n} bytes from stdin");
if let Err(e) = stdin_writer.write_all(&buf) {
debug!("Exit error (silent): failed to write bytes: {e}");
break;
}
}
Err(e) => {
error!("Failed to read from stdin: {e}");
break;
}
}
}
let _ = stdin_writer.flush();
}))
} else {
None
};
let mut stripped_shell_cmd = String::new();
let mut prev_is_popup = false;
let mut prev_is_output_format_flag = false;
for arg in std::env::args() {
debug!("Got arg {arg}");
if prev_is_popup {
prev_is_popup = false;
if !arg.starts_with('-') {
continue;
}
} else if prev_is_output_format_flag {
prev_is_output_format_flag = false;
continue;
}
if arg == "--tmux" || arg == "--popup" {
debug!("Found popup arg, skipping this and the next");
prev_is_popup = true;
continue;
} else if arg.starts_with("--tmux") || arg.starts_with("--popup") {
debug!("Found equal popup arg, skipping");
continue;
} else if arg == "--print-cmd" {
debug!("Found print cmd arg, skipping");
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 stripped_shell_cmd, &arg);
}
for flag in &["--print-query", "--print-header", "--print-current", "--print-score"] {
let _ = write!(stripped_shell_cmd, " {flag}");
}
if has_piped_input {
let _ = write!(stripped_shell_cmd, " <{}", tmp_stdin.display());
}
let _ = write!(stripped_shell_cmd, " >{}", tmp_stdout.display());
debug!("build cmd {stripped_shell_cmd}");
let mut popup: Box<dyn SkimPopup> = if zellij::is_available() {
ZellijPopup::from_options(opts)
} else if tmux::is_available() {
TmuxPopup::from_options(opts)
} else {
panic!("You shouldn't have been able to get here");
};
for (name, value) in std::env::vars() {
if name.starts_with("SKIM") || name == "PATH" || name.starts_with("RUST") {
let value = sanitize_value(value);
debug!("adding {name} = {value} to the command's env");
popup.add_env(&name, &value);
}
}
popup.add_env("_SKIM_POPUP", "1");
let status = popup
.run_and_wait(&stripped_shell_cmd)
.unwrap_or_else(|e| panic!("Popup invocation of {stripped_shell_cmd} failed with {e}"));
stop_reading.store(true, Ordering::Relaxed);
let output_ending = if opts.print0 { "\0" } else { "\n" };
let mut stdout_bytes = std::fs::read_to_string(tmp_stdout).unwrap_or_default();
stdout_bytes.pop();
let mut stdout = stdout_bytes.split(output_ending);
let _ = std::fs::remove_dir_all(temp_dir);
debug!("popup stdout: {stdout:?}");
let query_str = if status.success() {
stdout.next().unwrap_or_default()
} else {
""
};
let header = if status.success() {
stdout.next().unwrap_or_default()
} else {
""
}
.to_string();
let current: Option<MatchedItem> = if status.success() {
let line = stdout.next().unwrap_or_default();
if line.is_empty() {
None
} else {
Some(MatchedItem::new(
Arc::new(SkimPopupOutput { line: line.to_string() }),
Rank::default(),
None,
&RankBuilder::default(),
))
}
} else {
None
};
let mut output_lines: Vec<MatchedItem> = vec![];
while let Some(line) = stdout.next() {
debug!("Adding output line: {line}");
let score: i32 = stdout.next().unwrap_or_default().parse().unwrap_or_default();
let rank = Rank {
score,
..Default::default()
};
let item = MatchedItem::new(
Arc::new(SkimPopupOutput { line: line.to_string() }),
rank,
None,
&RankBuilder::default(),
);
output_lines.push(item);
}
let is_abort = !status.success();
let final_event = if is_abort {
Event::Action(Action::Abort)
} else {
Event::Action(Action::Accept(None))
};
let skim_output = SkimOutput {
final_event,
is_abort,
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
query: query_str.to_string(),
cmd: opts.cmd.clone().unwrap_or_default(),
selected_items: output_lines,
current,
header,
};
Some(skim_output)
}
fn push_quoted_arg(args_str: &mut String, arg: &str) {
use shell_quote::{Quote as _, Sh};
let _ = write!(
args_str,
" {}",
String::from_utf8(Sh::quote(arg)).expect("Failed to parse quoted arg as utf8, this should not happen")
);
}
fn sanitize_value(value: String) -> String {
if !value.ends_with(';') {
return value;
}
let mut value = value.clone();
value.replace_range(value.len() - 1.., "\\;");
value
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;