completest_pty/
lib.rs

1//! Run completions for your program
2//!
3//! # Example
4//!
5//! ```rust,no_run
6//! # #[cfg(unix)] {
7//! # use std::path::Path;
8//! # let bin_root = Path::new("").to_owned();
9//! # let completion_script = "";
10//! # let home = std::env::current_dir().unwrap();
11//! let term = completest_pty::Term::new();
12//!
13//! let mut runtime = completest_pty::BashRuntime::new(bin_root, home).unwrap();
14//! runtime.register("foo", completion_script).unwrap();
15//! let output = runtime.complete("foo \t\t", &term).unwrap();
16//! # }
17//! ```
18
19#![cfg_attr(docsrs, feature(doc_auto_cfg))]
20#![warn(missing_docs)]
21#![warn(clippy::print_stderr)]
22#![warn(clippy::print_stdout)]
23#![cfg(unix)]
24
25use std::ffi::OsStr;
26use std::ffi::OsString;
27use std::io::Read as _;
28use std::io::Write as _;
29use std::path::PathBuf;
30use std::process::Command;
31use std::time::Duration;
32
33use ptyprocess::PtyProcess;
34
35pub use completest::Runtime;
36pub use completest::RuntimeBuilder;
37pub use completest::Term;
38
39/// Abstract factory for [`ZshRuntime`]
40#[derive(Debug)]
41#[non_exhaustive]
42pub struct ZshRuntimeBuilder {}
43
44impl RuntimeBuilder for ZshRuntimeBuilder {
45    type Runtime = ZshRuntime;
46
47    fn name() -> &'static str {
48        "zsh"
49    }
50
51    fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
52        ZshRuntime::new(bin_root, home)
53    }
54
55    fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
56        ZshRuntime::with_home(bin_root, home)
57    }
58}
59
60/// Zsh runtime
61#[derive(Debug)]
62#[cfg(unix)] // purely for rustdoc to pick it up
63pub struct ZshRuntime {
64    path: OsString,
65    home: PathBuf,
66    timeout: Duration,
67}
68
69impl ZshRuntime {
70    /// Initialize a new runtime's home
71    pub fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
72        std::fs::create_dir_all(&home)?;
73
74        let config_path = home.join(".zshenv");
75        let config = "\
76fpath=($fpath $ZDOTDIR/zsh)
77autoload -U +X compinit && compinit -u # bypass compaudit security checking
78precmd_functions=\"\"  # avoid the prompt being overwritten
79PS1='%% '
80PROMPT='%% '
81";
82        std::fs::write(config_path, config)?;
83
84        let _ = std::fs::remove_file(home.join(".zcompdump"));
85
86        Self::with_home(bin_root, home)
87    }
88
89    /// Reuse an existing runtime's home
90    pub fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
91        let path = build_path(bin_root);
92
93        Ok(Self {
94            path,
95            home,
96            timeout: Duration::from_millis(100),
97        })
98    }
99
100    /// Location of the runtime's home directory
101    pub fn home(&self) -> &std::path::Path {
102        &self.home
103    }
104
105    /// Register a completion script
106    pub fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
107        let path = self.home.join(format!("zsh/_{name}"));
108        std::fs::create_dir_all(path.parent().expect("path created with parent"))?;
109        std::fs::write(path, content)
110    }
111
112    /// Get the output from typing `input` into the shell
113    pub fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
114        let mut command = Command::new("zsh");
115        command.arg("--noglobalrcs");
116        command
117            .env("PATH", &self.path)
118            .env("TERM", "xterm")
119            .env("ZDOTDIR", &self.home);
120        let echo = false;
121        comptest(command, echo, input, term, self.timeout)
122    }
123}
124
125impl Runtime for ZshRuntime {
126    fn home(&self) -> &std::path::Path {
127        self.home()
128    }
129
130    fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
131        self.register(name, content)
132    }
133
134    fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
135        self.complete(input, term)
136    }
137}
138
139/// Abstract factory for [`BashRuntime`]
140#[derive(Debug)]
141#[non_exhaustive]
142pub struct BashRuntimeBuilder {}
143
144impl RuntimeBuilder for BashRuntimeBuilder {
145    type Runtime = BashRuntime;
146
147    fn name() -> &'static str {
148        "bash"
149    }
150
151    fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
152        BashRuntime::new(bin_root, home)
153    }
154
155    fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
156        BashRuntime::with_home(bin_root, home)
157    }
158}
159
160/// Bash runtime
161#[derive(Debug)]
162#[cfg(unix)] // purely for rustdoc to pick it up
163pub struct BashRuntime {
164    path: OsString,
165    home: PathBuf,
166    config: PathBuf,
167    timeout: Duration,
168}
169
170impl BashRuntime {
171    /// Initialize a new runtime's home
172    pub fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
173        std::fs::create_dir_all(&home)?;
174
175        let config_path = home.join(".bashrc");
176        let inputrc_path = home.join(".inputrc");
177        let config = "\
178PS1='% '
179. /etc/bash_completion
180"
181        .to_owned();
182        std::fs::write(config_path, config)?;
183        // Ignore ~/.inputrc which may set vi edit mode.
184        std::fs::write(
185            inputrc_path,
186            "# expected empty file to disable loading ~/.inputrc\n",
187        )?;
188
189        Self::with_home(bin_root, home)
190    }
191
192    /// Reuse an existing runtime's home
193    pub fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
194        let config_path = home.join(".bashrc");
195        let path = build_path(bin_root);
196
197        Ok(Self {
198            path,
199            home,
200            config: config_path,
201            timeout: Duration::from_millis(50),
202        })
203    }
204
205    /// Location of the runtime's home directory
206    pub fn home(&self) -> &std::path::Path {
207        &self.home
208    }
209
210    /// Register a completion script
211    pub fn register(&mut self, _name: &str, content: &str) -> std::io::Result<()> {
212        let mut file = std::fs::OpenOptions::new()
213            .append(true)
214            .open(&self.config)?;
215        writeln!(&mut file, "{content}")?;
216        Ok(())
217    }
218
219    /// Get the output from typing `input` into the shell
220    pub fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
221        let mut command = Command::new("bash");
222        let inputrc_path = self.home.join(".inputrc");
223        command
224            .env("PATH", &self.path)
225            .env("TERM", "xterm")
226            .env("INPUTRC", &inputrc_path)
227            .args([
228                OsStr::new("--noprofile"),
229                OsStr::new("--rcfile"),
230                self.config.as_os_str(),
231            ]);
232        let echo = !input.contains("\t\t");
233        comptest(command, echo, input, term, self.timeout)
234    }
235}
236
237impl Runtime for BashRuntime {
238    fn home(&self) -> &std::path::Path {
239        self.home()
240    }
241
242    fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
243        self.register(name, content)
244    }
245
246    fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
247        self.complete(input, term)
248    }
249}
250
251/// Abstract factory for [`FishRuntime`]
252#[derive(Debug)]
253#[non_exhaustive]
254pub struct FishRuntimeBuilder {}
255
256impl RuntimeBuilder for FishRuntimeBuilder {
257    type Runtime = FishRuntime;
258
259    fn name() -> &'static str {
260        "fish"
261    }
262
263    fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
264        FishRuntime::new(bin_root, home)
265    }
266
267    fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
268        FishRuntime::with_home(bin_root, home)
269    }
270}
271
272/// Fish runtime
273#[derive(Debug)]
274#[cfg(unix)] // purely for rustdoc to pick it up
275pub struct FishRuntime {
276    path: OsString,
277    home: PathBuf,
278    timeout: Duration,
279}
280
281impl FishRuntime {
282    /// Initialize a new runtime's home
283    pub fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
284        std::fs::create_dir_all(&home)?;
285
286        let config_path = home.join("fish/config.fish");
287        let config = "\
288set -U fish_greeting \"\"
289set -U fish_autosuggestion_enabled 0
290function fish_title
291end
292function fish_prompt
293    printf '%% '
294end;
295"
296        .to_owned();
297        std::fs::create_dir_all(config_path.parent().expect("path created with parent"))?;
298        std::fs::write(config_path, config)?;
299
300        Self::with_home(bin_root, home)
301    }
302
303    /// Reuse an existing runtime's home
304    pub fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
305        let path = build_path(bin_root);
306
307        Ok(Self {
308            path,
309            home,
310            timeout: Duration::from_millis(50),
311        })
312    }
313
314    /// Location of the runtime's home directory
315    pub fn home(&self) -> &std::path::Path {
316        &self.home
317    }
318
319    /// Register a completion script
320    pub fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
321        let path = self.home.join(format!("fish/completions/{name}.fish"));
322        std::fs::create_dir_all(path.parent().expect("path created with parent"))?;
323        std::fs::write(path, content)
324    }
325
326    /// Get the output from typing `input` into the shell
327    pub fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
328        let mut command = Command::new("fish");
329        command
330            .env("PATH", &self.path)
331            // fish requires TERM to be set.
332            .env("TERM", "xterm")
333            .env("XDG_CONFIG_HOME", &self.home);
334        let echo = false;
335        comptest(command, echo, input, term, self.timeout)
336    }
337}
338
339impl Runtime for FishRuntime {
340    fn home(&self) -> &std::path::Path {
341        self.home()
342    }
343
344    fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
345        self.register(name, content)
346    }
347
348    fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
349        self.complete(input, term)
350    }
351}
352
353/// Abstract factory for [`ElvishRuntime`]
354#[derive(Debug)]
355#[non_exhaustive]
356pub struct ElvishRuntimeBuilder {}
357
358impl RuntimeBuilder for ElvishRuntimeBuilder {
359    type Runtime = ElvishRuntime;
360
361    fn name() -> &'static str {
362        "elvish"
363    }
364
365    fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
366        ElvishRuntime::new(bin_root, home)
367    }
368
369    fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self::Runtime> {
370        ElvishRuntime::with_home(bin_root, home)
371    }
372}
373
374/// Elvish runtime
375#[derive(Debug)]
376#[cfg(unix)] // purely for rustdoc to pick it up
377pub struct ElvishRuntime {
378    path: OsString,
379    home: PathBuf,
380    config: PathBuf,
381    timeout: Duration,
382}
383
384impl ElvishRuntime {
385    /// Initialize a new runtime's home
386    pub fn new(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
387        std::fs::create_dir_all(&home)?;
388
389        let config_path = home.join("elvish/rc.elv");
390        let config = "\
391set edit:rprompt = (constantly \"\")
392set edit:prompt = (constantly \"% \")
393"
394        .to_owned();
395        std::fs::create_dir_all(config_path.parent().expect("path created with parent"))?;
396        std::fs::write(config_path, config)?;
397
398        Self::with_home(bin_root, home)
399    }
400
401    /// Reuse an existing runtime's home
402    pub fn with_home(bin_root: PathBuf, home: PathBuf) -> std::io::Result<Self> {
403        let config_path = home.join("elvish/rc.elv");
404        let path = build_path(bin_root);
405
406        Ok(Self {
407            path,
408            home,
409            config: config_path,
410            timeout: Duration::from_millis(50),
411        })
412    }
413
414    /// Location of the runtime's home directory
415    pub fn home(&self) -> &std::path::Path {
416        &self.home
417    }
418
419    /// Register a completion script
420    pub fn register(&mut self, _name: &str, content: &str) -> std::io::Result<()> {
421        let mut file = std::fs::OpenOptions::new()
422            .append(true)
423            .open(&self.config)?;
424        writeln!(&mut file, "{content}")?;
425        Ok(())
426    }
427
428    /// Get the output from typing `input` into the shell
429    pub fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
430        let mut command = Command::new("elvish");
431        command
432            .env("PATH", &self.path)
433            .env("XDG_CONFIG_HOME", &self.home);
434        let echo = false;
435        comptest(command, echo, input, term, self.timeout)
436    }
437}
438
439impl Runtime for ElvishRuntime {
440    fn home(&self) -> &std::path::Path {
441        self.home()
442    }
443
444    fn register(&mut self, name: &str, content: &str) -> std::io::Result<()> {
445        self.register(name, content)
446    }
447
448    fn complete(&mut self, input: &str, term: &Term) -> std::io::Result<String> {
449        self.complete(input, term)
450    }
451}
452
453fn comptest(
454    command: Command,
455    echo: bool,
456    input: &str,
457    term: &Term,
458    timeout: Duration,
459) -> std::io::Result<String> {
460    #![allow(clippy::unwrap_used)] // some unwraps need extra investigation
461
462    // spawn a new process, pass it the input was.
463    //
464    // This triggers completion loading process which takes some time in shell so we should let it
465    // run for some time
466    let mut process = PtyProcess::spawn(command)?;
467    process.set_window_size(term.get_width(), term.get_height())?;
468    // for some reason bash does not produce anything with echo disabled...
469    process.set_echo(echo, None)?;
470
471    let mut parser = vt100::Parser::new(term.get_height(), term.get_width(), 0);
472
473    let mut stream = process.get_raw_handle()?;
474    // pass the completion input
475    write!(stream, "{}", input)?;
476    stream.flush()?;
477
478    let (snd, rcv) = std::sync::mpsc::channel();
479
480    let shutdown = std::sync::atomic::AtomicBool::new(false);
481    let shutdown_ref = &shutdown;
482    std::thread::scope(|scope| {
483        scope.spawn(move || {
484            // since we don't know when exactly shell is done completing the idea is to wait until
485            // something at all is produced, then wait for some duration since the last produced chunk.
486            rcv.recv().unwrap();
487            while rcv.recv_timeout(timeout).is_ok() {}
488            shutdown_ref.store(true, std::sync::atomic::Ordering::SeqCst);
489            process.exit(false).unwrap();
490        });
491
492        let mut buf = [0; 2048];
493        while let Ok(n) = stream.read(&mut buf) {
494            if shutdown.load(std::sync::atomic::Ordering::SeqCst) {
495                // fish clears completions on process teardown
496                break;
497            }
498            let buf = &buf[..n];
499            if buf.is_empty() {
500                break;
501            }
502            let _ = snd.send(());
503            parser.process(buf);
504        }
505    });
506
507    let content = parser.screen().contents();
508    Ok(content)
509}
510
511fn build_path(bin_root: PathBuf) -> OsString {
512    let mut path = bin_root.into_os_string();
513    if let Some(existing) = std::env::var_os("PATH") {
514        path.push(":");
515        path.push(existing);
516    }
517    path
518}