use std::ops::Range;
use super::quoting;
#[derive(Debug)]
pub(super) struct LineWords<'a> {
pub comp_words: Vec<&'a str>,
pub cword: Option<usize>,
}
pub(super) static NO_WORDS: LineWords<'static> = LineWords {
comp_words: Vec::new(),
cword: None,
};
impl<'a> LineWords<'a> {
pub fn command_name(&self) -> Option<&'a str> {
self.comp_words.first().copied()
}
pub fn preceding_word(&self) -> Option<&'a str> {
let index = self.cword?.saturating_sub(1);
self.comp_words.get(index).copied()
}
}
#[derive(Debug)]
pub(super) struct CompletionWord {
pub text: String,
pub range: Range<usize>,
}
#[derive(Debug, Clone, Copy)]
struct CompWord<'a> {
text: &'a str,
start: usize,
}
impl CompWord<'_> {
const fn end(&self) -> usize {
self.start + self.text.len()
}
}
pub(super) fn find_line_words<'a>(
input: &'a str,
word_breaks: &[char],
cursor: usize,
) -> LineWords<'a> {
let mut comp_words = comp_words(input, word_breaks);
let cword = (!comp_words.is_empty()).then(|| {
comp_words
.iter()
.rposition(|w| w.start <= cursor && cursor <= w.end())
.unwrap_or_else(|| {
let index = comp_words.partition_point(|w| w.start < cursor);
comp_words.insert(
index,
CompWord {
text: "",
start: cursor,
},
);
index
})
});
LineWords {
comp_words: comp_words.into_iter().map(|w| w.text).collect(),
cword,
}
}
fn is_break(scanned: "ing::ScannedChar, word_breaks: &[char]) -> bool {
scanned.unquoted && word_breaks.contains(&scanned.c)
}
#[allow(
clippy::string_slice,
reason = "the word starts at a char boundary, and the caller checks the cursor is one"
)]
pub(super) fn find_completion_word(
input: &str,
word_breaks: &[char],
cursor: usize,
) -> CompletionWord {
const SPECIAL_PREFIXES: [char; 2] = ['$', '@'];
let mut word_start = 0;
let mut open_quote = None;
for scanned in quoting::scan(input).take_while(|s| s.index < cursor) {
if is_break(&scanned, word_breaks) {
word_start = scanned.index;
if !SPECIAL_PREFIXES.contains(&scanned.c) {
word_start += scanned.c.len_utf8();
}
}
open_quote = scanned.open_quote;
}
let (start, text_start) = match open_quote {
Some((index, q)) => (index, index + q.opening().len()),
None => (word_start, word_start),
};
CompletionWord {
text: input[text_start..cursor].to_owned(),
range: start..cursor,
}
}
#[allow(clippy::string_slice, reason = "used indices come from char_indices")]
fn comp_words<'a>(input: &'a str, word_breaks: &[char]) -> Vec<CompWord<'a>> {
let mut tokens = vec![];
let mut word_start = None;
let mut word_is_breaks = false;
for scanned in quoting::scan(input) {
let (i, c) = (scanned.index, scanned.c);
let is_break = is_break(&scanned, word_breaks);
let is_whitespace = is_break && c.is_ascii_whitespace();
if let Some(start) = word_start {
if is_whitespace || is_break != word_is_breaks {
tokens.push(CompWord {
text: &input[start..i],
start,
});
word_start = None;
}
}
if !is_whitespace && word_start.is_none() {
word_start = Some(i);
word_is_breaks = is_break;
}
}
if let Some(start) = word_start {
tokens.push(CompWord {
text: &input[start..],
start,
});
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_word_like_readline() {
let word_with = |delims: &str, input: &str, cursor: usize| {
let delims: Vec<char> = delims.chars().collect();
let word = find_completion_word(input, &delims, cursor);
let quote = (word.range.len() > word.text.len())
.then(|| input.get(word.range.start..)?.chars().next())
.flatten();
(word.text, quote)
};
let word = |input: &str, cursor: usize| word_with(" \t\n\"'><=;|&(:", input, cursor);
let unquoted = |text: &str| (text.to_owned(), None);
let quoted = |text: &str, q| (text.to_owned(), Some(q));
assert_eq!(word("", 0), unquoted(""));
assert_eq!(word("ls fo", 5), unquoted("fo"));
assert_eq!(word("ls fo", 4), unquoted("f"));
assert_eq!(word("ls ", 3), unquoted(""));
assert_eq!(word("ls --opt=", 9), unquoted(""));
assert_eq!(word("ls --opt=val", 12), unquoted("val"));
assert_eq!(word("ls --opt=val", 9), unquoted(""));
assert_eq!(word("ls --opt=val", 8), unquoted("--opt"));
assert_eq!(word("ls a:=b", 5), unquoted(""));
assert_eq!(word("ls a\\:b", 7), unquoted("a\\:b"));
assert_eq!(word("ls \"a b\"c=d", 11), unquoted("d"));
assert_eq!(word("ls 'a b'c", 9), unquoted("'a b'c"));
assert_eq!(word("ls é:ü", 8), unquoted("ü"));
let word = |input: &str, cursor: usize| word_with(" \t\n\"'><=;|&(:@", input, cursor);
assert_eq!(word("ls a@b", 6), unquoted("@b"));
assert_eq!(word("ls a:@b", 7), unquoted("@b"));
assert_eq!(word("ls a@:b", 7), unquoted("b"));
assert_eq!(word("ls a@", 5), unquoted("@"));
assert_eq!(word("ls 'a b:c", 9), quoted("a b:c", '\''));
assert_eq!(word("ls x'a b", 8), quoted("a b", '\''));
assert_eq!(word("ls --x=\"a", 9), quoted("a", '"'));
assert_eq!(word(r#"ls "a\"b"#, 8), quoted(r#"a\"b"#, '"'));
assert_eq!(word("ls $'a b", 8), quoted("a b", '$'));
assert_eq!(word(r"ls $'a\'b c", 11), quoted(r"a\'b c", '$'));
assert_eq!(word("ls $$'a b", 9), quoted("a b", '\''));
assert_eq!(word(r"ls 'a\'b", 8), unquoted(r"'a\'b"));
assert_eq!(word(r"ls \'a", 6), unquoted(r"\'a"));
}
#[test]
fn line_words_like_bash() {
fn words(
input: &str,
cursor: usize,
) -> (Vec<&str>, Option<usize>, Option<&str>, Option<&str>) {
let words = find_line_words(input, &[' ', '='], cursor);
(
words.comp_words.clone(),
words.cword,
words.command_name(),
words.preceding_word(),
)
}
assert_eq!(words("", 0), (vec![], None, None, None));
assert_eq!(
words("ls fo", 5),
(vec!["ls", "fo"], Some(1), Some("ls"), Some("ls"))
);
assert_eq!(
words("ls", 2),
(vec!["ls"], Some(0), Some("ls"), Some("ls"))
);
assert_eq!(
words("ls x", 3),
(vec!["ls", "", "x"], Some(1), Some("ls"), Some("ls"))
);
assert_eq!(
words("ls ", 3),
(vec!["ls", ""], Some(1), Some("ls"), Some("ls"))
);
assert_eq!(
words("ls a=b", 5),
(vec!["ls", "a", "=", "b"], Some(3), Some("ls"), Some("="))
);
}
#[test]
fn completion_tokenization() {
let words = |input: &str, word_breaks: &str| -> Vec<(String, usize)> {
let word_breaks: Vec<char> = word_breaks.chars().collect();
comp_words(input, &word_breaks)
.into_iter()
.map(|w| (w.text.to_owned(), w.start))
.collect()
};
let expected = |words: &[(&str, usize)]| -> Vec<(String, usize)> {
words
.iter()
.map(|&(text, start)| (text.to_owned(), start))
.collect()
};
for (input, word_breaks, expected_words) in [
("one two", " ", expected(&[("one", 0), ("two", 4)])),
("one \t two", " \t", expected(&[("one", 0), ("two", 6)])),
(" ", " ", expected(&[])),
(":", ":", expected(&[(":", 0)])),
("a:::b", ": ", expected(&[("a", 0), (":::", 1), ("b", 4)])),
("a:=", ":= ", expected(&[("a", 0), (":=", 1)])),
(
"a: : :b",
": ",
expected(&[("a", 0), (":", 1), (":", 3), (":", 5), ("b", 6)]),
),
(
"one two:three",
": ",
expected(&[("one", 0), ("two", 4), (":", 7), ("three", 8)]),
),
("one'two", "'", expected(&[("one'two", 0)])),
(
"a'b c' x=\"d e",
" =\"'",
expected(&[("a'b c'", 0), ("x", 7), ("=", 8), ("\"d e", 9)]),
),
(
"one 'two:three'",
": ",
expected(&[("one", 0), ("'two:three'", 4)]),
),
(
"one \\'two \"two four\"",
": ",
expected(&[("one", 0), ("\\'two", 4), ("\"two four\"", 10)]),
),
] {
assert_eq!(words(input, word_breaks), expected_words, "{input:?}");
}
}
}