ad_editor/
system.rs

1//! An abstraction around system interactions to support testing and
2//! platform specific behaviour
3use crate::{editor::Action, input::Event, util::normalize_line_endings};
4use std::{
5    env,
6    ffi::OsStr,
7    fmt,
8    io::{self, Read, Write},
9    path::Path,
10    process::{Command, Stdio},
11    sync::mpsc::Sender,
12    thread::spawn,
13};
14use tracing::info;
15
16/// Wrapper around storing system interactions
17pub trait System: fmt::Debug {
18    /// Set the clipboard to the given string
19    fn set_clipboard(&mut self, s: &str) -> io::Result<()>;
20
21    /// Read the current contents of the clipboard
22    fn read_clipboard(&self) -> io::Result<String>;
23
24    /// Run an external command and collect its output.
25    fn run_command_blocking<I, S>(
26        &self,
27        cmd: &str,
28        args: I,
29        cwd: &Path,
30        bufid: usize,
31    ) -> io::Result<String>
32    where
33        I: IntoIterator<Item = S>,
34        S: AsRef<OsStr>,
35    {
36        run_command_blocking(cmd, args, cwd, bufid)
37    }
38
39    /// Run an external command and append its output to the output buffer for `bufid` from a
40    /// background thread.
41    fn run_command<I, S>(&self, cmd: &str, args: I, cwd: &Path, bufid: usize, tx: Sender<Event>)
42    where
43        I: IntoIterator<Item = S>,
44        S: AsRef<OsStr>,
45    {
46        run_command(cmd, args, cwd, bufid, tx)
47    }
48
49    /// Pipe input text through an external command, returning the output
50    fn pipe_through_command<I, S>(
51        &self,
52        cmd: &str,
53        args: I,
54        input: &str,
55        cwd: &Path,
56        bufid: usize,
57    ) -> io::Result<String>
58    where
59        I: IntoIterator<Item = S>,
60        S: AsRef<OsStr>,
61    {
62        pipe_through_command(cmd, args, input, cwd, bufid)
63    }
64}
65
66#[derive(Debug, Clone)]
67struct ClipboardProvider {
68    copy_cmd: &'static str,
69    copy_args: Vec<&'static str>,
70    paste_cmd: &'static str,
71    paste_args: Vec<&'static str>,
72}
73
74impl ClipboardProvider {
75    pub fn try_from_env() -> Option<Self> {
76        let paths = env::var("PATH").expect("path not set");
77        let exists = |cmd: &str| env::split_paths(&paths).any(|dir| dir.join(cmd).is_file());
78
79        let (copy_cmd, copy_args, paste_cmd, paste_args) = if exists("pbcopy") {
80            info!("clipboard provider found: pbcopy");
81            ("pbcopy", vec![], "pbpaste", vec![])
82        } else if env::var("WAYLAND_DISPLAY").is_ok() && exists("wl-copy") && exists("wl-paste") {
83            info!("clipboard provider found: wl-copy");
84            (
85                "wl-copy",
86                vec!["--foreground", "--type", "text/plain"],
87                "wl-paste",
88                vec!["--no-newline"],
89            )
90        } else if env::var("DISPLAY").is_ok() && exists("xclip") {
91            info!("clipboard provider found: xclip");
92            (
93                "xclip",
94                vec!["-i", "-selection", "clipboard"],
95                "xclip",
96                vec!["-o", "-selection", "clipboard"],
97            )
98        } else {
99            info!("no clipboard provider found");
100            return None;
101        };
102
103        Some(Self {
104            copy_cmd,
105            copy_args,
106            paste_cmd,
107            paste_args,
108        })
109    }
110}
111
112/// A default implementation for system interactions
113#[derive(Debug, Clone)]
114pub struct DefaultSystem {
115    selection: String,
116    cp: Option<ClipboardProvider>,
117}
118
119impl DefaultSystem {
120    pub fn from_env() -> Self {
121        Self {
122            selection: String::new(),
123            cp: ClipboardProvider::try_from_env(),
124        }
125    }
126}
127
128impl System for DefaultSystem {
129    fn set_clipboard(&mut self, s: &str) -> io::Result<()> {
130        match &self.cp {
131            Some(cp) => {
132                let mut child = Command::new(cp.copy_cmd)
133                    .args(&cp.copy_args)
134                    .stdin(Stdio::piped())
135                    .spawn()?;
136
137                child.stdin.take().unwrap().write_all(s.as_bytes())
138            }
139
140            None => {
141                self.selection = s.to_string();
142                Ok(())
143            }
144        }
145    }
146
147    fn read_clipboard(&self) -> io::Result<String> {
148        match &self.cp {
149            Some(cp) => {
150                let output = Command::new(cp.paste_cmd).args(&cp.paste_args).output()?;
151
152                Ok(String::from_utf8(output.stdout).unwrap_or_default())
153            }
154
155            None => Ok(self.selection.clone()),
156        }
157    }
158}
159
160fn prepare_command<I, S>(cmd: &str, args: I, cwd: &Path, bufid: usize) -> Command
161where
162    I: IntoIterator<Item = S>,
163    S: AsRef<OsStr>,
164{
165    let path = env::var("PATH").unwrap();
166    let home = env::var("HOME").unwrap();
167    let mut command = Command::new(cmd);
168    command
169        .env("PATH", format!("{home}/.ad/bin:{path}"))
170        .env("bufid", bufid.to_string())
171        .current_dir(cwd)
172        .args(args);
173
174    command
175}
176
177fn run_command_blocking<I, S>(cmd: &str, args: I, cwd: &Path, bufid: usize) -> io::Result<String>
178where
179    I: IntoIterator<Item = S>,
180    S: AsRef<OsStr>,
181{
182    let output = prepare_command(cmd, args, cwd, bufid).output()?;
183    let mut stdout = String::from_utf8(output.stdout).unwrap_or_default();
184    let stderr = String::from_utf8(output.stderr).unwrap_or_default();
185    stdout.push_str(&stderr);
186
187    Ok(normalize_line_endings(stdout))
188}
189
190fn run_command<I, S>(cmd: &str, args: I, cwd: &Path, bufid: usize, tx: Sender<Event>)
191where
192    I: IntoIterator<Item = S>,
193    S: AsRef<OsStr>,
194{
195    let mut command = prepare_command(cmd, args, cwd, bufid);
196
197    spawn(move || {
198        let output = match command.output() {
199            Ok(output) => output,
200            Err(err) => {
201                _ = tx.send(Event::Action(Action::SetStatusMessage {
202                    message: err.to_string(),
203                }));
204                return;
205            }
206        };
207
208        let mut content = String::from_utf8(output.stdout).unwrap_or_default();
209        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
210        content.push_str(&stderr);
211        if content.is_empty() {
212            return;
213        }
214        _ = tx.send(Event::Action(Action::AppendToOutputBuffer {
215            bufid,
216            content: normalize_line_endings(content),
217        }));
218    });
219}
220
221/// Pipe input text through an external command, returning the output
222pub fn pipe_through_command<I, S>(
223    cmd: &str,
224    args: I,
225    input: &str,
226    cwd: &Path,
227    bufid: usize,
228) -> io::Result<String>
229where
230    I: IntoIterator<Item = S>,
231    S: AsRef<OsStr>,
232{
233    let mut child = prepare_command(cmd, args, cwd, bufid)
234        .stdin(Stdio::piped())
235        .stdout(Stdio::piped())
236        .stderr(Stdio::piped())
237        .spawn()?;
238
239    let mut buf = String::new();
240    child.stdin.take().unwrap().write_all(input.as_bytes())?;
241    child.stdout.take().unwrap().read_to_string(&mut buf)?;
242    child.stderr.take().unwrap().read_to_string(&mut buf)?;
243    _ = child.wait();
244
245    Ok(normalize_line_endings(buf))
246}