Early implementation of fuzzy completion menu

This commit is contained in:
2026-03-02 01:54:23 -05:00
parent 6d2d94b6a7
commit a2b8fc203f
9 changed files with 469 additions and 72 deletions

View File

@@ -1,8 +1,9 @@
use std::{
collections::HashSet, fmt::Debug, path::PathBuf, sync::Arc,
collections::HashSet, fmt::{Write,Debug}, path::PathBuf, sync::Arc,
};
use nix::sys::signal::Signal;
use unicode_width::UnicodeWidthStr;
use crate::{
builtin::complete::{CompFlags, CompOptFlags, CompOpts},
@@ -16,8 +17,7 @@ use crate::{
lex::{self, LexFlags, Tk, TkRule, ends_with_unescaped},
},
readline::{
Marker, annotate_input_recursive,
markers::{self, is_marker},
Marker, annotate_input_recursive, keys::{KeyCode as C, KeyEvent as K, ModKeys as M}, linebuf::{ClampedUsize, LineBuf}, markers::{self, is_marker}, term::{LineWriter, TermWriter}, vimode::{ViInsert, ViMode}
},
state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars},
};
@@ -512,8 +512,322 @@ impl CompResult {
}
}
pub enum CompResponse {
Passthrough, // key falls through
Accept(String), // user accepted completion
Dismiss, // user canceled completion
Consumed // key was handled, but completion remains active
}
pub trait Completer {
fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>>;
fn reset(&mut self);
fn is_active(&self) -> bool;
fn selected_candidate(&self) -> Option<String>;
fn token_span(&self) -> (usize, usize);
fn original_input(&self) -> &str;
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>;
fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) }
fn handle_key(&mut self, key: K) -> ShResult<CompResponse>;
fn get_completed_line(&self, candidate: &str) -> String {
let (start, end) = self.token_span();
let orig = self.original_input();
format!("{}{}{}", &orig[..start], candidate, &orig[end..])
}
}
#[derive(Default, Debug, Clone)]
pub struct Completer {
pub struct ScoredCandidate {
content: String,
score: Option<i32>,
}
impl ScoredCandidate {
const BONUS_BOUNDARY: i32 = 10;
const BONUS_CONSECUTIVE: i32 = 8;
const BONUS_FIRST_CHAR: i32 = 5;
const PENALTY_GAP_START: i32 = 3;
const PENALTY_GAP_EXTEND: i32 = 1;
pub fn new(content: String) -> Self {
Self { content, score: None }
}
fn is_word_bound(prev: char, curr: char) -> bool {
match prev {
'/' | '_' | '-' | '.' | ' ' => true,
c if c.is_lowercase() && curr.is_uppercase() => true, // camelCase boundary
_ => false,
}
}
pub fn fuzzy_score(&mut self, other: &str) -> i32 {
if other.is_empty() {
self.score = Some(0);
return 0;
}
let query_chars: Vec<char> = other.chars().collect();
let content_chars: Vec<char> = self.content.chars().collect();
let mut indices = vec![];
let mut qi = 0;
for (ci, c_ch) in self.content.chars().enumerate() {
if qi < query_chars.len() && c_ch.eq_ignore_ascii_case(&query_chars[qi]) {
indices.push(ci);
qi += 1;
}
}
if indices.len() != query_chars.len() {
self.score = Some(i32::MIN);
return i32::MIN;
}
let mut score: i32 = 0;
for (i, &idx) in indices.iter().enumerate() {
if idx == 0 {
score += Self::BONUS_FIRST_CHAR;
}
if idx == 0 || Self::is_word_bound(content_chars[idx - 1], content_chars[idx]) {
score += Self::BONUS_BOUNDARY;
}
if i > 0 {
let gap = idx - indices[i - 1] - 1;
if gap == 0 {
score += Self::BONUS_CONSECUTIVE;
} else {
score -= Self::PENALTY_GAP_START + (gap as i32 - 1) * Self::PENALTY_GAP_EXTEND;
}
}
}
self.score = Some(score);
score
}
}
impl From<String> for ScoredCandidate {
fn from(content: String) -> Self {
Self { content, score: None }
}
}
#[derive(Debug, Clone)]
pub struct FuzzyLayout {
rows: u16
}
#[derive(Default, Debug, Clone)]
pub struct QueryEditor {
mode: ViInsert,
linebuf: LineBuf
}
impl QueryEditor {
pub fn clear(&mut self) {
self.linebuf = LineBuf::default();
self.mode = ViInsert::default();
}
pub fn handle_key(&mut self, key: K) -> ShResult<()> {
let Some(cmd) = self.mode.handle_key(key) else {
return Ok(())
};
self.linebuf.exec_cmd(cmd)
}
}
#[derive(Clone, Debug)]
pub struct FuzzyCompleter {
completer: SimpleCompleter,
query: QueryEditor,
filtered: Vec<ScoredCandidate>,
candidates: Vec<String>,
cursor: ClampedUsize,
old_layout: Option<FuzzyLayout>,
max_height: usize,
scroll_offset: usize,
active: bool
}
impl FuzzyCompleter {
fn get_window(&mut self) -> &[ScoredCandidate] {
let height = self.filtered.len().min(self.max_height);
self.update_scroll_offset();
&self.filtered[self.scroll_offset..self.scroll_offset + height]
}
pub fn update_scroll_offset(&mut self) {
let height = self.filtered.len().min(self.max_height);
if self.cursor.get() < self.scroll_offset + 1 {
self.scroll_offset = self.cursor.ret_sub(1);
}
if self.cursor.get() >= self.scroll_offset + height.saturating_sub(1) {
self.scroll_offset = self.cursor.ret_sub(height.saturating_sub(2));
}
self.scroll_offset = self.scroll_offset.min(self.filtered.len().saturating_sub(height));
}
pub fn score_candidates(&mut self) {
let mut scored: Vec<_> = self.candidates
.clone()
.into_iter()
.filter_map(|c| {
let mut sc = ScoredCandidate::new(c);
let score = sc.fuzzy_score(self.query.linebuf.as_str());
if score > i32::MIN {
Some(sc)
} else {
None
}
}).collect();
scored.sort_by_key(|sc| sc.score.unwrap_or(i32::MIN));
scored.reverse();
self.cursor.set_max(scored.len());
self.filtered = scored;
}
}
impl Default for FuzzyCompleter {
fn default() -> Self {
Self {
max_height: 8,
completer: SimpleCompleter::default(),
query: QueryEditor::default(),
filtered: vec![],
candidates: vec![],
cursor: ClampedUsize::new(0, 0, true),
old_layout: None,
scroll_offset: 0,
active: false,
}
}
}
impl Completer for FuzzyCompleter {
fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>> {
self.completer.complete(line, cursor_pos, direction)?;
let candidates: Vec<_> = self.completer.candidates.clone();
if candidates.is_empty() {
self.completer.reset();
self.active = false;
return Ok(None);
}
self.active = true;
self.candidates = candidates;
self.score_candidates();
self.completer.reset();
Ok(None) // FuzzyCompleter itself doesn't directly return a completed line, it manages the state of the filtered candidates and selection
}
fn handle_key(&mut self, key: K) -> ShResult<CompResponse> {
match key {
K(C::Esc, M::NONE) => {
self.active = false;
self.filtered.clear();
Ok(CompResponse::Dismiss)
}
K(C::Enter, M::NONE) => {
if let Some(selected) = self.filtered.get(self.cursor.get()).map(|c| c.content.clone()) {
self.active = false;
self.query.clear();
self.filtered.clear();
Ok(CompResponse::Accept(selected))
} else {
Ok(CompResponse::Passthrough)
}
}
K(C::Tab, M::SHIFT) |
K(C::Up, M::NONE) => {
self.cursor.sub(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
}
K(C::Tab, M::NONE) |
K(C::Down, M::NONE) => {
self.cursor.add(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
}
_ => {
self.query.handle_key(key)?;
self.score_candidates();
Ok(CompResponse::Consumed)
}
}
}
fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if let Some(layout) = self.old_layout.take() {
let mut buf = String::new();
// Cursor is on the query line. Move down to the last candidate.
if layout.rows > 0 {
write!(buf, "\x1b[{}B", layout.rows).unwrap();
}
// Erase each line and move up, back to the query line
for _ in 0..layout.rows {
buf.push_str("\x1b[2K\x1b[A");
}
// Erase the query line, then move up to the prompt line
buf.push_str("\x1b[2K\x1b[A");
writer.flush_write(&buf)?;
}
Ok(())
}
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if !self.active {
return Ok(());
}
let mut buf = String::new();
let cursor_pos = self.cursor.get();
let offset = self.scroll_offset;
let query = self.query.linebuf.as_str().to_string();
let visible = self.get_window();
buf.push_str("\n\r> ");
buf.push_str(&query);
for (i, candidate) in visible.iter().enumerate() {
buf.push_str("\n\r");
if i + offset == cursor_pos {
buf.push_str("\x1b[7m");
buf.push_str(&candidate.content);
buf.push_str("\x1b[0m");
} else {
buf.push_str(&candidate.content);
}
}
let new_layout = FuzzyLayout {
rows: visible.len() as u16, // +1 for the query line
};
// Move cursor back up to the query line and position after "> " + query text
write!(buf, "\x1b[{}A\r\x1b[{}C", new_layout.rows, self.query.linebuf.as_str().width() + 2).unwrap();
writer.flush_write(&buf)?;
self.old_layout = Some(new_layout);
Ok(())
}
fn reset(&mut self) {
*self = Self::default();
}
fn token_span(&self) -> (usize, usize) {
self.completer.token_span()
}
fn is_active(&self) -> bool {
self.active
}
fn selected_candidate(&self) -> Option<String> {
self.filtered.get(self.cursor.get()).map(|c| c.content.clone())
}
fn original_input(&self) -> &str {
&self.completer.original_input
}
}
#[derive(Default, Debug, Clone)]
pub struct SimpleCompleter {
pub candidates: Vec<String>,
pub selected_idx: usize,
pub original_input: String,
@@ -523,7 +837,45 @@ pub struct Completer {
pub add_space: bool,
}
impl Completer {
impl Completer for SimpleCompleter {
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)
}
}
fn reset(&mut self) {
*self = Self::default();
}
fn is_active(&self) -> bool {
self.active
}
fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned()
}
fn token_span(&self) -> (usize, usize) {
self.token_span
}
fn draw(&mut self, _writer: &mut TermWriter) -> ShResult<()> {
Ok(())
}
fn original_input(&self) -> &str {
&self.original_input
}
fn handle_key(&mut self, _key: K) -> ShResult<CompResponse> {
Ok(CompResponse::Passthrough)
}
}
impl SimpleCompleter {
pub fn new() -> Self {
Self::default()
}
@@ -569,6 +921,12 @@ impl Completer {
ctx.push(markers::ARG);
}
}
markers::RESET => {
if ctx.len() > 1 {
ctx.pop();
last_priority = 0;
}
}
_ => {}
},
_ => {
@@ -584,31 +942,6 @@ impl Completer {
(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();
@@ -713,7 +1046,7 @@ impl Completer {
let cword = if let Some(pos) = relevant
.iter()
.position(|tk| cursor_pos >= tk.span.range().start && cursor_pos <= tk.span.range().end)
.position(|tk| cursor_pos >= tk.span.range().start && cursor_pos < tk.span.range().end)
{
pos
} else {
@@ -825,10 +1158,12 @@ impl Completer {
self.token_span = (cur_token.span.range().start, cur_token.span.range().end);
self.token_span.0 = token_start;
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
if token_start >= self.token_span.0 && token_start <= self.token_span.1 {
self.token_span.0 = token_start;
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
}
// If token contains '=', only complete after the '='
let token_str = cur_token.span.as_str();

View File

@@ -162,7 +162,7 @@ impl MotionKind {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct Edit {
pub pos: usize,
pub cursor_pos: usize,
@@ -235,7 +235,7 @@ impl Edit {
pub struct ClampedUsize {
value: usize,
max: usize,
exclusive: bool,
pub exclusive: bool,
}
impl ClampedUsize {
@@ -317,7 +317,7 @@ impl ClampedUsize {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct LineBuf {
pub buffer: String,
pub hint: Option<String>,
@@ -2817,6 +2817,9 @@ impl LineBuf {
for _ in 0..delta {
if self.grapheme_at(line_start).is_some_and(|gr| gr == "\t") {
self.remove(line_start);
if !self.cursor_at_max() {
self.cursor.sub(1);
}
}
}
}

View File

@@ -10,12 +10,13 @@ use crate::expand::expand_prompt;
use crate::libsh::sys::TTY_FILENO;
use crate::parse::lex::{LexStream, QuoteState};
use crate::prelude::*;
use crate::readline::complete::FuzzyCompleter;
use crate::readline::term::{Pos, calc_str_width};
use crate::state::{ShellParam, read_shopts};
use crate::{
libsh::error::ShResult,
parse::lex::{self, LexFlags, Tk, TkFlags, TkRule},
readline::{complete::Completer, highlight::Highlighter},
readline::{complete::{CompResponse, Completer}, highlight::Highlighter},
};
pub mod complete;
@@ -206,7 +207,7 @@ pub struct ShedVi {
pub prompt: Prompt,
pub highlighter: Highlighter,
pub completer: Completer,
pub completer: Box<dyn Completer>,
pub mode: Box<dyn ViMode>,
pub repeat_action: Option<CmdReplay>,
@@ -225,7 +226,7 @@ impl ShedVi {
reader: PollReader::new(),
writer: TermWriter::new(tty),
prompt,
completer: Completer::new(),
completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()),
old_layout: None,
@@ -319,6 +320,44 @@ impl ShedVi {
// Process all available keys
while let Some(key) = self.reader.read_key()? {
// If completer is active, delegate input to it
if self.completer.is_active() {
match self.completer.handle_key(key.clone())? {
CompResponse::Accept(candidate) => {
let span_start = self.completer.token_span().0;
let new_cursor = span_start + candidate.len();
let line = self.completer.get_completed_line(&candidate);
self.editor.set_buffer(line);
self.editor.cursor.set(new_cursor);
// Don't reset yet — clear() needs old_layout to erase the selector.
if !self.history.at_pending() {
self.history.reset_to_pending();
}
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
let hint = self.history.get_hint();
self.editor.set_hint(hint);
self.completer.clear(&mut self.writer)?;
self.needs_redraw = true;
continue;
}
CompResponse::Dismiss => {
self.completer.clear(&mut self.writer)?;
// Don't reset yet — clear() needs old_layout to erase the selector.
// The next print_line() will call clear(), then we can reset.
continue;
}
CompResponse::Consumed => {
/* just redraw */
self.needs_redraw = true;
continue;
}
CompResponse::Passthrough => { /* fall through to normal handling below */ }
}
}
if self.should_accept_hint(&key) {
self.editor.accept_hint();
if !self.history.at_pending() {
@@ -347,7 +386,7 @@ impl ShedVi {
self.old_layout = None;
}
Ok(Some(line)) => {
let span_start = self.completer.token_span.0;
let span_start = self.completer.token_span().0;
let new_cursor = span_start
+ self
.completer
@@ -556,6 +595,8 @@ impl ShedVi {
.unwrap_or_default() as usize;
let one_line = new_layout.end.row == 0;
self.completer.clear(&mut self.writer)?;
if let Some(layout) = self.old_layout.as_ref() {
self.writer.clear_rows(layout)?;
}
@@ -610,6 +651,8 @@ impl ShedVi {
self.writer.flush_write(&self.mode.cursor_style())?;
self.completer.draw(&mut self.writer)?;
self.old_layout = Some(new_layout);
self.needs_redraw = false;
Ok(())

View File

@@ -66,7 +66,7 @@ pub trait ViMode {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct ViInsert {
cmds: Vec<ViCmd>,
pending_cmd: ViCmd,