use std::borrow::Cow;
use std::io;
use std::io::Write;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use colored::*;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, ConditionalEventHandler, Config, Editor, Event, EventContext,
EventHandler, Helper, KeyCode, KeyEvent, Modifiers, RepeatCount,
};
use crate::common::{CTP_BLUE, CTP_OVERLAY0, CTP_PRIMARY, current_directory, show_cursor};
use crate::config;
use crate::slash_commands;
static PREVIEW_LINE_COUNT: AtomicUsize = AtomicUsize::new(0);
const PREVIEW_DESC_COL: usize = 16;
fn format_preview_row(cmd_name: &str, typed_len: usize, description: &str) -> String {
let typed = &cmd_name[..typed_len.min(cmd_name.len())];
let untyped = &cmd_name[typed_len.min(cmd_name.len())..];
let name_display_len = 1 + cmd_name.len(); let pad = PREVIEW_DESC_COL.saturating_sub(name_display_len + 2);
format!(
" {}{}{}{}",
typed.custom_color(CTP_PRIMARY).bold(),
untyped.custom_color(CTP_OVERLAY0),
" ".repeat(pad + 4),
description.custom_color(CTP_OVERLAY0),
)
}
pub fn clear_slash_preview() {
let n = PREVIEW_LINE_COUNT.swap(0, Ordering::Relaxed);
if n == 0 {
return;
}
let mut seq = String::new();
for _ in 0..n {
seq.push_str("\n\x1b[K");
}
seq.push_str(&format!("\x1b[{}A\r", n));
eprint!("{seq}");
let _ = io::stderr().flush();
}
pub fn draw_slash_preview(line: &str) {
if line.contains(' ') {
draw_arg_preview(line);
return;
}
let matches = slash_commands::filter(line);
let prev_count = PREVIEW_LINE_COUNT.load(Ordering::Relaxed);
let new_count = matches.len();
let max_lines = prev_count.max(new_count);
if max_lines == 0 {
return;
}
let typed_len = line.len();
let mut seq = String::new();
for i in 0..max_lines {
seq.push_str("\n\x1b[K"); if let Some(cmd) = matches.get(i) {
let row = format_preview_row(&format!("/{}", cmd.name), typed_len, cmd.description);
seq.push('\r');
seq.push_str(&row);
}
}
seq.push_str(&format!("\x1b[{}A\r", max_lines));
PREVIEW_LINE_COUNT.store(new_count, Ordering::Relaxed);
eprint!("{seq}");
let _ = io::stderr().flush();
}
fn draw_arg_preview(line: &str) {
let prev_count = PREVIEW_LINE_COUNT.load(Ordering::Relaxed);
let Some((start, choices)) = slash_commands::arg_completions(line) else {
clear_slash_preview();
return;
};
let partial_len = line.len() - start;
let new_count = choices.len();
let max_lines = prev_count.max(new_count);
if max_lines == 0 {
return;
}
let mut seq = String::new();
for i in 0..max_lines {
seq.push_str("\n\x1b[K");
if let Some(choice) = choices.get(i) {
seq.push('\r');
seq.push_str(&format_preview_row(
choice.value,
partial_len,
choice.description,
));
}
}
seq.push_str(&format!("\x1b[{}A\r", max_lines));
PREVIEW_LINE_COUNT.store(new_count, Ordering::Relaxed);
eprint!("{seq}");
let _ = io::stderr().flush();
}
pub fn reserve_preview_space() {
let n = slash_commands::COMMANDS.len();
let mut seq = String::new();
for _ in 0..n {
seq.push('\n');
}
seq.push_str(&format!("\x1b[{}A", n));
eprint!("{seq}");
let _ = io::stderr().flush();
}
pub struct NlshHelper;
impl Helper for NlshHelper {}
impl Completer for NlshHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
_pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
if !line.starts_with('/') {
return Ok((0, vec![]));
}
if line.contains(' ') {
if let Some((start, choices)) = slash_commands::arg_completions(line) {
let candidates = choices
.iter()
.map(|c| Pair {
display: c.value.to_string(),
replacement: format!("{} ", c.value),
})
.collect();
return Ok((start, candidates));
}
return Ok((0, vec![]));
}
let matches = slash_commands::filter(line);
let candidates: Vec<Pair> = matches
.iter()
.map(|cmd| {
let name = format!("/{}", cmd.name);
Pair {
display: name.clone(),
replacement: format!("{name} "),
}
})
.collect();
Ok((0, candidates))
}
}
impl Hinter for NlshHelper {
type Hint = String;
}
impl Validator for NlshHelper {}
impl Highlighter for NlshHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
if !line.starts_with('/') {
clear_slash_preview();
return Cow::Borrowed(line);
}
draw_slash_preview(line);
Cow::Owned(line.custom_color(CTP_BLUE).to_string())
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
line.starts_with('/')
}
}
struct SlashPreviewHandler;
impl ConditionalEventHandler for SlashPreviewHandler {
fn handle(
&self,
evt: &Event,
_n: RepeatCount,
_positive: bool,
ctx: &EventContext<'_>,
) -> Option<Cmd> {
let line = ctx.line();
let pos = ctx.pos();
let effective = match evt {
Event::KeySeq(keys) => match keys.first() {
Some(KeyEvent(KeyCode::Char(c), Modifiers::NONE)) => {
let mut s = line.to_string();
s.insert(pos, *c);
s
}
Some(KeyEvent(KeyCode::Backspace, _)) if pos > 0 => {
let char_start = line[..pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
let mut s = line.to_string();
s.replace_range(char_start..pos, "");
s
}
_ => line.to_string(),
},
_ => line.to_string(),
};
if !effective.starts_with('/') {
clear_slash_preview();
}
None
}
}
type NlshEditor = Editor<NlshHelper, DefaultHistory>;
static EDITOR: Mutex<Option<NlshEditor>> = Mutex::new(None);
fn with_editor<F>(readline_fn: F) -> Result<Option<String>, io::Error>
where
F: FnOnce(&mut NlshEditor, &str) -> rustyline::Result<String>,
{
let mut editor_lock = EDITOR.lock().unwrap_or_else(|e| e.into_inner());
let editor = editor_lock.get_or_insert_with(|| {
let mut ed = Editor::<NlshHelper, DefaultHistory>::with_config(
Config::builder()
.completion_type(CompletionType::Circular)
.build(),
)
.unwrap();
ed.set_helper(Some(NlshHelper));
ed.bind_sequence(
Event::Any,
EventHandler::Conditional(Box::new(SlashPreviewHandler)),
);
if config::history_enabled()
&& let Ok(path) = config::history_path()
{
let _ = ed.load_history(&path);
}
ed
});
let cwd = current_directory();
let prompt = format!(
"{}:{}{} ",
"larpshell".custom_color(CTP_BLUE).bold(),
cwd.custom_color(CTP_OVERLAY0).bold(),
">".bold()
);
match readline_fn(editor, &prompt) {
Ok(line) => {
clear_slash_preview();
let trimmed = line.trim();
if !trimmed.is_empty() {
let _ = editor.add_history_entry(&line);
if config::history_enabled()
&& let Ok(path) = config::history_path()
{
let _ = editor.save_history(&path);
}
Ok(Some(trimmed.to_string()))
} else {
Ok(None)
}
}
Err(ReadlineError::Interrupted) => {
clear_slash_preview();
show_cursor();
Err(io::Error::from(io::ErrorKind::Interrupted))
}
Err(ReadlineError::Eof) => {
clear_slash_preview();
show_cursor();
Err(io::Error::from(io::ErrorKind::UnexpectedEof))
}
Err(err) => {
clear_slash_preview();
Err(io::Error::other(err))
}
}
}
pub fn user_input_prefilled(initial: &str) -> Result<Option<String>, io::Error> {
with_editor(|editor, prompt| editor.readline_with_initial(prompt, (initial, "")))
}
pub fn user_input() -> Result<Option<String>, io::Error> {
with_editor(|editor, prompt| editor.readline(prompt))
}
#[cfg(test)]
mod tests {
use super::*;
use rustyline::highlight::{CmdKind, Highlighter};
#[test]
fn highlight_slash_prefix_colors_typed_part() {
colored::control::set_override(true);
let helper = NlshHelper;
let result = helper.highlight("/pr", 3);
assert!(result.contains("\x1b["), "expected ANSI codes in: {result}");
assert!(result.contains("/pr"), "typed part must appear in output");
}
#[test]
fn highlight_non_slash_line_is_unchanged() {
let helper = NlshHelper;
let result = helper.highlight("list files", 10);
assert_eq!(result.as_ref(), "list files");
}
#[test]
fn highlight_char_true_for_slash_line() {
let helper = NlshHelper;
assert!(helper.highlight_char("/api", 4, CmdKind::Other));
}
#[test]
fn highlight_char_false_for_normal_line() {
let helper = NlshHelper;
assert!(!helper.highlight_char("list files", 10, CmdKind::Other));
}
#[test]
fn format_preview_row_pads_to_column() {
colored::control::set_override(false);
let row = format_preview_row_plain("/api", 0, "configure API provider");
assert!(row.contains("configure API provider"), "row: {row}");
let row2 = format_preview_row_plain("/uninstall", 0, "uninstall larpshell");
let desc_pos1 = row.find("configure").unwrap();
let desc_pos2 = row2.find("uninstall larpshell").unwrap();
assert_eq!(desc_pos1, desc_pos2, "descriptions must align");
}
fn format_preview_row_plain(cmd_name: &str, typed_len: usize, description: &str) -> String {
colored::control::set_override(false);
let r = format_preview_row(cmd_name, typed_len, description);
strip_ansi_escapes::strip_str(&r)
}
}