reader: fix try_apply_edit_to_autosuggestion false positive

Given command line ": i" and suggestion ": İnstall" whose lowercase
mapping is ": i\u{307}nstall", most of our code assumes that typing
"n" invalidates the autosuggestion.

This doesn't happen because try_apply_edit_to_autosuggestion thinks
that "i" has fully matched the suggestion's "İ".

Fix this inconsistency by recording the exact number of lowercase
characters already matched in the suggestion; then we only need to
compare the rest.

This allows us to restore an important invariant; this reverts
1d2a5997cc (Remove broken assert, 2026-01-21).
This commit is contained in:
Johannes Altmanninger
2026-01-22 14:53:02 +01:00
parent 2a3fe73a6d
commit 78f4541116
4 changed files with 95 additions and 32 deletions

View File

@@ -18,14 +18,24 @@ pub fn count_newlines(s: &wstr) -> usize {
count count
} }
fn is_prefix(mut lhs: impl Iterator<Item = char>, mut rhs: impl Iterator<Item = char>) -> bool { #[derive(Eq, PartialEq)]
pub enum IsPrefix {
Prefix,
Equal,
}
pub fn is_prefix(
mut lhs: impl Iterator<Item = char>,
mut rhs: impl Iterator<Item = char>,
) -> Option<IsPrefix> {
use IsPrefix::*;
loop { loop {
match (lhs.next(), rhs.next()) { match (lhs.next(), rhs.next()) {
(None, _) => return true, (None, None) => return Some(Equal),
(Some(_), None) => return false, (None, Some(_)) => return Some(Prefix),
(Some(_), None) => return None,
(Some(lhs), Some(rhs)) => { (Some(lhs), Some(rhs)) => {
if lhs != rhs { if lhs != rhs {
return false; return None;
} }
} }
} }
@@ -36,7 +46,7 @@ fn is_prefix(mut lhs: impl Iterator<Item = char>, mut rhs: impl Iterator<Item =
pub fn string_prefixes_string_case_insensitive(proposed_prefix: &wstr, value: &wstr) -> bool { pub fn string_prefixes_string_case_insensitive(proposed_prefix: &wstr, value: &wstr) -> bool {
let proposed_prefix = lowercase(proposed_prefix.chars()); let proposed_prefix = lowercase(proposed_prefix.chars());
let value = lowercase(value.chars()); let value = lowercase(value.chars());
is_prefix(proposed_prefix, value) is_prefix(proposed_prefix, value).is_some()
} }
pub fn string_prefixes_string_maybe_case_insensitive( pub fn string_prefixes_string_maybe_case_insensitive(
@@ -63,7 +73,7 @@ pub fn strip_executable_suffix(path: &wstr) -> Option<&wstr> {
pub fn string_suffixes_string_case_insensitive(proposed_suffix: &wstr, value: &wstr) -> bool { pub fn string_suffixes_string_case_insensitive(proposed_suffix: &wstr, value: &wstr) -> bool {
let proposed_suffix = lowercase_rev(proposed_suffix.chars()); let proposed_suffix = lowercase_rev(proposed_suffix.chars());
let value = lowercase_rev(value.chars()); let value = lowercase_rev(value.chars());
is_prefix(proposed_suffix, value) is_prefix(proposed_suffix, value).is_some()
} }
/// Test if a string prefixes another. Returns true if a is a prefix of b. /// Test if a string prefixes another. Returns true if a is a prefix of b.
@@ -588,6 +598,8 @@ macro_rules! validate {
validate!("i", "İ", true); validate!("i", "İ", true);
validate!("gs", "gs_", true); validate!("gs", "gs_", true);
validate!("gs_", "gs", false); validate!("gs_", "gs", false);
assert_eq!("İn".to_lowercase().as_str(), "i\u{307}n");
validate!("echo in", "echo İnstall", false);
} }
#[test] #[test]

View File

@@ -1640,6 +1640,10 @@ pub fn current_item(&self) -> &HistoryItem {
self.current_item.as_ref().expect("No current item") self.current_item.as_ref().expect("No current item")
} }
pub fn canon_term(&self) -> &wstr {
&self.canon_term
}
/// Returns the current search result item contents. /// Returns the current search result item contents.
/// ///
/// # Panics /// # Panics

View File

@@ -19,6 +19,8 @@
use crate::portable_atomic::AtomicU64; use crate::portable_atomic::AtomicU64;
use fish_common::{UTF8_BOM_WCHAR, help_section}; use fish_common::{UTF8_BOM_WCHAR, help_section};
use fish_fallback::lowercase;
use fish_wcstringutil::{IsPrefix, is_prefix};
use libc::{ use libc::{
_POSIX_VDISABLE, ECHO, EINTR, EIO, EISDIR, ENOTTY, EPERM, ESRCH, FLUSHO, ICANON, ICRNL, IEXTEN, _POSIX_VDISABLE, ECHO, EINTR, EIO, EISDIR, ENOTTY, EPERM, ESRCH, FLUSHO, ICANON, ICRNL, IEXTEN,
INLCR, IXOFF, IXON, O_NONBLOCK, O_RDONLY, ONLCR, OPOST, SIGINT, SIGTTIN, STDERR_FILENO, INLCR, IXOFF, IXON, O_NONBLOCK, O_RDONLY, ONLCR, OPOST, SIGINT, SIGTTIN, STDERR_FILENO,
@@ -2012,26 +2014,40 @@ fn try_apply_edit_to_autosuggestion(
// This is also the main mechanism by which readline commands that don't change the command line // This is also the main mechanism by which readline commands that don't change the command line
// text avoid recomputing the autosuggestion. // text avoid recomputing the autosuggestion.
assert!(string_prefixes_string_maybe_case_insensitive( assert!(string_prefixes_string_maybe_case_insensitive(
autosuggestion.icase, autosuggestion.icase_matched_codepoints.is_some(),
&command_line_text[autosuggestion.search_string_range.clone()], &command_line_text[autosuggestion.search_string_range.clone()],
&autosuggestion.text &autosuggestion.text
)); ));
let search_string_range = autosuggestion.search_string_range.clone(); let search_string_range = autosuggestion.search_string_range.clone();
// This is a heuristic with false negatives but that seems fine. // This is a heuristic with false negatives but that seems fine.
let Some(offset) = edit.range.start.checked_sub(search_string_range.start) else { if edit.range.start < search_string_range.start
return false; || edit.range.end != search_string_range.end
}; || {
let Some(remaining) = autosuggestion.text.get(offset..) else { let suggestion = autosuggestion.text.chars();
return false; let replacement = edit.replacement.chars();
}; let unchanged_prefix = autosuggestion.icase_matched_codepoints.map_or(
if edit.range.end != search_string_range.end search_string_range
|| !string_prefixes_string_maybe_case_insensitive( .len()
autosuggestion.icase, .checked_sub(edit.range.len())
&edit.replacement, .unwrap(),
remaining, |matched_codepoints| {
) matched_codepoints
|| edit.replacement.len() == remaining.len() .checked_sub(
lowercase(command_line_text[edit.range.clone()].chars()).count(),
)
.unwrap()
},
);
if autosuggestion.icase_matched_codepoints.is_some() {
is_prefix(
lowercase(replacement),
lowercase(suggestion).skip(unchanged_prefix),
)
} else {
is_prefix(replacement, suggestion.skip(unchanged_prefix))
}
} != Some(IsPrefix::Prefix)
{ {
return false; return false;
} }
@@ -2050,6 +2066,7 @@ fn push_edit_internal(&mut self, elt: EditableLineTag, edit: Edit, allow_coalesc
self.command_line.text(), self.command_line.text(),
&edit, &edit,
); );
if preserves_autosuggestion { if preserves_autosuggestion {
autosuggestion_update = AutosuggestionUpdate::Preserve autosuggestion_update = AutosuggestionUpdate::Preserve
} else if !self.autosuggestion.is_empty() } else if !self.autosuggestion.is_empty()
@@ -5146,8 +5163,9 @@ pub(super) struct Autosuggestion {
/// Always at least whole line. /// Always at least whole line.
search_string_range: Range<usize>, search_string_range: Range<usize>,
/// Whether the autosuggestion should be case insensitive. /// If the autosuggestion is a case insensitive (prefix) match, this indicates the number
icase: bool, /// of code points we matched in the lowercase mapping of the suggestion..
icase_matched_codepoints: Option<usize>,
/// Whether the autosuggestion is a whole match from history. /// Whether the autosuggestion is a whole match from history.
is_whole_item_from_history: bool, is_whole_item_from_history: bool,
@@ -5190,14 +5208,14 @@ fn new(
command_line: WString, command_line: WString,
search_string_range: Range<usize>, search_string_range: Range<usize>,
text: WString, text: WString,
icase: bool, icase_matched_codepoints: Option<usize>,
is_whole_item_from_history: bool, is_whole_item_from_history: bool,
) -> Self { ) -> Self {
Self { Self {
autosuggestion: Autosuggestion { autosuggestion: Autosuggestion {
text, text,
search_string_range, search_string_range,
icase, icase_matched_codepoints,
is_whole_item_from_history, is_whole_item_from_history,
}, },
command_line, command_line,
@@ -5333,7 +5351,7 @@ fn get_autosuggestion_performer(
command_line.clone(), command_line.clone(),
range.clone(), range.clone(),
full[suggested_range].into(), full[suggested_range].into(),
icase, icase.then(|| searcher.canon_term().char_count()),
is_whole, is_whole,
); );
if icase { if icase {
@@ -5403,11 +5421,12 @@ fn get_autosuggestion_performer(
); );
line_at_cursor(&full_line, would_be_cursor).to_owned() line_at_cursor(&full_line, would_be_cursor).to_owned()
}; };
let lowercase_char_count = lowercase(command_line[line_range.clone()].chars()).count();
let mut result = AutosuggestionResult::new( let mut result = AutosuggestionResult::new(
command_line, command_line,
line_range, line_range,
suggestion, suggestion,
true, // normal completions are case-insensitive Some(lowercase_char_count), // normal completions are case-insensitive
/*is_whole_item_from_history=*/ false, /*is_whole_item_from_history=*/ false,
); );
result.needs_load = needs_load; result.needs_load = needs_load;
@@ -5468,7 +5487,7 @@ fn autosuggest_completed(&mut self, result: AutosuggestionResult) {
} else if !result.is_empty() } else if !result.is_empty()
&& self.can_autosuggest() && self.can_autosuggest()
&& string_prefixes_string_maybe_case_insensitive( && string_prefixes_string_maybe_case_insensitive(
result.icase, result.icase_matched_codepoints.is_some(),
result.search_string(), result.search_string(),
&result.text, &result.text,
) )
@@ -5491,6 +5510,12 @@ fn update_autosuggestion(&mut self) {
let el = &self.data.command_line; let el = &self.data.command_line;
if self.is_at_line_with_autosuggestion() { if self.is_at_line_with_autosuggestion() {
let autosuggestion = &self.autosuggestion;
assert!(string_prefixes_string_maybe_case_insensitive(
autosuggestion.icase_matched_codepoints.is_some(),
&el.text()[autosuggestion.search_string_range.clone()],
&autosuggestion.text
));
return; return;
} }
@@ -7369,7 +7394,7 @@ macro_rules! validate {
Autosuggestion { Autosuggestion {
text: L!("echo hest").to_owned(), text: L!("echo hest").to_owned(),
search_string_range: 0..4, search_string_range: 0..4,
icase: false, icase_matched_codepoints: None,
is_whole_item_from_history: true, is_whole_item_from_history: true,
}, },
"echo", "echo",
@@ -7377,7 +7402,7 @@ macro_rules! validate {
Some(Autosuggestion { Some(Autosuggestion {
text: L!("echo hest").to_owned(), text: L!("echo hest").to_owned(),
search_string_range: 0..5, search_string_range: 0..5,
icase: false, icase_matched_codepoints: None,
is_whole_item_from_history: true, is_whole_item_from_history: true,
}) })
); );
@@ -7387,7 +7412,7 @@ macro_rules! validate {
Autosuggestion { Autosuggestion {
text: L!("echo hest").to_owned(), text: L!("echo hest").to_owned(),
search_string_range: 0..4, search_string_range: 0..4,
icase: false, icase_matched_codepoints: None,
is_whole_item_from_history: true, is_whole_item_from_history: true,
}, },
"echo", "echo",
@@ -7400,7 +7425,7 @@ macro_rules! validate {
Autosuggestion { Autosuggestion {
text: L!("echo hest").to_owned(), text: L!("echo hest").to_owned(),
search_string_range: 0..4, search_string_range: 0..4,
icase: true, icase_matched_codepoints: Some(4),
is_whole_item_from_history: true, is_whole_item_from_history: true,
}, },
"echo", "echo",
@@ -7408,9 +7433,22 @@ macro_rules! validate {
Some(Autosuggestion { Some(Autosuggestion {
text: L!("echo hest").to_owned(), text: L!("echo hest").to_owned(),
search_string_range: 0..6, search_string_range: 0..6,
icase: true, icase_matched_codepoints: Some(4),
is_whole_item_from_history: true, is_whole_item_from_history: true,
}) })
); );
validate!(
"Lowercase mapping is only partially matched",
Autosuggestion {
text: L!("echo İnstall").to_owned(),
search_string_range: 0..6,
icase_matched_codepoints: Some(6),
is_whole_item_from_history: true,
},
"echo i",
Edit::new(6..6, L!("n").to_owned()),
None,
);
} }
} }

View File

@@ -50,3 +50,12 @@ isolated-tmux send-keys C-u 'echo İ___' Enter C-l 'echo i'
tmux-sleep tmux-sleep
isolated-tmux capture-pane -p isolated-tmux capture-pane -p
# CHECK: prompt {{\d+}}> echo İ___ # CHECK: prompt {{\d+}}> echo İ___
isolated-tmux send-keys C-u 'echo İnstall' Enter C-l 'echo i'
tmux-sleep
isolated-tmux capture-pane -p
# CHECK: prompt {{\d+}}> echo İnstall
isolated-tmux send-keys n
tmux-sleep
isolated-tmux capture-pane -p
# CHECK: prompt {{\d+}}> echo in