Extracted readline from the dead prompt module

This commit is contained in:
2026-02-25 20:00:19 -05:00
parent ef64e22be5
commit e82f45f2ea
17 changed files with 13 additions and 35 deletions

470
src/readline/complete.rs Normal file
View File

@@ -0,0 +1,470 @@
use std::{env, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
use crate::{
builtin::BUILTINS,
libsh::error::{ShErr, ShErrKind, ShResult},
parse::lex::{self, LexFlags, Tk, TkFlags},
readline::{
Marker, annotate_input, annotate_input_recursive, get_insertions,
markers::{self, is_marker},
},
state::{read_logic, read_vars},
};
pub enum CompCtx {
CmdName,
FileName,
}
pub enum CompResult {
NoMatch,
Single { result: String },
Many { candidates: Vec<String> },
}
impl CompResult {
pub fn from_candidates(candidates: Vec<String>) -> Self {
if candidates.is_empty() {
Self::NoMatch
} else if candidates.len() == 1 {
Self::Single {
result: candidates[0].clone(),
}
} else {
Self::Many { candidates }
}
}
}
pub struct Completer {
pub candidates: Vec<String>,
pub selected_idx: usize,
pub original_input: String,
pub token_span: (usize, usize),
pub active: bool,
}
impl Completer {
pub fn new() -> Self {
Self {
candidates: vec![],
selected_idx: 0,
original_input: String::new(),
token_span: (0, 0),
active: false,
}
}
pub fn slice_line(line: &str, cursor_pos: usize) -> (&str, &str) {
let (before_cursor, after_cursor) = line.split_at(cursor_pos);
(before_cursor, after_cursor)
}
pub fn get_completion_context(&self, line: &str, cursor_pos: usize) -> (Vec<Marker>, usize) {
let annotated = annotate_input_recursive(line);
let mut ctx = vec![markers::NULL];
let mut last_priority = 0;
let mut ctx_start = 0;
let mut pos = 0;
for ch in annotated.chars() {
match ch {
_ if is_marker(ch) => match ch {
markers::COMMAND | markers::BUILTIN => {
if last_priority < 2 {
if last_priority > 0 {
ctx.pop();
}
ctx_start = pos;
last_priority = 2;
ctx.push(markers::COMMAND);
}
}
markers::VAR_SUB => {
if last_priority < 3 {
if last_priority > 0 {
ctx.pop();
}
ctx_start = pos;
last_priority = 3;
ctx.push(markers::VAR_SUB);
}
}
markers::ARG | markers::ASSIGNMENT => {
if last_priority < 1 {
ctx_start = pos;
ctx.push(markers::ARG);
}
}
_ => {}
},
_ => {
last_priority = 0; // reset priority on normal characters
pos += 1; // we hit a normal character, advance our position
if pos >= cursor_pos {
break;
}
}
}
}
(ctx, ctx_start)
}
pub fn reset(&mut self) {
self.candidates.clear();
self.selected_idx = 0;
self.original_input.clear();
self.token_span = (0, 0);
self.active = false;
}
pub fn complete(
&mut self,
line: String,
cursor_pos: usize,
direction: i32,
) -> ShResult<Option<String>> {
if self.active {
Ok(Some(self.cycle_completion(direction)))
} else {
self.start_completion(line, cursor_pos)
}
}
pub fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned()
}
pub fn cycle_completion(&mut self, direction: i32) -> String {
if self.candidates.is_empty() {
return self.original_input.clone();
}
let len = self.candidates.len();
self.selected_idx = (self.selected_idx as i32 + direction).rem_euclid(len as i32) as usize;
self.get_completed_line()
}
pub fn start_completion(&mut self, line: String, cursor_pos: usize) -> ShResult<Option<String>> {
let result = self.get_candidates(line.clone(), cursor_pos)?;
match result {
CompResult::Many { candidates } => {
self.candidates = candidates.clone();
self.selected_idx = 0;
self.original_input = line;
self.active = true;
Ok(Some(self.get_completed_line()))
}
CompResult::Single { result } => {
self.candidates = vec![result.clone()];
self.selected_idx = 0;
self.original_input = line;
self.active = false;
Ok(Some(self.get_completed_line()))
}
CompResult::NoMatch => Ok(None),
}
}
pub fn extract_var_name(text: &str) -> Option<(String, usize, usize)> {
let mut chars = text.chars().peekable();
let mut name = String::new();
let mut reading_name = false;
let mut pos = 0;
let mut name_start = 0;
let mut name_end = 0;
while let Some(ch) = chars.next() {
match ch {
'$' => {
if chars.peek() == Some(&'{') {
continue;
}
reading_name = true;
name_start = pos + 1; // Start after the '$'
}
'{' if !reading_name => {
reading_name = true;
name_start = pos + 1;
}
ch if ch.is_alphanumeric() || ch == '_' => {
if reading_name {
name.push(ch);
}
}
_ => {
if reading_name {
name_end = pos; // End before the non-alphanumeric character
break;
}
}
}
pos += 1;
}
if !reading_name {
return None;
}
if name_end == 0 {
name_end = pos;
}
Some((name, name_start, name_end))
}
pub fn get_completed_line(&self) -> String {
if self.candidates.is_empty() {
return self.original_input.clone();
}
let selected = &self.candidates[self.selected_idx];
let (start, end) = self.token_span;
format!(
"{}{}{}",
&self.original_input[..start],
selected,
&self.original_input[end..]
)
}
pub fn get_candidates(&mut self, line: String, cursor_pos: usize) -> ShResult<CompResult> {
let source = Arc::new(line.clone());
let tokens =
lex::LexStream::new(source, LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>()?;
let Some(mut cur_token) = tokens.into_iter().find(|tk| {
let start = tk.span.start;
let end = tk.span.end;
(start..=end).contains(&cursor_pos)
}) else {
let candidates = Self::complete_filename("./"); // Default to filename completion if no token is found
let end_pos = line.len();
self.token_span = (end_pos, end_pos);
return Ok(CompResult::from_candidates(candidates));
};
self.token_span = (cur_token.span.start, cur_token.span.end);
// Look for marker at the START of what we're completing, not at cursor
let (mut ctx, token_start) = self.get_completion_context(&line, cursor_pos);
self.token_span.0 = token_start; // Update start of token span based on context
cur_token
.span
.set_range(self.token_span.0..self.token_span.1); // Update token span to reflect context
// If token contains '=', only complete after the '='
let token_str = cur_token.span.as_str();
if let Some(eq_pos) = token_str.rfind('=') {
// Adjust span to only replace the part after '='
self.token_span.0 = cur_token.span.start + eq_pos + 1;
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
}
if ctx.last().is_some_and(|m| *m == markers::VAR_SUB) {
let var_sub = &cur_token.as_str();
if let Some((var_name, start, end)) = Self::extract_var_name(var_sub) {
if read_vars(|v| v.get_var(&var_name)).is_empty() {
// if we are here, we have a variable substitution that isn't complete
// so let's try to complete it
let ret: ShResult<CompResult> = read_vars(|v| {
let var_matches = v
.flatten_vars()
.keys()
.filter(|k| k.starts_with(&var_name) && *k != &var_name)
.map(|k| k.to_string())
.collect::<Vec<_>>();
if !var_matches.is_empty() {
let name_start = cur_token.span.start + start;
let name_end = cur_token.span.start + end;
self.token_span = (name_start, name_end);
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
Ok(CompResult::from_candidates(var_matches))
} else {
Ok(CompResult::NoMatch)
}
});
if !matches!(ret, Ok(CompResult::NoMatch)) {
return ret;
} else {
ctx.pop();
}
} else {
ctx.pop();
}
}
}
let raw_tk = cur_token.as_str().to_string();
let expanded_tk = cur_token.expand()?;
let expanded_words = expanded_tk.get_words().into_iter().collect::<Vec<_>>();
let expanded = expanded_words.join("\\ ");
let mut candidates = match ctx.pop() {
Some(markers::COMMAND) => Self::complete_command(&expanded)?,
Some(markers::ARG) => Self::complete_filename(&expanded),
Some(_) => {
return Ok(CompResult::NoMatch);
}
None => {
return Ok(CompResult::NoMatch);
}
};
// Now we are just going to graft the completed text
// onto the original token. This prevents something like
// $SOME_PATH/
// from being completed into
// /path/to/some_path/file.txt
// and instead returns
// $SOME_PATH/file.txt
candidates = candidates
.into_iter()
.map(|c| match c.strip_prefix(&expanded) {
Some(suffix) => format!("{raw_tk}{suffix}"),
None => c,
})
.collect();
let limit = crate::state::read_shopts(|s| s.prompt.comp_limit);
candidates.truncate(limit);
Ok(CompResult::from_candidates(candidates))
}
fn complete_command(start: &str) -> ShResult<Vec<String>> {
let mut candidates = vec![];
let path = env::var("PATH").unwrap_or_default();
let paths = path.split(':').map(PathBuf::from).collect::<Vec<_>>();
for path in paths {
// Skip directories that don't exist (common in PATH)
let Ok(entries) = std::fs::read_dir(path) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let Ok(meta) = entry.metadata() else {
continue;
};
let file_name = entry.file_name().to_string_lossy().to_string();
if meta.is_file()
&& (meta.permissions().mode() & 0o111) != 0
&& file_name.starts_with(start)
{
candidates.push(file_name);
}
}
}
let builtin_candidates = BUILTINS
.iter()
.filter(|b| b.starts_with(start))
.map(|s| s.to_string());
candidates.extend(builtin_candidates);
read_logic(|l| {
let func_table = l.funcs();
let matches = func_table
.keys()
.filter(|k| k.starts_with(start))
.map(|k| k.to_string());
candidates.extend(matches);
let aliases = l.aliases();
let matches = aliases
.keys()
.filter(|k| k.starts_with(start))
.map(|k| k.to_string());
candidates.extend(matches);
});
// Deduplicate (same command may appear in multiple PATH dirs)
candidates.sort();
candidates.dedup();
Ok(candidates)
}
fn complete_filename(start: &str) -> Vec<String> {
let mut candidates = vec![];
let has_dotslash = start.starts_with("./");
// Split path into directory and filename parts
// Use "." if start is empty (e.g., after "foo=")
let path = PathBuf::from(if start.is_empty() { "." } else { start });
let (dir, prefix) = if start.ends_with('/') || start.is_empty() {
// Completing inside a directory: "src/" → dir="src/", prefix=""
(path, "")
} else if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
// Has directory component: "src/ma" → dir="src", prefix="ma"
(
parent.to_path_buf(),
path.file_name().unwrap().to_str().unwrap_or(""),
)
} else {
// No directory: "fil" → dir=".", prefix="fil"
(PathBuf::from("."), start)
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return candidates;
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_str = file_name.to_string_lossy();
// Skip hidden files unless explicitly requested
if !prefix.starts_with('.') && file_str.starts_with('.') {
continue;
}
if file_str.starts_with(prefix) {
// Reconstruct full path
let mut full_path = dir.join(&file_name);
// Add trailing slash for directories
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
full_path.push(""); // adds trailing /
}
let mut path_raw = full_path.to_string_lossy().to_string();
if path_raw.starts_with("./") && !has_dotslash {
path_raw = path_raw.trim_start_matches("./").to_string();
}
candidates.push(path_raw);
}
}
candidates.sort();
candidates
}
}
impl Default for Completer {
fn default() -> Self {
Self::new()
}
}

