#![warn(missing_docs)]
#![warn(clippy::doc_markdown)]
mod buffer;
mod completion;
mod display;
mod edit;
mod error;
mod hints;
mod history;
mod parser;
mod state;
mod terminal;
mod unicode;
pub use completion::{
CommandCompleter, CompletionProvider, Completions, FileCompleter, NoCompleter, format_columns,
word_start,
};
pub use error::{Result, RustlineError};
pub use hints::{Hint, HintProvider, NoHinter};
pub use history::History;
pub use terminal::WinSize;
pub use unicode::{char_width, display_width, is_separator};
use parser::{KeySeq, Parser};
use state::EditorState;
use std::collections::VecDeque;
use std::os::unix::io::{AsRawFd, RawFd};
use std::path::PathBuf;
use terminal::{RawMode, is_tty, is_unsupported_term, read_input, terminal_size, write_all};
#[derive(Debug, Clone)]
pub struct Config {
pub history_max_size: usize,
pub enable_hints: bool,
pub enable_completion: bool,
pub enable_multiline: bool,
pub enable_bracketed_paste: bool,
pub balance_pairs: bool,
pub highlight_brackets: bool,
pub mask_mode: bool,
pub continuation_prompt: String,
}
impl Default for Config {
fn default() -> Self {
Config {
history_max_size: 1024,
enable_hints: true,
enable_completion: true,
enable_multiline: true,
enable_bracketed_paste: true,
balance_pairs: false,
highlight_brackets: true,
mask_mode: false,
continuation_prompt: "... ".to_string(),
}
}
}
pub struct Rustline {
pub(crate) config: Config,
pub(crate) completer: Box<dyn CompletionProvider>,
pub(crate) hinter: Box<dyn HintProvider>,
pub(crate) xlat: Option<Box<dyn Fn(char) -> char>>,
pub(crate) history: History,
pub(crate) parser: Parser,
pub(crate) pending: VecDeque<KeySeq>,
}
impl Default for Rustline {
fn default() -> Self {
Self::new()
}
}
impl Rustline {
#[must_use]
pub fn new() -> Self {
Self::with_config(Config::default())
}
#[must_use]
pub fn with_config(config: Config) -> Self {
let history = History::new(config.history_max_size);
Rustline {
config,
completer: Box::new(NoCompleter),
hinter: Box::new(NoHinter),
xlat: None,
history,
parser: Parser::new(),
pending: VecDeque::new(),
}
}
#[must_use]
pub fn config(&self) -> &Config {
&self.config
}
pub fn set_config(&mut self, config: Config) {
if config.history_max_size != self.history.max_size() {
let mut resized = History::new(config.history_max_size);
for entry in self.history.iter() {
resized.push(entry.to_string());
}
self.history = resized;
}
self.config = config;
}
pub fn set_completer(&mut self, completer: Box<dyn CompletionProvider>) {
self.completer = completer;
}
pub fn set_hinter(&mut self, hinter: Box<dyn HintProvider>) {
self.hinter = hinter;
}
pub fn set_xlat(&mut self, xlat: Box<dyn Fn(char) -> char>) {
self.xlat = Some(xlat);
}
pub fn clear_xlat(&mut self) {
self.xlat = None;
}
#[must_use]
pub fn history(&self) -> &History {
&self.history
}
pub fn history_mut(&mut self) -> &mut History {
&mut self.history
}
pub fn set_history(&mut self, history: History) {
self.history = history;
}
pub fn add_history_entry(&mut self, line: &str) -> bool {
self.history.add(line)
}
pub fn load_history(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
self.history.load(path)
}
pub fn save_history(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
self.history.save(path)
}
pub fn readline(&mut self, prompt: &str) -> Result<String> {
self.readline_with_init(prompt, "")
}
pub fn readline_with_init(&mut self, prompt: &str, init: &str) -> Result<String> {
let ifd = std::io::stdin().as_raw_fd();
let ofd = std::io::stdout().as_raw_fd();
self.readline_raw(prompt, init, ifd, ofd)
}
pub fn read_password(&mut self, prompt: &str) -> Result<String> {
let was_masked = self.config.mask_mode;
self.config.mask_mode = true;
let result = self.readline(prompt);
self.config.mask_mode = was_masked;
result
}
pub fn readline_raw(
&mut self,
prompt: &str,
init: &str,
ifd: RawFd,
ofd: RawFd,
) -> Result<String> {
let _ = std::io::Write::flush(&mut std::io::stdout());
if !is_tty(ifd) || !is_tty(ofd) || is_unsupported_term() {
if !prompt.is_empty() && is_tty(ofd) {
write_all(ofd, prompt.as_bytes())?;
}
return read_plain_line(ifd);
}
let mut raw = RawMode::enable(ifd)?;
let win = terminal_size(ifd, ofd);
let mut state = EditorState::new(prompt, &self.config.continuation_prompt, ifd, ofd, win);
state.mask = self.config.mask_mode;
if self.config.enable_bracketed_paste {
let _ = write_all(ofd, b"\x1b[?2004h");
}
let result = self.edit(&mut state, &mut raw, init);
if self.config.enable_bracketed_paste {
let _ = write_all(ofd, b"\x1b[?2004l");
}
if state.paused {
let _ = terminal::resume_output(ofd);
}
let trailing = state.renderer.finish();
let _ = write_all(ofd, &trailing);
result
}
}
pub fn clear_screen() -> Result<()> {
let ofd = std::io::stdout().as_raw_fd();
terminal::clear_screen(ofd)
}
fn read_plain_line(fd: RawFd) -> Result<String> {
let mut bytes = Vec::new();
let mut byte = [0u8; 1];
let mut saw_newline = false;
loop {
match read_input(fd, &mut byte)? {
None => continue,
Some(0) => break,
Some(_) => {
if byte[0] == b'\n' {
saw_newline = true;
break;
}
bytes.push(byte[0]);
}
}
}
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
if bytes.is_empty() && !saw_newline {
return Err(RustlineError::Eof);
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn readline(prompt: &str) -> Result<String> {
Rustline::new().readline(prompt)
}
pub(crate) fn home_dir() -> Option<PathBuf> {
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => Some(PathBuf::from(home)),
_ => nix::unistd::User::from_uid(nix::unistd::Uid::current())
.ok()
.flatten()
.map(|user| user.dir),
}
}
#[must_use]
pub fn history_path(prog: &str) -> PathBuf {
if prog.contains('/') || prog.contains('.') {
return PathBuf::from(prog);
}
let name = format!(".{prog}_history");
match home_dir() {
Some(home) => home.join(name),
None => PathBuf::from(name),
}
}
pub fn readline_with_history(prompt: &str, prog: &str) -> Result<String> {
let path = history_path(prog);
let mut rl = Rustline::new();
rl.load_history(&path)?;
let line = rl.readline(prompt)?;
if !line.is_empty() {
rl.load_history(&path)?;
rl.add_history_entry(&line);
rl.save_history(&path)?;
}
Ok(line)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_sane() {
let c = Config::default();
assert_eq!(c.history_max_size, 1024);
assert!(c.enable_completion);
assert!(!c.balance_pairs);
assert_eq!(c.continuation_prompt, "... ");
}
#[test]
fn config_history_size_is_honoured() {
let rl = Rustline::with_config(Config {
history_max_size: 7,
..Config::default()
});
assert_eq!(rl.history().max_size(), 7);
}
#[test]
fn resizing_history_keeps_the_newest_entries() {
let mut rl = Rustline::new();
for i in 0..10 {
rl.add_history_entry(&format!("line{i}"));
}
rl.set_config(Config {
history_max_size: 3,
..Config::default()
});
assert_eq!(rl.history().len(), 3);
assert_eq!(rl.history().get(2), Some("line9"));
}
#[test]
fn history_entries_round_trip_through_the_api() {
let mut rl = Rustline::new();
assert!(rl.add_history_entry("one"));
assert!(!rl.add_history_entry("one"));
assert!(rl.add_history_entry("two"));
assert_eq!(rl.history().iter().collect::<Vec<_>>(), ["one", "two"]);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("h");
rl.save_history(&path).unwrap();
let mut other = Rustline::new();
other.load_history(&path).unwrap();
assert_eq!(other.history().iter().collect::<Vec<_>>(), ["one", "two"]);
}
#[test]
fn home_dir_resolves_to_an_absolute_path() {
if let Some(home) = home_dir() {
assert!(home.is_absolute(), "home_dir returned {home:?}");
}
}
#[test]
fn history_path_rules() {
assert_eq!(history_path("/tmp/h"), PathBuf::from("/tmp/h"));
assert_eq!(history_path("h.txt"), PathBuf::from("h.txt"));
let derived = history_path("foo");
assert!(derived.to_string_lossy().ends_with(".foo_history"));
}
#[test]
fn plain_line_reading_from_a_pipe() {
use std::io::Write;
use std::os::fd::AsRawFd;
let (r, mut w) = {
let (r, w) = nix::unistd::pipe().unwrap();
(r, std::fs::File::from(w))
};
w.write_all(b"hello\nworld\n").unwrap();
drop(w);
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "hello");
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "world");
assert!(matches!(
read_plain_line(r.as_raw_fd()),
Err(RustlineError::Eof)
));
}
#[test]
fn plain_line_reading_treats_a_blank_line_as_empty_not_eof() {
use std::io::Write;
use std::os::fd::AsRawFd;
let (r, w) = nix::unistd::pipe().unwrap();
let mut w = std::fs::File::from(w);
w.write_all(b"\n\n").unwrap();
drop(w);
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "");
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "");
assert!(matches!(
read_plain_line(r.as_raw_fd()),
Err(RustlineError::Eof)
));
}
#[test]
fn plain_line_reading_strips_crlf_and_handles_a_missing_newline() {
use std::io::Write;
use std::os::fd::AsRawFd;
let (r, w) = nix::unistd::pipe().unwrap();
let mut w = std::fs::File::from(w);
w.write_all(b"windows\r\nno-newline").unwrap();
drop(w);
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "windows");
assert_eq!(read_plain_line(r.as_raw_fd()).unwrap(), "no-newline");
}
#[test]
fn readline_falls_back_when_input_is_not_a_terminal() {
use std::io::Write;
use std::os::fd::AsRawFd;
let (r, w) = nix::unistd::pipe().unwrap();
let mut w = std::fs::File::from(w);
w.write_all(b"piped input\n").unwrap();
drop(w);
let mut rl = Rustline::new();
let line = rl
.readline_raw("> ", "", r.as_raw_fd(), std::io::stdout().as_raw_fd())
.unwrap();
assert_eq!(line, "piped input");
}
#[test]
fn xlat_is_installed_and_cleared() {
let mut rl = Rustline::new();
rl.set_xlat(Box::new(|c| c.to_ascii_uppercase()));
assert!(rl.xlat.is_some());
rl.clear_xlat();
assert!(rl.xlat.is_none());
}
}