Extracted readline from the dead prompt module
This commit is contained in:
470
src/readline/complete.rs
Normal file
470
src/readline/complete.rs
Normal 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
436
src/readline/highlight.rs
Normal 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
425
src/readline/history.rs
Normal 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
139
src/readline/keys.rs
Normal 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
1
src/readline/layout.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3084
src/readline/linebuf.rs
Normal file
3084
src/readline/linebuf.rs
Normal file
File diff suppressed because it is too large
Load Diff
1155
src/readline/mod.rs
Normal file
1155
src/readline/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
251
src/readline/register.rs
Normal file
251
src/readline/register.rs
Normal 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
1054
src/readline/term.rs
Normal file
File diff suppressed because it is too large
Load Diff
461
src/readline/vicmd.rs
Normal file
461
src/readline/vicmd.rs
Normal 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
1785
src/readline/vimode.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user