436
src/readline/highlight.rs Normal file
View File

@@ -0,0 +1,436 @@
use std::{
env,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
};
use crate::{
libsh::term::{Style, StyleSet, Styled},
readline::{annotate_input, markers::{self, is_marker}},
state::{read_logic, read_meta, read_shopts},
};
/// Syntax highlighter for shell input using Unicode marker-based annotation
///
/// The highlighter processes annotated input strings containing invisible
/// Unicode markers (U+FDD0-U+FDEF range) that indicate syntax elements. It
/// generates ANSI escape codes for terminal display while maintaining a style
/// stack for proper color restoration in nested constructs (e.g., variables
/// inside strings inside command substitutions).
pub struct Highlighter {
input: String,
output: String,
linebuf_cursor_pos: usize,
style_stack: Vec<StyleSet>,
last_was_reset: bool,
in_selection: bool
}
impl Highlighter {
/// Creates a new highlighter with empty buffers and reset state
pub fn new() -> Self {
Self {
input: String::new(),
output: String::new(),
linebuf_cursor_pos: 0,
style_stack: Vec::new(),
last_was_reset: true, // start as true so we don't emit a leading reset
in_selection: false
}
}
/// Loads raw input text and annotates it with syntax markers
///
/// The input is passed through the annotator which inserts Unicode markers
/// indicating token types and sub-token constructs (strings, variables, etc.)
pub fn load_input(&mut self, input: &str, linebuf_cursor_pos: usize) {
let input = annotate_input(input);
self.input = input;
self.linebuf_cursor_pos = linebuf_cursor_pos;
}
pub fn strip_markers(str: &str) -> String {
let mut out = String::new();
for ch in str.chars() {
if !is_marker(ch) {
out.push(ch);
}
}
out
}
/// Processes the annotated input and generates ANSI-styled output
///
/// Walks through the input character by character, interpreting markers and
/// applying appropriate styles. Nested constructs (command substitutions,
/// subshells, strings) are handled recursively with proper style restoration.
pub fn highlight(&mut self) {
let input = self.input.clone();
let mut input_chars = input.chars().peekable();
while let Some(ch) = input_chars.next() {
match ch {
markers::VISUAL_MODE_START => {
self.emit_style(Style::BgWhite | Style::Black);
self.in_selection = true;
}
markers::VISUAL_MODE_END => {
self.reapply_style();
self.in_selection = false;
}
markers::STRING_DQ_END
| markers::STRING_SQ_END
| markers::VAR_SUB_END
| markers::CMD_SUB_END
| markers::PROC_SUB_END
| markers::SUBSH_END => self.pop_style(),
markers::CMD_SEP | markers::RESET => self.clear_styles(),
markers::STRING_DQ | markers::STRING_SQ | markers::KEYWORD => {
self.push_style(Style::Yellow)
}
markers::BUILTIN => {
let mut cmd_name = String::new();
let mut chars_clone = input_chars.clone();
while let Some(ch) = chars_clone.next() {
if ch == markers::RESET {
break;
}
if !is_marker(ch) {
cmd_name.push(ch);
}
}
match cmd_name.as_str() {
"continue" | "return" | "break" => self.push_style(Style::Magenta),
_ => self.push_style(Style::Green),
}
}
markers::CASE_PAT => self.push_style(Style::Blue),
markers::COMMENT => self.push_style(Style::BrightBlack),
markers::GLOB => self.push_style(Style::Blue),
markers::REDIRECT | markers::OPERATOR => self.push_style(Style::Magenta | Style::Bold),
markers::ASSIGNMENT => {
let mut var_name = String::new();
while let Some(ch) = input_chars.peek() {
if ch == &'=' {
input_chars.next(); // consume the '='
break;
}
match *ch {
markers::RESET => break,
_ => {
var_name.push(*ch);
input_chars.next();
}
}
}
self.output.push_str(&var_name);
self.push_style(Style::Blue);
self.output.push('=');
self.pop_style();
}
markers::ARG => {
let mut arg = String::new();
let is_last_arg = !input_chars.clone().any(|c| c == markers::ARG || c.is_whitespace());
if !is_last_arg {
self.push_style(Style::White);
} else {
let mut chars_clone = input_chars.clone();
while let Some(ch) = chars_clone.next() {
if ch == markers::RESET {
break;
}
arg.push(ch);
}
let style = if Self::is_filename(&Self::strip_markers(&arg)) {
Style::White | Style::Underline
} else {
Style::White.into()
};
self.push_style(style);
self.last_was_reset = false;
}
}
markers::COMMAND => {
let mut cmd_name = String::new();
let mut chars_clone = input_chars.clone();
while let Some(ch) = chars_clone.next() {
if ch == markers::RESET {
break;
}
cmd_name.push(ch);
}
let style = if matches!(Self::strip_markers(&cmd_name).as_str(), "break" | "continue" | "return") {
Style::Magenta.into()
} else if Self::is_valid(&Self::strip_markers(&cmd_name)) {
Style::Green.into()
} else {
Style::Red | Style::Bold
};
self.push_style(style);
self.last_was_reset = false;
}
markers::CMD_SUB | markers::SUBSH | markers::PROC_SUB => {
let mut inner = String::new();
let mut incomplete = true;
let end_marker = match ch {
markers::CMD_SUB => markers::CMD_SUB_END,
markers::SUBSH => markers::SUBSH_END,
markers::PROC_SUB => markers::PROC_SUB_END,
_ => unreachable!(),
};
while let Some(ch) = input_chars.peek() {
if *ch == end_marker {
incomplete = false;
input_chars.next(); // consume the end marker
break;
}
inner.push(*ch);
input_chars.next();
}
let inner_clean = Self::strip_markers(&inner);
// Determine prefix from content (handles both <( and >( for proc subs)
let prefix = match ch {
markers::CMD_SUB => "$(",
markers::SUBSH => "(",
markers::PROC_SUB => {
if inner_clean.starts_with("<(") {
"<("
} else if inner_clean.starts_with(">(") {
">("
} else {
"<("
} // fallback
}
_ => unreachable!(),
};
let inner_content = if incomplete {
inner_clean.strip_prefix(prefix).unwrap_or(&inner_clean)
} else {
inner_clean
.strip_prefix(prefix)
.and_then(|s| s.strip_suffix(")"))
.unwrap_or(&inner_clean)
};
let mut recursive_highlighter = Self::new();
recursive_highlighter.load_input(inner_content, self.linebuf_cursor_pos);
recursive_highlighter.highlight();
self.push_style(Style::Blue);
self.output.push_str(prefix);
self.pop_style();
self.output.push_str(&recursive_highlighter.take());
if !incomplete {
self.push_style(Style::Blue);
self.output.push(')');
self.pop_style();
}
self.last_was_reset = false;
}
markers::VAR_SUB => {
let mut var_sub = String::new();
while let Some(ch) = input_chars.peek() {
if *ch == markers::VAR_SUB_END {
input_chars.next(); // consume the end marker
break;
} else if markers::is_marker(*ch) {
input_chars.next(); // skip the marker
continue;
}
var_sub.push(*ch);
input_chars.next();
}
let style = Style::Cyan;
self.push_style(style);
self.output.push_str(&var_sub);
self.pop_style();
}
_ => {
if markers::is_marker(ch) {
} else {
self.output.push(ch);
self.last_was_reset = false;
}
}
}
}
}
/// Extracts the highlighted output and resets the highlighter state
///
/// Clears the input buffer, style stack, and returns the generated output
/// containing ANSI escape codes. The highlighter is ready for reuse after
/// this.
pub fn take(&mut self) -> String {
self.input.clear();
self.clear_styles();
std::mem::take(&mut self.output)
}
/// Checks if a command name is valid (exists in PATH, is a function, or is an
/// alias)
///
/// Searches:
/// 1. Current directory if command is a path
/// 2. All directories in PATH environment variable
/// 3. Shell functions and aliases in the current shell state
fn is_valid(command: &str) -> bool {
let cmd_path = Path::new(&command);
if cmd_path.is_absolute() {
// the user has given us an absolute path
if cmd_path.is_dir() && read_shopts(|o| o.core.autocd) {
// this is a directory and autocd is enabled
true
} else {
let Ok(meta) = cmd_path.metadata() else {
return false;
};
// this is a file that is executable by someone
meta.permissions().mode() & 0o111 != 0
}
} else {
read_meta(|m| m.cached_cmds().get(command).is_some())
}
}
fn is_filename(arg: &str) -> bool {
let path = Path::new(arg);
if path.is_absolute() && path.exists() {
return true;
}
if path.is_absolute()
&& let Some(parent_dir) = path.parent()
&& let Ok(entries) = parent_dir.read_dir() {
let files = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>();
let Some(arg_filename) = PathBuf::from(arg)
.file_name()
.map(|s| s.to_string_lossy().to_string())
else {
return false;
};
for file in files {
if file.starts_with(&arg_filename) {
return true;
}
}
}
read_meta(|m| {
let files = m.cwd_cache();
for file in files {
if file.starts_with(arg) {
return true;
}
}
false
})
}
/// Emits a reset ANSI code to the output, with deduplication
///
/// Only emits the reset if the last emitted code was not already a reset,
/// preventing redundant `\x1b[0m` sequences in the output.
fn emit_reset(&mut self) {
if !self.last_was_reset {
self.output.push_str(&Style::Reset.to_string());
self.last_was_reset = true;
}
}
/// Emits a style ANSI code to the output
///
/// Unconditionally appends the ANSI escape sequence for the given style
/// and marks that we're no longer in a reset state.
fn emit_style(&mut self, style: StyleSet) {
let mut style = style;
if !style.styles().contains(&Style::BgWhite) {
style = style.add_style(Style::BgBlack);
}
self.output.push_str(&style.to_string());
self.last_was_reset = false;
}
/// Pushes a new style onto the stack and emits its ANSI code
///
/// Used when entering a new syntax context (string, variable, command, etc.).
/// The style stack allows proper restoration when exiting nested constructs.
pub fn push_style(&mut self, style: impl Into<StyleSet>) {
let set: StyleSet = style.into();
self.style_stack.push(set.clone());
if !self.in_selection {
self.emit_style(set.clone());
}
}
/// Pops a style from the stack and restores the previous style
///
/// Used when exiting a syntax context. If there's a parent style on the
/// stack, it's re-emitted to restore the previous color. Otherwise, emits a
/// reset. This ensures colors are properly restored in nested constructs
/// like `"string with $VAR"` where the string color resumes after the
/// variable.
pub fn pop_style(&mut self) {
self.style_stack.pop();
if let Some(style) = self.style_stack.last().cloned() {
self.emit_style(style);
} else {
self.emit_reset();
}
}
/// Clears all styles from the stack and emits a reset
///
/// Used at command separators and explicit reset markers to return to
/// the default terminal color between independent commands.
pub fn clear_styles(&mut self) {
self.style_stack.clear();
if !self.in_selection {
self.emit_reset();
}
}
pub fn reapply_style(&mut self) {
if let Some(style) = self.style_stack.last().cloned() {
self.emit_style(style);
} else {
self.emit_reset();
}
}
/// Simple marker-to-ANSI replacement (unused in favor of stack-based
/// highlighting)
///
/// Performs direct string replacement of markers with ANSI codes, without
/// handling nesting or proper color restoration. Kept for reference but not
/// used in the current implementation.
pub fn trivial_replace(&mut self) {
self.input = self
.input
.replace([markers::RESET, markers::ARG], "\x1b[0m")
.replace(markers::KEYWORD, "\x1b[33m")
.replace(markers::CASE_PAT, "\x1b[34m")
.replace(markers::COMMENT, "\x1b[90m")
.replace(markers::OPERATOR, "\x1b[35m");
}
}

