mirror of
https://github.com/fish-shell/fish-shell.git
synced 2026-05-31 20:31:19 -03:00
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:
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user