use super::buffer::Buffer;
use super::cursor::Cursor;
use super::list::Lists;
use super::motion::char_class;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ObjectRange {
pub start: usize,
pub end: usize,
pub linewise: bool,
}
impl ObjectRange {
fn chars(start: usize, end: usize) -> Option<Self> {
(end > start).then_some(ObjectRange {
start,
end,
linewise: false,
})
}
}
pub fn resolve(buffer: &Buffer, lists: &Lists, key: char, around: bool) -> Option<ObjectRange> {
let cursor = buffer.cursor;
match key {
'w' => word(buffer, cursor, around, false),
'W' => word(buffer, cursor, around, true),
'p' => paragraph_object(buffer, cursor.line, around),
's' => sentence_object(buffer, cursor, around),
'"' | '\'' | '`' => quote(buffer, cursor, key, around),
'(' | ')' | 'b' => pair(buffer, cursor, '(', ')', around),
'[' | ']' => pair(buffer, cursor, '[', ']', around),
'{' | '}' | 'B' => pair(buffer, cursor, '{', '}', around),
'<' | '>' => pair(buffer, cursor, '<', '>', around),
'l' if around => whole_lines(buffer, lists.item_bounds(cursor.line)?),
'l' => whole_lines(buffer, lists.own_bounds(cursor.line)?),
'L' if around => whole_lines(buffer, lists.level_bounds(cursor.line)?),
_ => None,
}
}
fn word(buffer: &Buffer, cursor: Cursor, around: bool, big: bool) -> Option<ObjectRange> {
let chars: Vec<char> = buffer.line_text(cursor.line).chars().collect();
if chars.is_empty() {
return None;
}
let base = buffer.rope.line_to_char(cursor.line);
let class = |c: char| {
if big {
u8::from(!c.is_whitespace())
} else {
char_class(c)
}
};
let col = cursor.col.min(chars.len() - 1);
let k = class(chars[col]);
let mut s = col;
while s > 0 && class(chars[s - 1]) == k {
s -= 1;
}
let mut e = col + 1;
while e < chars.len() && class(chars[e]) == k {
e += 1;
}
if around {
if k == 0 {
let next = class(*chars.get(e)?);
while e < chars.len() && class(chars[e]) == next {
e += 1;
}
} else {
let mut trailing = e;
while trailing < chars.len() && chars[trailing].is_whitespace() {
trailing += 1;
}
if trailing > e {
e = trailing;
} else {
while s > 0 && chars[s - 1].is_whitespace() {
s -= 1;
}
}
}
}
ObjectRange::chars(base + s, base + e)
}
pub fn paragraph_bounds(buffer: &Buffer, line: usize) -> (usize, usize) {
let blank = |l: usize| buffer.line_text(l).trim().is_empty();
if blank(line) {
return (line, line);
}
let mut s = line;
while s > 0 && !blank(s - 1) {
s -= 1;
}
let last = buffer.last_line();
let mut e = line;
while e < last && !blank(e + 1) {
e += 1;
}
(s, e)
}
fn paragraph_object(buffer: &Buffer, line: usize, around: bool) -> Option<ObjectRange> {
let blank = |l: usize| buffer.line_text(l).trim().is_empty();
let last = buffer.last_line();
let on_blank = blank(line);
let mut s = line;
while s > 0 && blank(s - 1) == on_blank {
s -= 1;
}
let mut e = line;
while e < last && blank(e + 1) == on_blank {
e += 1;
}
if around {
let mut trailing = e;
while trailing < last && blank(trailing + 1) != on_blank {
trailing += 1;
}
if trailing > e {
e = trailing;
} else {
while s > 0 && blank(s - 1) != on_blank {
s -= 1;
}
}
}
whole_lines(buffer, (s, e))
}
fn whole_lines(buffer: &Buffer, (s, e): (usize, usize)) -> Option<ObjectRange> {
let start = buffer.rope.line_to_char(s);
let end = if e < buffer.last_line() {
buffer.rope.line_to_char(e + 1)
} else {
buffer.rope.len_chars()
};
(end > start).then_some(ObjectRange {
start,
end,
linewise: true,
})
}
pub fn sentence_bounds(buffer: &Buffer, cursor: Cursor) -> (usize, usize) {
let (ps, pe) = paragraph_bounds(buffer, cursor.line);
let lo = buffer.rope.line_to_char(ps);
let hi = if pe + 1 < buffer.line_count() {
buffer.rope.line_to_char(pe + 1)
} else {
buffer.rope.len_chars()
};
let chars: Vec<char> = buffer.rope.slice(lo..hi).chars().collect();
let idx = buffer.char_index(cursor).clamp(lo, hi);
let at = |i: usize| chars[i - lo];
let mut s = idx;
while s > lo && !matches!(at(s - 1), '.' | '!' | '?') {
s -= 1;
}
while s < hi && at(s).is_whitespace() {
s += 1;
}
let mut e = idx;
while e < hi {
let terminator = matches!(at(e), '.' | '!' | '?');
e += 1;
if terminator {
break;
}
}
(s.max(lo).min(e), e)
}
fn sentence_object(buffer: &Buffer, cursor: Cursor, around: bool) -> Option<ObjectRange> {
let (s, mut e) = sentence_bounds(buffer, cursor);
if around {
let len = buffer.rope.len_chars();
while e < len && buffer.rope.char(e) == ' ' {
e += 1;
}
}
ObjectRange::chars(s, e)
}
fn quote(buffer: &Buffer, cursor: Cursor, q: char, around: bool) -> Option<ObjectRange> {
let chars: Vec<char> = buffer.line_text(cursor.line).chars().collect();
let base = buffer.rope.line_to_char(cursor.line);
let marks: Vec<usize> = chars
.iter()
.enumerate()
.filter(|(i, c)| **c == q && (*i == 0 || chars[i - 1] != '\\'))
.map(|(i, _)| i)
.collect();
let (open, close) = marks
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0], c[1]))
.find(|(_, close)| cursor.col <= *close)?;
if !around {
return ObjectRange::chars(base + open + 1, base + close);
}
let mut e = close + 1;
while e < chars.len() && chars[e].is_whitespace() {
e += 1;
}
ObjectRange::chars(base + open, base + e)
}
fn pair(
buffer: &Buffer,
cursor: Cursor,
open: char,
close: char,
around: bool,
) -> Option<ObjectRange> {
let rope = &buffer.rope;
let len = rope.len_chars();
let idx = buffer.char_index(cursor).min(len.saturating_sub(1));
if len == 0 {
return None;
}
let start = if rope.char(idx) == open {
idx
} else {
let mut depth = 0usize;
let mut i = idx;
loop {
if i == 0 {
return None;
}
i -= 1;
let c = rope.char(i);
if c == close {
depth += 1;
} else if c == open {
match depth.checked_sub(1) {
Some(d) => depth = d,
None => break i,
}
}
}
};
let mut depth = 0usize;
let mut j = start + 1;
let end = loop {
if j >= len {
return None;
}
let c = rope.char(j);
if c == open {
depth += 1;
} else if c == close {
match depth.checked_sub(1) {
Some(d) => depth = d,
None => break j,
}
}
j += 1;
};
if around {
ObjectRange::chars(start, end + 1)
} else {
ObjectRange::chars(start + 1, end)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::markdown::block::BlockCache;
use ropey::Rope;
fn buf(text: &str, line: usize, col: usize) -> Buffer {
let mut b = Buffer::empty();
b.rope = Rope::from_str(text);
b.cursor = Cursor::new(line, col);
b
}
fn obj(b: &Buffer, key: char, around: bool) -> Option<ObjectRange> {
let cache = BlockCache::build(b);
resolve(b, &Lists::new(b, &cache.kinds, 4), key, around)
}
fn text_of(b: &Buffer, r: ObjectRange) -> String {
b.rope.slice(r.start..r.end).to_string()
}
#[test]
fn inner_and_around_word() {
let b = buf("the quick fox\n", 0, 6); assert_eq!(text_of(&b, obj(&b, 'w', false).unwrap()), "quick");
assert_eq!(text_of(&b, obj(&b, 'w', true).unwrap()), "quick ");
}
#[test]
fn around_word_at_end_of_line_takes_leading_space() {
let b = buf("the quick fox\n", 0, 11);
assert_eq!(text_of(&b, obj(&b, 'w', true).unwrap()), " fox");
}
#[test]
fn word_objects_follow_character_class() {
let b = buf("one, two\n", 0, 3);
assert_eq!(text_of(&b, obj(&b, 'w', false).unwrap()), ",");
let b = buf("one, two\n", 0, 1);
assert_eq!(text_of(&b, obj(&b, 'W', false).unwrap()), "one,");
}
#[test]
fn paragraph_is_linewise_and_around_takes_the_blank_line() {
let b = buf("a one\na two\n\nb one\n", 0, 2);
let inner = obj(&b, 'p', false).unwrap();
assert!(inner.linewise);
assert_eq!(text_of(&b, inner), "a one\na two\n");
assert_eq!(text_of(&b, obj(&b, 'p', true).unwrap()), "a one\na two\n\n");
}
#[test]
fn sentence_stops_at_the_terminator() {
let b = buf("One thing. Two things. Three.\n", 0, 12);
assert_eq!(text_of(&b, obj(&b, 's', false).unwrap()), "Two things.");
assert_eq!(text_of(&b, obj(&b, 's', true).unwrap()), "Two things. ");
}
#[test]
fn quotes_pair_left_to_right() {
let b = buf("say \"hello there\" now\n", 0, 8);
assert_eq!(text_of(&b, obj(&b, '"', false).unwrap()), "hello there");
assert_eq!(text_of(&b, obj(&b, '"', true).unwrap()), "\"hello there\" ");
}
#[test]
fn quotes_found_from_before_them() {
let b = buf("say \"hi\" now\n", 0, 0);
assert_eq!(text_of(&b, obj(&b, '"', false).unwrap()), "hi");
}
#[test]
fn brackets_nest_and_span_lines() {
let b = buf("f(a, g(b),\n c)\n", 0, 3);
assert_eq!(text_of(&b, obj(&b, '(', false).unwrap()), "a, g(b),\n c");
assert_eq!(text_of(&b, obj(&b, '(', true).unwrap()), "(a, g(b),\n c)");
let b = buf("f(a, g(b),\n c)\n", 0, 7);
assert_eq!(text_of(&b, obj(&b, 'b', false).unwrap()), "b");
}
#[test]
fn unmatched_bracket_is_no_object() {
let b = buf("no brackets here\n", 0, 4);
assert!(obj(&b, '(', false).is_none());
}
#[test]
fn empty_pair_has_no_inside() {
let b = buf("f()\n", 0, 2);
assert!(obj(&b, '(', false).is_none());
assert_eq!(text_of(&b, obj(&b, '(', true).unwrap()), "()");
}
#[test]
fn list_item_objects() {
let t = "- a\n - b\n - c\n- d\n";
let b = buf(t, 1, 3);
assert_eq!(text_of(&b, obj(&b, 'l', true).unwrap()), " - b\n");
let b = buf(t, 0, 0);
let al = obj(&b, 'l', true).unwrap();
assert!(al.linewise);
assert_eq!(text_of(&b, al), "- a\n - b\n - c\n");
assert_eq!(text_of(&b, obj(&b, 'l', false).unwrap()), "- a\n");
let b = buf(t, 2, 0);
assert_eq!(text_of(&b, obj(&b, 'L', true).unwrap()), " - b\n - c\n");
assert!(obj(&b, 'L', false).is_none(), "iL is not defined");
}
#[test]
fn list_item_object_at_the_end_of_the_file() {
let b = buf("- a\n- b", 1, 0);
assert_eq!(text_of(&b, obj(&b, 'l', true).unwrap()), "- b");
}
#[test]
fn list_objects_outside_a_list_are_no_object() {
let b = buf("just prose\n", 0, 2);
assert!(obj(&b, 'l', true).is_none());
assert!(obj(&b, 'l', false).is_none());
assert!(obj(&b, 'L', true).is_none());
}
}