425
src/readline/history.rs Normal file
View File

@@ -0,0 +1,425 @@
use std::{
collections::HashSet,
env,
fmt::{Display, Write},
fs::{self, OpenOptions},
io::Write as IoWrite,
path::{Path, PathBuf},
str::FromStr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::prelude::*;
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
readline::linebuf::LineBuf,
};
use super::vicmd::Direction; // surprisingly useful
#[derive(Default, Clone, Copy, Debug)]
pub enum SearchKind {
Fuzzy,
#[default]
Prefix,
}
#[derive(Default, Clone, Debug)]
pub struct SearchConstraint {
kind: SearchKind,
term: String,
}
impl SearchConstraint {
pub fn new(kind: SearchKind, term: String) -> Self {
Self { kind, term }
}
}
#[derive(Debug, Clone)]
pub struct HistEntry {
id: u32,
timestamp: SystemTime,
command: String,
new: bool,
}
impl HistEntry {
pub fn id(&self) -> u32 {
self.id
}
pub fn timestamp(&self) -> &SystemTime {
&self.timestamp
}
pub fn command(&self) -> &str {
&self.command
}
fn with_escaped_newlines(&self) -> String {
let mut escaped = String::new();
for ch in self.command.chars() {
match ch {
'\\' => escaped.push_str("\\\\"), // escape all backslashes
'\n' => escaped.push_str("\\\n"), // line continuation
_ => escaped.push(ch),
}
}
escaped
}
pub fn is_new(&self) -> bool {
self.new
}
}
impl FromStr for HistEntry {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = Err(ShErr::Simple {
kind: ShErrKind::HistoryReadErr,
msg: format!("Bad formatting on history entry '{s}'"),
notes: vec![],
});
//: 248972349;148;echo foo; echo bar
let Some(cleaned) = s.strip_prefix(": ") else {
return err;
};
//248972349;148;echo foo; echo bar
let Some((timestamp, id_and_command)) = cleaned.split_once(';') else {
return err;
};
//("248972349","148;echo foo; echo bar")
let Some((id, command)) = id_and_command.split_once(';') else {
return err;
};
//("148","echo foo; echo bar")
let Ok(ts_seconds) = timestamp.parse::<u64>() else {
return err;
};
let Ok(id) = id.parse::<u32>() else {
return err;
};
let timestamp = UNIX_EPOCH + Duration::from_secs(ts_seconds);
let command = command.to_string();
Ok(Self {
id,
timestamp,
command,
new: false,
})
}
}
impl Display for HistEntry {
/// Similar to zsh's history format, but not entirely
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let command = self.with_escaped_newlines();
let HistEntry {
id,
timestamp,
command: _,
new: _,
} = self;
let timestamp = timestamp.duration_since(UNIX_EPOCH).unwrap().as_secs();
writeln!(f, ": {timestamp};{id};{command}")
}
}
pub struct HistEntries(Vec<HistEntry>);
impl FromStr for HistEntries {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut entries = vec![];
let mut lines = s.lines().enumerate().peekable();
let mut cur_line = String::new();
while let Some((i, line)) = lines.next() {
if !line.starts_with(": ") {
return Err(ShErr::Simple {
kind: ShErrKind::HistoryReadErr,
msg: format!("Bad formatting on line {i}"),
notes: vec![],
});
}
let mut chars = line.chars().peekable();
let mut feeding_lines = true;
while feeding_lines {
feeding_lines = false;
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(esc_ch) = chars.next() {
// Unescape: \\ -> \, \n stays as literal n after backslash was written as \\n
cur_line.push(esc_ch);
} else {
// Trailing backslash = line continuation in history file format
cur_line.push('\n');
feeding_lines = true;
}
}
'\n' => break,
_ => {
cur_line.push(ch);
}
}
}
if feeding_lines {
let Some((_, line)) = lines.next() else {
return Err(ShErr::Simple {
kind: ShErrKind::HistoryReadErr,
msg: format!("Bad formatting on line {i}"),
notes: vec![],
});
};
chars = line.chars().peekable();
}
}
let entry = cur_line.parse::<HistEntry>()?;
entries.push(entry);
cur_line.clear();
}
Ok(Self(entries))
}
}
fn read_hist_file(path: &Path) -> ShResult<Vec<HistEntry>> {
if !path.exists() {
fs::File::create(path)?;
}
let raw = fs::read_to_string(path)?;
Ok(raw.parse::<HistEntries>()?.0)
}
/// Deduplicate entries, keeping only the most recent occurrence of each
/// command. Preserves chronological order (oldest to newest).
fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
let mut seen = HashSet::new();
// Iterate backwards (newest first), keeping first occurrence of each command
entries
.iter()
.rev()
.filter(|ent| seen.insert(ent.command.clone()))
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev() // Restore chronological order
.collect()
}
pub struct History {
path: PathBuf,
pub pending: Option<LineBuf>, // command, cursor_pos
entries: Vec<HistEntry>,
search_mask: Vec<HistEntry>,
no_matches: bool,
pub cursor: usize,
search_direction: Direction,
ignore_dups: bool,
max_size: Option<u32>,
}
impl History {
pub fn new() -> ShResult<Self> {
let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes);
let max_hist = crate::state::read_shopts(|s| s.core.max_hist);
let path = PathBuf::from(env::var("FERNHIST").unwrap_or({
let home = env::var("HOME").unwrap();
format!("{home}/.shed_history")
}));
let mut entries = read_hist_file(&path)?;
// Enforce max_hist limit on loaded entries
if entries.len() > max_hist {
entries = entries.split_off(entries.len() - max_hist);
}
let search_mask = dedupe_entries(&entries);
let cursor = search_mask.len();
Ok(Self {
path,
entries,
pending: None,
search_mask,
no_matches: false,
cursor,
search_direction: Direction::Backward,
ignore_dups,
max_size: Some(max_hist as u32),
})
}
pub fn reset(&mut self) {
self.search_mask = dedupe_entries(&self.entries);
self.cursor = self.search_mask.len();
}
pub fn entries(&self) -> &[HistEntry] {
&self.entries
}
pub fn masked_entries(&self) -> &[HistEntry] {
&self.search_mask
}
pub fn cursor_entry(&self) -> Option<&HistEntry> {
self.search_mask.get(self.cursor)
}
pub fn at_pending(&self) -> bool {
self.cursor >= self.search_mask.len()
}
pub fn reset_to_pending(&mut self) {
self.cursor = self.search_mask.len();
}
pub fn update_pending_cmd(&mut self, buf: (&str, usize)) {
let cursor_pos = if let Some(pending) = &self.pending {
pending.cursor.get()
} else {
buf.1
};
let cmd = buf.0.to_string();
let constraint = SearchConstraint {
kind: SearchKind::Prefix,
term: cmd.clone(),
};
if let Some(pending) = &mut self.pending {
pending.set_buffer(cmd);
pending.cursor.set(cursor_pos);
} else {
self.pending = Some(LineBuf::new().with_initial(&cmd, cursor_pos));
}
self.constrain_entries(constraint);
}
pub fn last_mut(&mut self) -> Option<&mut HistEntry> {
self.entries.last_mut()
}
pub fn get_new_id(&self) -> u32 {
let Some(ent) = self.entries.last() else {
return 0;
};
ent.id + 1
}
pub fn ignore_dups(&mut self, yn: bool) {
self.ignore_dups = yn
}
pub fn max_hist_size(&mut self, size: Option<u32>) {
self.max_size = size
}
pub fn constrain_entries(&mut self, constraint: SearchConstraint) {
let SearchConstraint { kind, term } = constraint;
match kind {
SearchKind::Prefix => {
if term.is_empty() {
self.search_mask = dedupe_entries(&self.entries);
} else {
let filtered: Vec<_> = self
.entries
.iter()
.filter(|ent| ent.command().starts_with(&term))
.cloned()
.collect();
self.search_mask = dedupe_entries(&filtered);
self.no_matches = self.search_mask.is_empty();
if self.no_matches {
// If no matches, reset to full history so user can still scroll through it
self.search_mask = dedupe_entries(&self.entries);
}
}
self.cursor = self.search_mask.len();
}
SearchKind::Fuzzy => todo!(),
}
}
pub fn hint_entry(&self) -> Option<&HistEntry> {
if self.no_matches {
return None;
};
self.search_mask.last()
}
pub fn get_hint(&self) -> Option<String> {
if self.at_pending() && self.pending.as_ref().is_some_and(|p| !p.buffer.is_empty()) {
let entry = self.hint_entry()?;
Some(entry.command().to_string())
} else {
None
}
}
pub fn scroll(&mut self, offset: isize) -> Option<&HistEntry> {
self.cursor = self
.cursor
.saturating_add_signed(offset)
.clamp(0, self.search_mask.len());
self.search_mask.get(self.cursor)
}
pub fn push(&mut self, command: String) {
let timestamp = SystemTime::now();
let id = self.get_new_id();
if self.ignore_dups && self.is_dup(&command) {
return;
}
self.entries.push(HistEntry {
id,
timestamp,
command,
new: true,
});
}
pub fn is_dup(&self, other: &str) -> bool {
let Some(ent) = self.entries.last() else {
return false;
};
let ent_cmd = &ent.command;
ent_cmd == other
}
pub fn save(&mut self) -> ShResult<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let last_file_entry = self
.entries
.iter()
.rfind(|ent| !ent.new)
.map(|ent| ent.command.clone())
.unwrap_or_default();
let entries = self.entries.iter_mut().filter(|ent| {
ent.new
&& !ent.command.is_empty()
&& if self.ignore_dups {
ent.command() != last_file_entry
} else {
true
}
});
let mut data = String::new();
for ent in entries {
ent.new = false;
write!(data, "{ent}").unwrap();
}
file.write_all(data.as_bytes())?;
self.pending = None;
self.reset();
Ok(())
}
}

