use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Result};
use crate::slash::{slash_command_names, slash_command_takes_path};
pub struct ReplHelper {
filename_completer: FilenameCompleter,
}
impl ReplHelper {
pub fn new() -> Self {
Self {
filename_completer: FilenameCompleter::new(),
}
}
}
impl Default for ReplHelper {
fn default() -> Self {
Self::new()
}
}
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Result<(usize, Vec<Pair>)> {
complete_repl_line(&self.filename_completer, line, pos, ctx)
}
}
fn complete_repl_line(
filename_completer: &FilenameCompleter,
line: &str,
pos: usize,
ctx: &Context<'_>,
) -> Result<(usize, Vec<Pair>)> {
let prefix = &line[..pos];
if let Some(at) = mention_start(prefix) {
let (_, pairs) = filename_completer.complete(line, pos, ctx)?;
return Ok((at + 1, pairs));
}
let Some(rest) = prefix.strip_prefix('/') else {
return Ok((pos, Vec::new()));
};
if !rest.contains(char::is_whitespace) {
let mut candidates: Vec<Pair> = slash_command_names()
.filter(|name| name.starts_with(prefix))
.map(|name| Pair {
display: name.to_string(),
replacement: name.to_string(),
})
.collect();
candidates.sort_by(|a, b| a.display.cmp(&b.display));
return Ok((0, candidates));
}
let cmd_name = prefix.split_whitespace().next().unwrap_or("");
if slash_command_takes_path(cmd_name) {
return filename_completer.complete(line, pos, ctx);
}
Ok((pos, Vec::new()))
}
fn mention_start(prefix: &str) -> Option<usize> {
let at = prefix.rfind('@')?;
if prefix[at..].contains(char::is_whitespace) {
return None;
}
let starts_word = at == 0
|| prefix[..at]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
starts_word.then_some(at)
}
impl Hinter for ReplHelper {
type Hint = String;
}
impl Highlighter for ReplHelper {}
impl Validator for ReplHelper {}
impl rustyline::Helper for ReplHelper {}
#[cfg(test)]
mod tests {
use super::*;
use rustyline::history::DefaultHistory;
fn candidates(line: &str) -> Vec<String> {
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let fc = FilenameCompleter::new();
let pos = line.len();
let (_start, pairs) = complete_repl_line(&fc, line, pos, &ctx).unwrap();
pairs.into_iter().map(|p| p.replacement).collect()
}
#[test]
fn a_mention_completes_as_a_path_and_an_email_address_does_not() {
assert_eq!(mention_start("look at @src/li"), Some(8));
assert_eq!(mention_start("@"), Some(0));
assert_eq!(mention_start("mail me@example.com"), None);
assert_eq!(mention_start("@done and now"), None);
assert_eq!(mention_start("no mention here"), None);
assert!(!candidates("read @Car").is_empty());
}
#[test]
fn slash_alone_lists_every_accepted_spelling() {
let got = candidates("/");
let expected: Vec<String> = {
let mut v: Vec<String> = slash_command_names().map(String::from).collect();
v.sort();
v
};
assert_eq!(got, expected);
assert!(got.contains(&"/help".to_string()));
assert!(got.contains(&"/reduce".to_string()));
}
#[test]
fn partial_command_narrows_to_matching_prefix() {
let got = candidates("/re");
assert!(got.contains(&"/reduce".to_string()), "{got:?}");
assert!(got.contains(&"/reset".to_string()), "{got:?}");
assert!(!got.contains(&"/help".to_string()), "{got:?}");
for c in &got {
assert!(c.starts_with("/re"), "{c} does not start with /re");
}
}
#[test]
fn unambiguous_prefix_yields_single_candidate() {
let got = candidates("/tok");
assert_eq!(got, vec!["/tokens".to_string()]);
}
#[test]
fn unknown_prefix_yields_no_candidates() {
let got = candidates("/zzz");
assert!(got.is_empty(), "{got:?}");
}
#[test]
fn plain_text_is_inert_no_candidates() {
assert!(candidates("hello there").is_empty());
assert!(candidates("what is /help anyway").is_empty());
assert!(candidates("").is_empty());
}
#[test]
fn path_taking_command_delegates_to_filename_completer() {
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let fc = FilenameCompleter::new();
let line = "/add ";
let (start, _pairs) = complete_repl_line(&fc, line, line.len(), &ctx).unwrap();
assert_eq!(start, "/add ".len());
}
#[test]
fn non_path_command_argument_is_inert() {
let got = candidates("/model ");
assert!(got.is_empty(), "{got:?}");
}
}