use std::ops::Range;
use super::{TargetResolutionError, motion::clamp_to_boundary};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextObjectScope {
Inner,
Around,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextObjectKind {
Word,
Paragraph,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TextObject {
scope: TextObjectScope,
kind: TextObjectKind,
}
impl TextObject {
#[must_use]
pub const fn new(scope: TextObjectScope, kind: TextObjectKind) -> Self {
Self { scope, kind }
}
#[must_use]
pub const fn scope(self) -> TextObjectScope {
self.scope
}
#[must_use]
pub const fn kind(self) -> TextObjectKind {
self.kind
}
}
pub fn resolve_text_object_range(
text: &str,
cursor_byte_index: usize,
object: TextObject,
count: usize,
) -> Result<Range<usize>, TargetResolutionError> {
let count = count.max(1);
match object.kind {
TextObjectKind::Word => word_object_range(text, cursor_byte_index, object.scope, count),
TextObjectKind::Paragraph => {
paragraph_object_range(text, cursor_byte_index, object.scope, count)
}
}
}
fn is_keyword_character(character: char) -> bool {
character == '_' || character.is_alphanumeric()
}
const fn is_word_object_character(character: char) -> bool {
!character.is_whitespace()
}
fn word_object_range(
text: &str,
cursor_byte_index: usize,
scope: TextObjectScope,
count: usize,
) -> Result<Range<usize>, TargetResolutionError> {
let cursor = clamp_to_boundary(text, cursor_byte_index);
let runs = word_runs(text);
let Some(first_index) = runs
.iter()
.position(|run| run.start <= cursor && cursor < run.end)
.or_else(|| runs.iter().position(|run| run.start >= cursor))
else {
return Err(TargetResolutionError::NoTextObject);
};
let last_index = first_index.saturating_add(count - 1).min(runs.len() - 1);
let inner = runs[first_index].start..runs[last_index].end;
Ok(match scope {
TextObjectScope::Inner => inner,
TextObjectScope::Around => around_word_range(text, inner),
})
}
fn around_word_range(text: &str, inner: Range<usize>) -> Range<usize> {
let trailing = following_whitespace_end(text, inner.end);
if trailing > inner.end {
return inner.start..trailing;
}
preceding_whitespace_start(text, inner.start)..inner.end
}
fn paragraph_object_range(
text: &str,
cursor_byte_index: usize,
scope: TextObjectScope,
count: usize,
) -> Result<Range<usize>, TargetResolutionError> {
let cursor = clamp_to_boundary(text, cursor_byte_index);
let paragraphs = paragraph_runs(text);
let Some(first_index) = paragraphs
.iter()
.position(|paragraph| paragraph.start <= cursor && cursor < paragraph.end)
.or_else(|| {
paragraphs
.iter()
.position(|paragraph| paragraph.start >= cursor)
})
else {
return Err(TargetResolutionError::NoTextObject);
};
let last_index = first_index
.saturating_add(count - 1)
.min(paragraphs.len() - 1);
let inner = paragraphs[first_index].start..paragraphs[last_index].end;
Ok(match scope {
TextObjectScope::Inner => inner,
TextObjectScope::Around => around_paragraph_range(text, inner),
})
}
fn around_paragraph_range(text: &str, inner: Range<usize>) -> Range<usize> {
let trailing = following_blank_lines_end(text, inner.end);
if trailing > inner.end {
return inner.start..trailing;
}
preceding_blank_lines_start(text, inner.start)..inner.end
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct WordRun {
start: usize,
end: usize,
}
fn word_runs(text: &str) -> Vec<WordRun> {
let mut runs = Vec::new();
let mut current: Option<(WordRun, bool)> = None;
for (byte_index, character) in text.char_indices() {
if !is_word_object_character(character) {
if let Some((run, _class)) = current.take() {
runs.push(run);
}
continue;
}
let class = is_keyword_character(character);
match current {
Some((mut run, current_class)) if current_class == class => {
run.end = byte_index + character.len_utf8();
current = Some((run, current_class));
}
Some((run, _current_class)) => {
runs.push(run);
current = Some((
WordRun {
start: byte_index,
end: byte_index + character.len_utf8(),
},
class,
));
}
None => {
current = Some((
WordRun {
start: byte_index,
end: byte_index + character.len_utf8(),
},
class,
));
}
}
}
if let Some((run, _class)) = current {
runs.push(run);
}
runs
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ParagraphRun {
start: usize,
end: usize,
}
fn paragraph_runs(text: &str) -> Vec<ParagraphRun> {
let mut runs = Vec::new();
let mut current_start = None;
let mut current_end = 0;
for line in lines(text) {
if line.is_blank {
if let Some(start) = current_start.take() {
runs.push(ParagraphRun {
start,
end: current_end,
});
}
continue;
}
if current_start.is_none() {
current_start = Some(line.start);
}
current_end = line.end_with_newline;
}
if let Some(start) = current_start {
runs.push(ParagraphRun {
start,
end: current_end,
});
}
runs
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TextLine {
start: usize,
end: usize,
end_with_newline: usize,
is_blank: bool,
}
fn lines(text: &str) -> impl Iterator<Item = TextLine> + '_ {
let mut start = 0;
std::iter::from_fn(move || {
if start > text.len() {
return None;
}
let line_start = start;
let line_end = text[start..]
.find('\n')
.map_or(text.len(), |offset| start + offset);
let end_with_newline = text[line_end..]
.chars()
.next()
.filter(|character| *character == '\n')
.map_or(line_end, |newline| line_end + newline.len_utf8());
start = end_with_newline
.checked_add(usize::from(
end_with_newline == line_end && line_end == text.len(),
))
.unwrap_or(text.len() + 1);
Some(TextLine {
start: line_start,
end: line_end,
end_with_newline,
is_blank: text[line_start..line_end].trim().is_empty(),
})
})
}
fn following_whitespace_end(text: &str, index: usize) -> usize {
let mut end = index;
for (offset, character) in text[index..].char_indices() {
if !character.is_whitespace() {
break;
}
end = index + offset + character.len_utf8();
}
end
}
fn preceding_whitespace_start(text: &str, index: usize) -> usize {
let mut start = index;
for (byte_index, character) in text[..index].char_indices().rev() {
if !character.is_whitespace() {
break;
}
start = byte_index;
}
start
}
fn following_blank_lines_end(text: &str, index: usize) -> usize {
let mut end = index;
for line in lines(&text[index..]) {
if !line.is_blank {
break;
}
end = index + line.end_with_newline;
}
end
}
fn preceding_blank_lines_start(text: &str, index: usize) -> usize {
let mut start = index;
for line in lines(text).take_while(|line| line.end_with_newline <= index) {
if line.is_blank {
start = start.min(line.start);
} else {
start = index;
}
}
start
}
#[cfg(test)]
mod tests {
use super::{TextObject, TextObjectKind, TextObjectScope, resolve_text_object_range};
use crate::vim::TargetResolutionError;
#[test]
fn inner_word_resolves_utf8_keyword_run() {
let text = "one λ_two!";
assert_eq!(
resolve_text_object_range(
text,
"one ".len(),
TextObject::new(TextObjectScope::Inner, TextObjectKind::Word),
1,
),
Ok("one ".len().."one λ_two".len())
);
}
#[test]
fn around_word_includes_trailing_space_when_present() {
let text = "one two three";
assert_eq!(
resolve_text_object_range(
text,
"one ".len(),
TextObject::new(TextObjectScope::Around, TextObjectKind::Word),
1,
),
Ok("one ".len().."one two ".len())
);
}
#[test]
fn counted_word_object_extends_across_runs() {
let text = "one two three";
assert_eq!(
resolve_text_object_range(
text,
0,
TextObject::new(TextObjectScope::Inner, TextObjectKind::Word),
2,
),
Ok(0.."one two".len())
);
}
#[test]
fn paragraph_objects_use_blank_line_boundaries() {
let text = "one\ntwo\n\nthree\n\n";
assert_eq!(
resolve_text_object_range(
text,
"one\n".len(),
TextObject::new(TextObjectScope::Inner, TextObjectKind::Paragraph),
1,
),
Ok(0.."one\ntwo\n".len())
);
assert_eq!(
resolve_text_object_range(
text,
"one\ntwo\n\n".len(),
TextObject::new(TextObjectScope::Around, TextObjectKind::Paragraph),
1,
),
Ok("one\ntwo\n\n".len()..text.len())
);
}
#[test]
fn missing_text_object_is_typed() {
assert_eq!(
resolve_text_object_range(
" \n\t",
0,
TextObject::new(TextObjectScope::Inner, TextObjectKind::Word),
1,
),
Err(TargetResolutionError::NoTextObject)
);
}
}