139
src/readline/keys.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
// Credit to Rustyline for the design ideas in this module
// https://github.com/kkawakam/rustyline
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct KeyEvent(pub KeyCode, pub ModKeys);
impl KeyEvent {
pub fn new(ch: &str, mut mods: ModKeys) -> Self {
use {KeyCode as K, KeyEvent as E, ModKeys as M};
let mut graphemes = ch.graphemes(true);
let first = match graphemes.next() {
Some(g) => g,
None => return E(K::Null, mods),
};
// If more than one grapheme, it's not a single key event
if graphemes.next().is_some() {
return E(K::Null, mods); // Or panic, or wrap in Grapheme if desired
}
let mut chars = first.chars();
let single_char = chars.next();
let is_single_char = chars.next().is_none();
match single_char {
Some(c) if is_single_char && c.is_control() => match c {
'\x00' => E(K::Char('@'), mods | M::CTRL),
'\x01' => E(K::Char('A'), mods | M::CTRL),
'\x02' => E(K::Char('B'), mods | M::CTRL),
'\x03' => E(K::Char('C'), mods | M::CTRL),
'\x04' => E(K::Char('D'), mods | M::CTRL),
'\x05' => E(K::Char('E'), mods | M::CTRL),
'\x06' => E(K::Char('F'), mods | M::CTRL),
'\x07' => E(K::Char('G'), mods | M::CTRL),
'\x08' => E(K::Backspace, mods),
'\x09' => {
if mods.contains(M::SHIFT) {
mods.remove(M::SHIFT);
E(K::BackTab, mods)
} else {
E(K::Tab, mods)
}
}
'\x0a' => E(K::Char('J'), mods | M::CTRL),
'\x0b' => E(K::Char('K'), mods | M::CTRL),
'\x0c' => E(K::Char('L'), mods | M::CTRL),
'\x0d' => E(K::Enter, mods),
'\x0e' => E(K::Char('N'), mods | M::CTRL),
'\x0f' => E(K::Char('O'), mods | M::CTRL),
'\x10' => E(K::Char('P'), mods | M::CTRL),
'\x11' => E(K::Char('Q'), mods | M::CTRL),
'\x12' => E(K::Char('R'), mods | M::CTRL),
'\x13' => E(K::Char('S'), mods | M::CTRL),
'\x14' => E(K::Char('T'), mods | M::CTRL),
'\x15' => E(K::Char('U'), mods | M::CTRL),
'\x16' => E(K::Char('V'), mods | M::CTRL),
'\x17' => E(K::Char('W'), mods | M::CTRL),
'\x18' => E(K::Char('X'), mods | M::CTRL),
'\x19' => E(K::Char('Y'), mods | M::CTRL),
'\x1a' => E(K::Char('Z'), mods | M::CTRL),
'\x1b' => E(K::Esc, mods),
'\x1c' => E(K::Char('\\'), mods | M::CTRL),
'\x1d' => E(K::Char(']'), mods | M::CTRL),
'\x1e' => E(K::Char('^'), mods | M::CTRL),
'\x1f' => E(K::Char('_'), mods | M::CTRL),
'\x7f' => E(K::Backspace, mods),
'\u{9b}' => E(K::Esc, mods | M::SHIFT),
_ => E(K::Null, mods),
},
Some(c) if is_single_char => {
if !mods.is_empty() {
mods.remove(M::SHIFT);
}
E(K::Char(c), mods)
}
_ => {
// multi-char grapheme (emoji, accented, etc)
if !mods.is_empty() {
mods.remove(M::SHIFT);
}
E(K::Grapheme(Arc::from(first)), mods)
}
}
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum KeyCode {
UnknownEscSeq,
Backspace,
BackTab,
BracketedPasteStart,
BracketedPasteEnd,
Char(char),
Grapheme(Arc<str>),
Delete,
Down,
End,
Enter,
Esc,
F(u8),
Home,
Insert,
Left,
Null,
PageDown,
PageUp,
Right,
Tab,
Up,
}
bitflags::bitflags! {
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ModKeys: u8 {
/// Control modifier
const CTRL = 1<<3;
/// Escape or Alt modifier
const ALT = 1<<2;
/// Shift modifier
const SHIFT = 1<<1;
/// No modifier
const NONE = 0;
/// Ctrl + Shift
const CTRL_SHIFT = Self::CTRL.bits() | Self::SHIFT.bits();
/// Alt + Shift
const ALT_SHIFT = Self::ALT.bits() | Self::SHIFT.bits();
/// Ctrl + Alt
const CTRL_ALT = Self::CTRL.bits() | Self::ALT.bits();
/// Ctrl + Alt + Shift
const CTRL_ALT_SHIFT = Self::CTRL.bits() | Self::ALT.bits() | Self::SHIFT.bits();
}
}

1
src/readline/layout.rs Normal file
View File

@@ -0,0 +1 @@

3084
src/readline/linebuf.rs Normal file

File diff suppressed because it is too large Load Diff

1155
src/readline/mod.rs Normal file

File diff suppressed because it is too large Load Diff

251
src/readline/register.rs Normal file
View File

@@ -0,0 +1,251 @@
use std::{fmt::Display, sync::Mutex};
pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new());
pub fn read_register(ch: Option<char>) -> Option<RegisterContent> {
let lock = REGISTERS.lock().unwrap();
lock.get_reg(ch).map(|r| r.content().clone())
}
pub fn write_register(ch: Option<char>, buf: RegisterContent) {
let mut lock = REGISTERS.lock().unwrap();
if let Some(r) = lock.get_reg_mut(ch) {
r.write(buf)
}
}
pub fn append_register(ch: Option<char>, buf: RegisterContent) {
let mut lock = REGISTERS.lock().unwrap();
if let Some(r) = lock.get_reg_mut(ch) {
r.append(buf)
}
}
#[derive(Default, Clone, Debug)]
pub enum RegisterContent {
Span(String),
Line(String),
#[default]
Empty,
}
impl Display for RegisterContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Span(s) => write!(f, "{}", s),
Self::Line(s) => write!(f, "{}", s),
Self::Empty => write!(f, ""),
}
}
}
impl RegisterContent {
pub fn clear(&mut self) {
match self {
Self::Span(s) => s.clear(),
Self::Line(s) => s.clear(),
Self::Empty => {}
}
}
pub fn len(&self) -> usize {
match self {
Self::Span(s) => s.len(),
Self::Line(s) => s.len(),
Self::Empty => 0,
}
}
pub fn is_empty(&self) -> bool {
match self {
Self::Span(s) => s.is_empty(),
Self::Line(s) => s.is_empty(),
Self::Empty => true,
}
}
pub fn is_line(&self) -> bool {
matches!(self, Self::Line(_))
}
pub fn is_span(&self) -> bool {
matches!(self, Self::Span(_))
}
pub fn as_str(&self) -> &str {
match self {
Self::Span(s) => s,
Self::Line(s) => s,
Self::Empty => "",
}
}
pub fn char_count(&self) -> usize {
self.as_str().chars().count()
}
}
#[derive(Default, Debug)]
pub struct Registers {
default: Register,
a: Register,
b: Register,
c: Register,
d: Register,
e: Register,
f: Register,
g: Register,
h: Register,
i: Register,
j: Register,
k: Register,
l: Register,
m: Register,
n: Register,
o: Register,
p: Register,
q: Register,
r: Register,
s: Register,
t: Register,
u: Register,
v: Register,
w: Register,
x: Register,
y: Register,
z: Register,
}
impl Registers {
pub const fn new() -> Self {
Self {
default: Register::new(),
a: Register::new(),
b: Register::new(),
c: Register::new(),
d: Register::new(),
e: Register::new(),
f: Register::new(),
g: Register::new(),
h: Register::new(),
i: Register::new(),
j: Register::new(),
k: Register::new(),
l: Register::new(),
m: Register::new(),
n: Register::new(),
o: Register::new(),
p: Register::new(),
q: Register::new(),
r: Register::new(),
s: Register::new(),
t: Register::new(),
u: Register::new(),
v: Register::new(),
w: Register::new(),
x: Register::new(),
y: Register::new(),
z: Register::new(),
}
}
pub fn get_reg(&self, ch: Option<char>) -> Option<&Register> {
let Some(ch) = ch else {
return Some(&self.default);
};
match ch {
'a' => Some(&self.a),
'b' => Some(&self.b),
'c' => Some(&self.c),
'd' => Some(&self.d),
'e' => Some(&self.e),
'f' => Some(&self.f),
'g' => Some(&self.g),
'h' => Some(&self.h),
'i' => Some(&self.i),
'j' => Some(&self.j),
'k' => Some(&self.k),
'l' => Some(&self.l),
'm' => Some(&self.m),
'n' => Some(&self.n),
'o' => Some(&self.o),
'p' => Some(&self.p),
'q' => Some(&self.q),
'r' => Some(&self.r),
's' => Some(&self.s),
't' => Some(&self.t),
'u' => Some(&self.u),
'v' => Some(&self.v),
'w' => Some(&self.w),
'x' => Some(&self.x),
'y' => Some(&self.y),
'z' => Some(&self.z),
_ => None,
}
}
pub fn get_reg_mut(&mut self, ch: Option<char>) -> Option<&mut Register> {
let Some(ch) = ch else {
return Some(&mut self.default);
};
match ch {
'a' => Some(&mut self.a),
'b' => Some(&mut self.b),
'c' => Some(&mut self.c),
'd' => Some(&mut self.d),
'e' => Some(&mut self.e),
'f' => Some(&mut self.f),
'g' => Some(&mut self.g),
'h' => Some(&mut self.h),
'i' => Some(&mut self.i),
'j' => Some(&mut self.j),
'k' => Some(&mut self.k),
'l' => Some(&mut self.l),
'm' => Some(&mut self.m),
'n' => Some(&mut self.n),
'o' => Some(&mut self.o),
'p' => Some(&mut self.p),
'q' => Some(&mut self.q),
'r' => Some(&mut self.r),
's' => Some(&mut self.s),
't' => Some(&mut self.t),
'u' => Some(&mut self.u),
'v' => Some(&mut self.v),
'w' => Some(&mut self.w),
'x' => Some(&mut self.x),
'y' => Some(&mut self.y),
'z' => Some(&mut self.z),
_ => None,
}
}
}
#[derive(Clone, Default, Debug)]
pub struct Register {
content: RegisterContent,
}
impl Register {
pub const fn new() -> Self {
Self {
content: RegisterContent::Span(String::new()),
}
}
pub fn content(&self) -> &RegisterContent {
&self.content
}
pub fn write(&mut self, buf: RegisterContent) {
self.content = buf
}
pub fn append(&mut self, buf: RegisterContent) {
match buf {
RegisterContent::Empty => {}
RegisterContent::Span(ref s) | RegisterContent::Line(ref s) => match &mut self.content {
RegisterContent::Empty => self.content = buf,
RegisterContent::Span(existing) => existing.push_str(s),
RegisterContent::Line(existing) => existing.push_str(s),
},
}
}
pub fn clear(&mut self) {
self.content.clear()
}
pub fn is_line(&self) -> bool {
self.content.is_line()
}
pub fn is_span(&self) -> bool {
self.content.is_span()
}
}

1054
src/readline/term.rs Normal file

File diff suppressed because it is too large Load Diff

461
src/readline/vicmd.rs Normal file
View File

@@ -0,0 +1,461 @@
use bitflags::bitflags;
use super::register::{append_register, read_register, write_register, RegisterContent};
//TODO: write tests that take edit results and cursor positions from actual
// neovim edits and test them against the behavior of this editor
#[derive(Clone, Copy, Debug)]
pub struct RegisterName {
name: Option<char>,
count: usize,
append: bool,
}
impl RegisterName {
pub fn new(name: Option<char>, count: Option<usize>) -> Self {
let Some(ch) = name else {
return Self::default();
};
let append = ch.is_uppercase();
let name = ch.to_ascii_lowercase();
Self {
name: Some(name),
count: count.unwrap_or(1),
append,
}
}
pub fn name(&self) -> Option<char> {
self.name
}
pub fn is_append(&self) -> bool {
self.append
}
pub fn count(&self) -> usize {
self.count
}
pub fn write_to_register(&self, buf: RegisterContent) {
if self.append {
append_register(self.name, buf);
} else {
write_register(self.name, buf);
}
}
pub fn read_from_register(&self) -> Option<RegisterContent> {
read_register(self.name)
}
}
impl Default for RegisterName {
fn default() -> Self {
Self {
name: None,
count: 1,
append: false,
}
}
}
bitflags! {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CmdFlags: u32 {
const VISUAL = 1<<0;
const VISUAL_LINE = 1<<1;
const VISUAL_BLOCK = 1<<2;
}
}
#[derive(Clone, Default, Debug)]
pub struct ViCmd {
pub register: RegisterName,
pub verb: Option<VerbCmd>,
pub motion: Option<MotionCmd>,
pub raw_seq: String,
pub flags: CmdFlags,
}
impl ViCmd {
pub fn new() -> Self {
Self::default()
}
pub fn set_motion(&mut self, motion: MotionCmd) {
self.motion = Some(motion)
}
pub fn set_verb(&mut self, verb: VerbCmd) {
self.verb = Some(verb)
}
pub fn verb(&self) -> Option<&VerbCmd> {
self.verb.as_ref()
}
pub fn motion(&self) -> Option<&MotionCmd> {
self.motion.as_ref()
}
pub fn verb_count(&self) -> usize {
self.verb.as_ref().map(|v| v.0).unwrap_or(1)
}
pub fn motion_count(&self) -> usize {
self.motion.as_ref().map(|m| m.0).unwrap_or(1)
}
pub fn normalize_counts(&mut self) {
let Some(verb) = self.verb.as_mut() else {
return;
};
let Some(motion) = self.motion.as_mut() else {
return;
};
let VerbCmd(v_count, _) = verb;
let MotionCmd(m_count, _) = motion;
let product = *v_count * *m_count;
verb.0 = 1;
motion.0 = product;
}
pub fn is_repeatable(&self) -> bool {
self.verb.as_ref().is_some_and(|v| v.1.is_repeatable())
}
pub fn is_cmd_repeat(&self) -> bool {
self
.verb
.as_ref()
.is_some_and(|v| matches!(v.1, Verb::RepeatLast))
}
pub fn is_motion_repeat(&self) -> bool {
self
.motion
.as_ref()
.is_some_and(|m| matches!(m.1, Motion::RepeatMotion | Motion::RepeatMotionRev))
}
pub fn is_char_search(&self) -> bool {
self
.motion
.as_ref()
.is_some_and(|m| matches!(m.1, Motion::CharSearch(..)))
}
pub fn is_submit_action(&self) -> bool {
self
.verb
.as_ref()
.is_some_and(|v| matches!(v.1, Verb::AcceptLineOrNewline))
}
pub fn is_undo_op(&self) -> bool {
self
.verb
.as_ref()
.is_some_and(|v| matches!(v.1, Verb::Undo | Verb::Redo))
}
pub fn is_inplace_edit(&self) -> bool {
self.verb.as_ref().is_some_and(|v| {
matches!(
v.1,
Verb::ReplaceCharInplace(_, _) | Verb::ToggleCaseInplace(_)
)
}) && self.motion.is_none()
}
pub fn is_line_motion(&self) -> bool {
self.motion.as_ref().is_some_and(|m| {
matches!(
m.1,
Motion::LineUp | Motion::LineDown | Motion::LineUpCharwise | Motion::LineDownCharwise
)
})
}
/// If a ViCmd has a linewise motion, but no verb, we change it to charwise
pub fn alter_line_motion_if_no_verb(&mut self) {
if self.is_line_motion()
&& self.verb.is_none()
&& let Some(motion) = self.motion.as_mut()
{
match motion.1 {
Motion::LineUp => motion.1 = Motion::LineUpCharwise,
Motion::LineDown => motion.1 = Motion::LineDownCharwise,
_ => unreachable!(),
}
}
}
pub fn is_mode_transition(&self) -> bool {
self.verb.as_ref().is_some_and(|v| {
matches!(
v.1,
Verb::Change
| Verb::InsertMode
| Verb::InsertModeLineBreak(_)
| Verb::NormalMode
| Verb::VisualModeSelectLast
| Verb::VisualMode
| Verb::ReplaceMode
)
})
}
}
#[derive(Clone, Debug)]
pub struct VerbCmd(pub usize, pub Verb);
#[derive(Clone, Debug)]
pub struct MotionCmd(pub usize, pub Motion);
impl MotionCmd {
pub fn invert_char_motion(self) -> Self {
let MotionCmd(count, Motion::CharSearch(dir, dest, ch)) = self else {
unreachable!()
};
let new_dir = match dir {
Direction::Forward => Direction::Backward,
Direction::Backward => Direction::Forward,
};
MotionCmd(count, Motion::CharSearch(new_dir, dest, ch))
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[non_exhaustive]
pub enum Verb {
Delete,
Change,
Yank,
Rot13, // lol
ReplaceChar(char), // char to replace with, number of chars to replace
ReplaceCharInplace(char, u16), // char to replace with, number of chars to replace
ToggleCaseInplace(u16), // Number of chars to toggle
ToggleCaseRange,
ToLower,
ToUpper,
Complete,
CompleteBackward,
Undo,
Redo,
RepeatLast,
Put(Anchor),
ReplaceMode,
InsertMode,
InsertModeLineBreak(Anchor),
NormalMode,
VisualMode,
VisualModeLine,
VisualModeBlock, // dont even know if im going to implement this
VisualModeSelectLast,
SwapVisualAnchor,
JoinLines,
InsertChar(char),
Insert(String),
Indent,
Dedent,
Equalize,
AcceptLineOrNewline,
EndOfFile,
}
impl Verb {
pub fn is_repeatable(&self) -> bool {
matches!(
self,
Self::Delete
| Self::Change
| Self::ReplaceChar(_)
| Self::ReplaceCharInplace(_, _)
| Self::ToLower
| Self::ToUpper
| Self::ToggleCaseRange
| Self::ToggleCaseInplace(_)
| Self::Put(_)
| Self::ReplaceMode
| Self::InsertModeLineBreak(_)
| Self::JoinLines
| Self::InsertChar(_)
| Self::Insert(_)
| Self::Indent
| Self::Dedent
| Self::Equalize
)
}
pub fn is_edit(&self) -> bool {
matches!(
self,
Self::Delete
| Self::Change
| Self::ReplaceChar(_)
| Self::ReplaceCharInplace(_, _)
| Self::ToggleCaseRange
| Self::ToggleCaseInplace(_)
| Self::ToLower
| Self::ToUpper
| Self::RepeatLast
| Self::Put(_)
| Self::ReplaceMode
| Self::InsertModeLineBreak(_)
| Self::JoinLines
| Self::InsertChar(_)
| Self::Insert(_)
| Self::Rot13
| Self::EndOfFile
)
}
pub fn is_char_insert(&self) -> bool {
matches!(
self,
Self::Change | Self::InsertChar(_) | Self::ReplaceChar(_) | Self::ReplaceCharInplace(_, _)
)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Motion {
WholeLineInclusive, // whole line including the linebreak
WholeLineExclusive, // whole line excluding the linebreak
TextObj(TextObj),
EndOfLastWord,
BeginningOfFirstWord,
BeginningOfLine,
EndOfLine,
WordMotion(To, Word, Direction),
CharSearch(Direction, Dest, char),
BackwardChar,
ForwardChar,
BackwardCharForced, // These two variants can cross line boundaries
ForwardCharForced,
LineUp,
LineUpCharwise,
ScreenLineUp,
ScreenLineUpCharwise,
LineDown,
LineDownCharwise,
ScreenLineDown,
ScreenLineDownCharwise,
BeginningOfScreenLine,
FirstGraphicalOnScreenLine,
HalfOfScreen,
HalfOfScreenLineText,
WholeBuffer,
BeginningOfBuffer,
EndOfBuffer,
ToColumn,
ToDelimMatch,
ToBrace(Direction),
ToBracket(Direction),
ToParen(Direction),
Range(usize, usize),
RepeatMotion,
RepeatMotionRev,
Null,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MotionBehavior {
Exclusive,
Inclusive,
Linewise,
}
impl Motion {
pub fn behavior(&self) -> MotionBehavior {
if self.is_linewise() {
MotionBehavior::Linewise
} else if self.is_exclusive() {
MotionBehavior::Exclusive
} else {
MotionBehavior::Inclusive
}
}
pub fn is_exclusive(&self) -> bool {
matches!(
&self,
Self::BeginningOfLine
| Self::BeginningOfFirstWord
| Self::BeginningOfScreenLine
| Self::FirstGraphicalOnScreenLine
| Self::LineDownCharwise
| Self::LineUpCharwise
| Self::ScreenLineUpCharwise
| Self::ScreenLineDownCharwise
| Self::ToColumn
| Self::TextObj(TextObj::Sentence(_))
| Self::TextObj(TextObj::Paragraph(_))
| Self::CharSearch(Direction::Backward, _, _)
| Self::WordMotion(To::Start, _, _)
| Self::ToBrace(_)
| Self::ToBracket(_)
| Self::ToParen(_)
| Self::ScreenLineDown
| Self::ScreenLineUp
| Self::Range(_, _)
)
}
pub fn is_linewise(&self) -> bool {
matches!(
self,
Self::WholeLineInclusive | Self::WholeLineExclusive | Self::LineUp | Self::LineDown | Self::ScreenLineDown | Self::ScreenLineUp
)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Anchor {
After,
Before,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum TextObj {
/// `iw`, `aw` — inner word, around word
Word(Word, Bound),
/// `)`, `(` — forward, backward
Sentence(Direction),
/// `}`, `{` — forward, backward
Paragraph(Direction),
WholeSentence(Bound),
WholeParagraph(Bound),
/// `i"`, `a"` — inner/around double quotes
DoubleQuote(Bound),
/// `i'`, `a'`
SingleQuote(Bound),
/// `i\``, `a\``
BacktickQuote(Bound),
/// `i)`, `a)` — round parens
Paren(Bound),
/// `i]`, `a]`
Bracket(Bound),
/// `i}`, `a}`
Brace(Bound),
/// `i<`, `a<`
Angle(Bound),
/// `it`, `at` — HTML/XML tags
Tag(Bound),
/// Custom user-defined objects maybe?
Custom(char),
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Word {
Big,
Normal,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Bound {
Inside,
Around,
}
#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)]
pub enum Direction {
#[default]
Forward,
Backward,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Dest {
On,
Before,
After,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum To {
Start,
End,
}

1785
src/readline/vimode.rs Normal file

File diff suppressed because it is too large Load Diff