use hjkl_engine::abbrev::{Abbrev, AbbrevKind, AbbrevTrigger};
use hjkl_vim_types::{RangeKind, TextObject};
use hjkl_engine::rope_util::{rope_line_to_str, rope_to_lines_vec};
use crate::vim_state::vim;
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_cursor_pos, buf_line, buf_set_cursor_rc};
pub type Pos = (usize, usize);
pub fn text_object_from_char(ch: char) -> Option<TextObject> {
Some(match ch {
'w' => TextObject::Word { big: false },
'W' => TextObject::Word { big: true },
'"' | '\'' | '`' => TextObject::Quote(ch),
'(' | ')' | 'b' => TextObject::Bracket('('),
'[' | ']' => TextObject::Bracket('['),
'{' | '}' | 'B' => TextObject::Bracket('{'),
'<' | '>' => TextObject::Bracket('<'),
'p' => TextObject::Paragraph,
't' => TextObject::XmlTag,
's' => TextObject::Sentence,
_ => return None,
})
}
pub fn text_object_range<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
obj: TextObject,
inner: bool,
count: usize,
) -> Option<(Pos, Pos, RangeKind)> {
match obj {
TextObject::Word { big } => {
word_text_object(ed, inner, big, count).map(|(s, e)| (s, e, RangeKind::Exclusive))
}
TextObject::Quote(q) => {
quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
}
TextObject::Bracket(open) => bracket_text_object(ed, open, inner, count),
TextObject::Paragraph => {
paragraph_text_object(ed, inner, count).map(|(s, e)| (s, e, RangeKind::Linewise))
}
TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
TextObject::Sentence => {
sentence_text_object(ed, inner, count).map(|(s, e)| (s, e, RangeKind::Exclusive))
}
}
}
fn is_sentence_terminator(c: char) -> bool {
matches!(c, '.' | '?' | '!')
}
fn is_sentence_closing(c: char) -> bool {
matches!(c, ')' | ']' | '"' | '\'')
}
pub fn sentence_boundary<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
forward: bool,
) -> Option<(usize, usize)> {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let line_of = |r: usize| -> std::borrow::Cow<'_, str> {
let start = rope.line_to_byte(r);
rope.byte_slice(start..start + hjkl_buffer::rope_line_bytes(&rope, r))
.into()
};
let line_chars = |r: usize| -> Vec<char> { line_of(r).chars().collect() };
let n_lines = if raw_n_lines > 1 && line_chars(raw_n_lines - 1).is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
if n_lines == 0 {
return None;
}
let cursor = ed.cursor();
let cursor = (cursor.0.min(n_lines - 1), cursor.1);
let (cr, cc) = cursor;
if forward {
if let Some(p) = first_sentence_boundary_forward(&line_chars, cr, cc, n_lines) {
return Some(p);
}
let end_col = line_chars(n_lines - 1).len().saturating_sub(1);
let end = (n_lines - 1, end_col);
(end > cursor).then_some(end)
} else {
let mut origin: Option<(usize, bool)> = if cr == 0 {
None
} else {
closest_stopper_below(&line_chars, cr - 1)
};
for r in (0..=cr).rev() {
let lc = line_chars(r);
let blank = lc.is_empty();
let first_ns = lc.iter().position(|&c| !c.is_whitespace());
let (mid, _has_eol) = scan_row_boundaries(r, &lc);
let mut cands: Vec<(usize, usize)> = Vec::new();
cands.extend(mid.into_iter().rev());
if origin.is_some_and(|(_, eol)| eol)
&& let Some(c) = first_ns
{
cands.push((r, c));
}
let prev_blank = r > 0 && line_chars(r - 1).is_empty();
if r > 0 && prev_blank != blank {
cands.push((r, 0));
}
if r == 0 {
cands.push((0, 0));
}
for (row, col) in cands {
if r < cr || col < cc {
return Some((row, col));
}
}
origin = if r > 0 {
if row_skippable(&line_chars(r - 1)) {
origin
} else if r >= 2 {
closest_stopper_below(&line_chars, r - 2)
} else {
None
}
} else {
None
};
}
None
}
}
fn first_sentence_boundary_forward<F: Fn(usize) -> Vec<char>>(
line_chars: &F,
cr: usize,
cc: usize,
n_lines: usize,
) -> Option<(usize, usize)> {
let mut stopper_eol = if cr == 0 {
false
} else {
closest_stopper_below(line_chars, cr - 1).is_some_and(|(_, eol)| eol)
};
let mut prev_blank = cr > 0 && line_chars(cr - 1).is_empty();
for r in cr..n_lines {
let lc = line_chars(r);
let blank = lc.is_empty();
let first_ns = lc.iter().position(|&c| !c.is_whitespace());
let mut cands: Vec<(usize, usize)> = Vec::new();
if r > 0 && prev_blank != blank {
cands.push((r, 0));
}
if stopper_eol && let Some(c) = first_ns {
cands.push((r, c));
}
let (mid, has_eol) = scan_row_boundaries(r, &lc);
cands.extend(mid);
for (row, col) in cands {
if r > cr || col > cc {
return Some((row, col));
}
}
prev_blank = blank;
stopper_eol = if first_ns.is_some() || blank {
has_eol
} else {
stopper_eol
};
}
None
}
fn row_skippable(line: &[char]) -> bool {
!line.is_empty() && line.iter().all(|&c| c.is_whitespace())
}
fn closest_stopper_below<F: Fn(usize) -> Vec<char>>(
line_chars: &F,
mut p: usize,
) -> Option<(usize, bool)> {
while p > 0 && row_skippable(&line_chars(p)) {
p -= 1;
}
(!row_skippable(&line_chars(p))).then(|| (p, row_has_eol_walk(line_chars, p)))
}
fn row_has_eol_walk<F: Fn(usize) -> Vec<char>>(line_chars: &F, r: usize) -> bool {
scan_row_boundaries(r, &line_chars(r)).1
}
fn scan_row_boundaries(r: usize, line: &[char]) -> (Vec<(usize, usize)>, bool) {
let lc = line;
let mut mid: Vec<(usize, usize)> = Vec::new();
let mut has_eol = false;
let mut i = 0;
while i < lc.len() {
if is_sentence_terminator(lc[i]) {
let mut j = i;
while j + 1 < lc.len() && is_sentence_terminator(lc[j + 1]) {
j += 1;
}
let mut k = j;
while k + 1 < lc.len() && is_sentence_closing(lc[k + 1]) {
k += 1;
}
if k + 1 < lc.len() {
if lc[k + 1].is_whitespace() {
let mut c = k + 1;
while c < lc.len() && lc[c].is_whitespace() {
c += 1;
}
if c < lc.len() {
mid.push((r, c));
} else {
has_eol = true;
}
}
i = k + 1;
} else {
has_eol = true;
break;
}
} else {
i += 1;
}
}
(mid, has_eol)
}
pub fn reverse_visual_block_sentence_landing<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
count: usize,
) -> Option<Pos> {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let mut lines: Vec<Vec<char>> = (0..raw_n_lines)
.map(|row| rope_line_to_str(&rope, row).chars().collect())
.collect();
if lines.len() > 1 && lines.last().is_some_and(Vec::is_empty) {
lines.pop();
}
let n_lines = lines.len();
if n_lines == 0 {
return None;
}
let cursor = (ed.cursor().0.min(n_lines - 1), ed.cursor().1);
let mut starts: Vec<Pos> = sentence_boundaries(&lines, n_lines)
.into_iter()
.filter(|&(row, col)| lines[row].get(col).is_some_and(|ch| !ch.is_whitespace()))
.collect();
for row in 1..n_lines {
let Some(col) = lines[row].iter().position(|ch| !ch.is_whitespace()) else {
continue;
};
let mut previous = row - 1;
while previous > 0 && row_skippable(&lines[previous]) {
previous -= 1;
}
let mut tail = &lines[previous][..];
while tail
.last()
.is_some_and(|ch| ch.is_whitespace() || is_sentence_closing(*ch))
{
tail = &tail[..tail.len() - 1];
}
if lines[previous].is_empty() || tail.last().is_some_and(|ch| is_sentence_terminator(*ch)) {
starts.push((row, col));
}
}
starts.sort_unstable();
starts.dedup();
let mut index = starts.iter().rposition(|&start| start <= cursor)?;
if starts[index] == cursor && index > 0 && starts[index - 1].0 < cursor.0 {
index -= 1;
}
let current = starts[index];
let separator_start = |(row, col): Pos| {
let line = &lines[row];
let mut start = col;
while start > 0 && line[start - 1].is_whitespace() {
start -= 1;
}
(start < col).then_some((row, start))
};
let mut landings = Vec::new();
if inner {
landings.push(current);
} else {
landings.push(separator_start(current).unwrap_or(current));
}
let mut last = current;
while index > 0 && starts[index - 1].0 == current.0 {
index -= 1;
if inner {
if let Some(separator) = separator_start(last) {
landings.push(separator);
}
landings.push(starts[index]);
} else if let Some(separator) = separator_start(starts[index]) {
landings.push(separator);
} else {
landings.push(starts[index]);
}
last = starts[index];
}
let count = count.max(1);
if let Some(&landing) = landings.get(count - 1) {
return Some(landing);
}
let mut remaining = count - landings.len();
while remaining > 0 && index > 0 {
index -= 1;
remaining -= 1;
}
starts.get(index).copied()
}
#[cfg_attr(not(test), allow(dead_code))]
fn sentence_boundaries(lines: &[Vec<char>], n_lines: usize) -> Vec<(usize, usize)> {
let mut out = vec![(0usize, 0usize)];
for (row, line) in lines.iter().enumerate().take(n_lines) {
let mut i = 0;
while i < line.len() {
if is_sentence_terminator(line[i]) {
let mut j = i;
while j + 1 < line.len() && is_sentence_terminator(line[j + 1]) {
j += 1;
}
let mut k = j;
while k + 1 < line.len() && is_sentence_closing(line[k + 1]) {
k += 1;
}
if k + 1 < line.len() {
if line[k + 1].is_whitespace()
&& let Some(p) = skip_sentence_ws(lines, n_lines, row, k + 1)
{
out.push(p);
}
i = k + 1;
continue;
}
if let Some(p) = skip_sentence_ws(lines, n_lines, row, line.len()) {
out.push(p);
}
break;
}
i += 1;
}
if row + 1 < n_lines {
let now_blank = lines[row].is_empty();
let next_blank = lines[row + 1].is_empty();
if now_blank != next_blank {
out.push((row + 1, 0));
}
}
}
out.sort_unstable();
out.dedup();
out
}
#[cfg_attr(not(test), allow(dead_code))]
fn skip_sentence_ws(
lines: &[Vec<char>],
n_lines: usize,
mut row: usize,
mut col: usize,
) -> Option<(usize, usize)> {
loop {
if col < lines[row].len() {
if lines[row][col].is_whitespace() {
col += 1;
continue;
}
return Some((row, col));
}
if row + 1 >= n_lines {
return None;
}
let was_blank = lines[row].is_empty();
row += 1;
col = 0;
let now_blank = lines[row].is_empty();
if now_blank != was_blank {
return Some((row, 0));
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn end_of_buffer_pos(lines: &[Vec<char>], n_lines: usize) -> (usize, usize) {
let last = n_lines - 1;
let col = lines[last].len().saturating_sub(1);
(last, col)
}
#[derive(Debug, PartialEq, Eq)]
pub enum SentenceStep {
Boundary((usize, usize)),
EndOfBuffer((usize, usize)),
AtEnd,
}
pub fn sentence_step_forward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> SentenceStep {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return SentenceStep::AtEnd;
}
let line_of = |r: usize| -> std::borrow::Cow<'_, str> {
let start = rope.line_to_byte(r);
rope.byte_slice(start..start + hjkl_buffer::rope_line_bytes(&rope, r))
.into()
};
let line_chars = |r: usize| -> Vec<char> { line_of(r).chars().collect() };
let n_lines = if raw_n_lines > 1 && line_chars(raw_n_lines - 1).is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
if n_lines == 0 {
return SentenceStep::AtEnd;
}
let cursor = ed.cursor();
let cursor = (cursor.0.min(n_lines - 1), cursor.1);
if let Some(p) = first_sentence_boundary_forward(&line_chars, cursor.0, cursor.1, n_lines) {
return SentenceStep::Boundary(p);
}
let last_row = line_chars(n_lines - 1);
let end = (n_lines - 1, last_row.len().saturating_sub(1));
if end <= cursor {
return SentenceStep::AtEnd;
}
let mut tail: &[char] = &last_row;
while tail.last().is_some_and(|c| c.is_whitespace()) {
tail = &tail[..tail.len() - 1];
}
while tail.last().is_some_and(|c| is_sentence_closing(*c)) {
tail = &tail[..tail.len() - 1];
}
if tail.last().is_some_and(|c| is_sentence_terminator(*c)) {
SentenceStep::Boundary(end)
} else {
SentenceStep::EndOfBuffer(end)
}
}
pub fn sentence_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
count: usize,
) -> Option<((usize, usize), (usize, usize))> {
let count = count.max(1);
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let cursor = ed.cursor();
let win_lo = cursor.0.saturating_sub(SENTENCE_WINDOW_ROWS);
let win_hi = (cursor.0 + SENTENCE_WINDOW_ROWS).min(raw_n_lines - 1);
let line_of = |r: usize| -> std::borrow::Cow<'_, str> {
let start = rope.line_to_byte(r);
rope.byte_slice(start..start + hjkl_buffer::rope_line_bytes(&rope, r))
.into()
};
let line_chars = |r: usize| -> Vec<char> { line_of(r).chars().collect() };
let line_len = |r: usize| -> usize { line_of(r).chars().count() };
let win_off = rope.line_to_char(win_lo);
let (win_lens, chars, last_content) =
window_flat(raw_n_lines, win_lo, win_hi, &line_len, &line_chars);
let flat_len = chars.len();
let whole_buffer = win_lo == 0 && win_hi >= last_content;
if flat_len == 0 {
return if whole_buffer {
None
} else {
sentence_text_object_full(ed, inner, count)
};
}
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (i, &len) in win_lens.iter().enumerate() {
if idx <= len {
return (win_lo + i, idx);
}
idx -= len + 1;
}
let last = win_lens.len() - 1;
(win_lo + last, win_lens[last])
};
let cursor_idx = (rope.line_to_char(cursor.0) + cursor.1 - win_off).min(flat_len - 1);
let Some((start, end, clipped)) =
sentence_text_object_on_chars(&chars, cursor_idx, inner, count)
else {
return if whole_buffer {
None
} else {
sentence_text_object_full(ed, inner, count)
};
};
if clipped && !whole_buffer {
return sentence_text_object_full(ed, inner, count);
}
Some((idx_to_pos(start), idx_to_pos(end)))
}
fn sentence_text_object_full<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
count: usize,
) -> Option<((usize, usize), (usize, usize))> {
let count = count.max(1);
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let n_lines = rope.len_lines();
if n_lines == 0 {
return None;
}
let line_lens: Vec<usize> = (0..n_lines)
.map(|r| rope_line_to_str(&rope, r).chars().count())
.collect();
let pos_to_idx = |pos: (usize, usize)| -> usize {
let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
idx + pos.1
};
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (r, &len) in line_lens.iter().enumerate() {
if idx <= len {
return (r, idx);
}
idx -= len + 1;
}
let last = n_lines.saturating_sub(1);
(last, line_lens[last])
};
let mut chars: Vec<char> = rope.chars().collect();
if chars.last() == Some(&'\n') {
chars.pop();
}
if chars.is_empty() {
return None;
}
let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
let (start, end, _) = sentence_text_object_on_chars(&chars, cursor_idx, inner, count)?;
Some((idx_to_pos(start), idx_to_pos(end)))
}
fn sentence_decl(chars: &[char], i: usize) -> Option<usize> {
if i == 0 {
None
} else if chars[i - 1] == '\n' {
i.checked_sub(2)
} else {
Some(i - 1)
}
}
fn sentence_incl(chars: &[char], i: usize) -> Option<usize> {
let n = chars.len();
if i + 1 >= n {
None
} else if chars[i + 1] == '\n' {
if i + 2 >= n { None } else { Some(i + 2) }
} else {
Some(i + 1)
}
}
fn sentence_first_blank(chars: &[char], i: usize) -> usize {
let mut p = i;
while let Some(q) = sentence_decl(chars, p) {
if chars[q] == ' ' || chars[q] == '\t' {
p = q;
} else {
break;
}
}
p
}
fn sentence_next_start(chars: &[char], pos: usize, clipped: &mut bool) -> usize {
let len = chars.len();
if pos >= len {
*clipped = true;
return len;
}
let is_ws = |c: char| c == ' ' || c == '\t';
let is_punct = |c: char| matches!(c, '.' | '!' | '?' | ')' | ']' | '"' | '\'');
let is_term = |c: char| matches!(c, '.' | '!' | '?');
let is_close = |c: char| matches!(c, ')' | ']' | '"' | '\'');
let mut p = pos;
let mut found_dot = false;
loop {
let c = chars[p];
if !is_ws(c) && !is_punct(c) {
break;
}
let Some(tp) = sentence_decl(chars, p) else {
break;
};
if found_dot {
break;
}
if is_term(c) {
found_dot = true;
}
if is_close(c) && !is_punct(chars[tp]) {
break;
}
p = tp;
}
let mut e = p;
while e < len {
let c = chars[e];
if is_term(c) {
let mut t = e;
while t + 1 < len && is_term(chars[t + 1]) {
t += 1;
}
while t + 1 < len && is_close(chars[t + 1]) {
t += 1;
}
if t + 1 >= len {
return len;
}
let n = chars[t + 1];
if is_ws(n) {
let mut k = t + 1;
while k < len && is_ws(chars[k]) {
k += 1;
}
return k;
} else if n == '\n' {
let mut k = t + 2;
while k < len && is_ws(chars[k]) {
k += 1;
}
return k;
}
e = t + 1;
} else {
e += 1;
}
}
*clipped = true;
len
}
fn sentence_at_line_leading_ws(chars: &[char], i: usize) -> bool {
let mut ls = i;
while ls > 0 && chars[ls - 1] != '\n' {
ls -= 1;
}
(ls..i).all(|k| chars[k] == ' ' || chars[k] == '\t')
}
fn sentence_text_object_on_chars(
chars: &[char],
cursor_idx: usize,
inner: bool,
count: usize,
) -> Option<(usize, usize, bool)> {
let len = chars.len();
if len == 0 {
return None;
}
let count = count.max(1);
let cursor_idx = cursor_idx.min(len - 1);
let is_ws = |c: char| c == ' ' || c == '\t' || c == '\n';
let is_term = |c: char| matches!(c, '.' | '?' | '!');
let mut clipped = false;
let mut blank = false;
if is_ws(chars[cursor_idx]) {
let mut k = cursor_idx;
while k > 0 && is_ws(chars[k - 1]) {
k -= 1;
}
if k > 0 && is_term(chars[k - 1]) {
blank = true;
}
}
let start = if blank {
sentence_first_blank(chars, cursor_idx)
} else {
let mut s = cursor_idx;
while s > 0 {
let prev = chars[s - 1];
if is_ws(prev) {
let mut k = s - 1;
while k > 0 && is_ws(chars[k - 1]) {
k -= 1;
}
if k > 0 && is_term(chars[k - 1]) {
break;
}
}
s -= 1;
}
if s == 0 {
clipped = true;
}
while s < len && is_ws(chars[s]) {
s += 1;
}
if s >= len {
return None;
}
s
};
let ncount = if !inner {
count * 2
} else if blank {
count - 1
} else {
count
};
let mut pos = if blank {
sentence_next_start(chars, cursor_idx, &mut clipped)
} else {
start
};
if ncount == 0 {
pos = sentence_decl(chars, pos).unwrap_or(pos);
}
let mut at_start_sent = true;
let mut remaining = ncount;
while remaining > 0 {
pos = sentence_next_start(chars, pos, &mut clipped);
if at_start_sent {
pos = sentence_first_blank(chars, pos);
}
if remaining == 1 || at_start_sent {
match sentence_decl(chars, pos) {
Some(d) => pos = d,
None => clipped = true,
}
}
at_start_sent = !at_start_sent;
remaining -= 1;
}
let raw_end = sentence_incl(chars, pos).unwrap_or(len);
let at_line_start = raw_end > 0 && chars[raw_end - 1] == '\n';
let end = if at_line_start && !sentence_at_line_leading_ws(chars, start) {
raw_end - 1
} else {
raw_end
};
Some((start, end, clipped))
}
pub fn tag_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
) -> Option<((usize, usize), (usize, usize))> {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let cursor = ed.cursor();
let win_lo = cursor.0.saturating_sub(TAG_WINDOW_ROWS);
let win_hi = (cursor.0 + TAG_WINDOW_ROWS).min(raw_n_lines - 1);
let line_of = |r: usize| -> std::borrow::Cow<'_, str> {
let start = rope.line_to_byte(r);
rope.byte_slice(start..start + hjkl_buffer::rope_line_bytes(&rope, r))
.into()
};
let line_chars = |r: usize| -> Vec<char> { line_of(r).chars().collect() };
let line_len = |r: usize| -> usize { line_of(r).chars().count() };
let win_off = rope.line_to_char(win_lo);
let (win_lens, chars, last_content) =
window_flat(raw_n_lines, win_lo, win_hi, &line_len, &line_chars);
let flat_len = chars.len();
let whole_buffer = win_lo == 0 && win_hi >= last_content;
if flat_len == 0 {
return if whole_buffer {
None
} else {
tag_text_object_full(ed, inner)
};
}
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (i, &len) in win_lens.iter().enumerate() {
if idx <= len {
return (win_lo + i, idx);
}
idx -= len + 1;
}
let last = win_lens.len() - 1;
(win_lo + last, win_lens[last])
};
let cursor_idx = rope.line_to_char(cursor.0) + cursor.1 - win_off;
let mut stack: Vec<(usize, usize, String)> = Vec::new(); let mut innermost: Option<(usize, usize, usize, usize)> = None;
let mut next_after: Option<(usize, usize, usize, usize)> = None;
let mut i = 0;
while i < flat_len {
if chars[i] != '<' {
i += 1;
continue;
}
let mut j = i + 1;
while j < flat_len && chars[j] != '>' {
j += 1;
}
if j >= flat_len {
break;
}
let inside: String = chars[i + 1..j].iter().collect();
let close_end = j + 1;
let trimmed = inside.trim();
if trimmed.starts_with('!') || trimmed.starts_with('?') {
i = close_end;
continue;
}
if let Some(rest) = trimmed.strip_prefix('/') {
let name = rest.split_whitespace().next().unwrap_or("").to_string();
if !name.is_empty()
&& let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
{
let (open_start, content_start, _) = stack[stack_idx].clone();
stack.truncate(stack_idx);
let content_end = i;
let candidate = (open_start, content_start, content_end, close_end);
if cursor_idx >= open_start && cursor_idx < close_end {
innermost = match innermost {
Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
Some(candidate)
}
None => Some(candidate),
existing => existing,
};
} else if open_start >= cursor_idx && next_after.is_none() {
next_after = Some(candidate);
}
}
} else if !trimmed.ends_with('/') {
let name: String = trimmed
.split(|c: char| c.is_whitespace() || c == '/')
.next()
.unwrap_or("")
.to_string();
if !name.is_empty() {
stack.push((i, close_end, name));
}
}
i = close_end;
}
let Some((open_start, content_start, content_end, close_end)) = innermost.or(next_after) else {
return tag_text_object_full(ed, inner);
};
let open_row = idx_to_pos(open_start).0;
let close_row = idx_to_pos(close_end).0;
if (open_row == win_lo && win_lo > 0) || (close_row >= win_hi && win_hi < raw_n_lines - 1) {
return tag_text_object_full(ed, inner);
}
if inner {
Some((idx_to_pos(content_start), idx_to_pos(content_end)))
} else {
Some((idx_to_pos(open_start), idx_to_pos(close_end)))
}
}
fn tag_text_object_full<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
) -> Option<((usize, usize), (usize, usize))> {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let n_lines = rope.len_lines();
if n_lines == 0 {
return None;
}
let line_lens: Vec<usize> = (0..n_lines)
.map(|r| rope_line_to_str(&rope, r).chars().count())
.collect();
let pos_to_idx = |pos: (usize, usize)| -> usize {
let idx: usize = line_lens.iter().take(pos.0).map(|&len| len + 1).sum();
idx + pos.1
};
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (r, &len) in line_lens.iter().enumerate() {
if idx <= len {
return (r, idx);
}
idx -= len + 1;
}
let last = n_lines.saturating_sub(1);
(last, line_lens[last])
};
let mut chars: Vec<char> = rope.chars().collect();
if chars.last() == Some(&'\n') {
chars.pop();
}
let cursor_idx = pos_to_idx(ed.cursor());
let mut stack: Vec<(usize, usize, String)> = Vec::new(); let mut innermost: Option<(usize, usize, usize, usize)> = None;
let mut next_after: Option<(usize, usize, usize, usize)> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] != '<' {
i += 1;
continue;
}
let mut j = i + 1;
while j < chars.len() && chars[j] != '>' {
j += 1;
}
if j >= chars.len() {
break;
}
let inside: String = chars[i + 1..j].iter().collect();
let close_end = j + 1;
let trimmed = inside.trim();
if trimmed.starts_with('!') || trimmed.starts_with('?') {
i = close_end;
continue;
}
if let Some(rest) = trimmed.strip_prefix('/') {
let name = rest.split_whitespace().next().unwrap_or("").to_string();
if !name.is_empty()
&& let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
{
let (open_start, content_start, _) = stack[stack_idx].clone();
stack.truncate(stack_idx);
let content_end = i;
let candidate = (open_start, content_start, content_end, close_end);
if cursor_idx >= open_start && cursor_idx < close_end {
innermost = match innermost {
Some((os, _, _, ce)) if os <= open_start && close_end <= ce => {
Some(candidate)
}
None => Some(candidate),
existing => existing,
};
} else if open_start >= cursor_idx && next_after.is_none() {
next_after = Some(candidate);
}
}
} else if !trimmed.ends_with('/') {
let name: String = trimmed
.split(|c: char| c.is_whitespace() || c == '/')
.next()
.unwrap_or("")
.to_string();
if !name.is_empty() {
stack.push((i, close_end, name));
}
}
i = close_end;
}
let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
if inner {
Some((idx_to_pos(content_start), idx_to_pos(content_end)))
} else {
Some((idx_to_pos(open_start), idx_to_pos(close_end)))
}
}
const SENTENCE_WINDOW_ROWS: usize = 200;
const TAG_WINDOW_ROWS: usize = 50;
fn window_flat<F: Fn(usize) -> usize, G: Fn(usize) -> Vec<char>>(
raw_n_lines: usize,
win_lo: usize,
win_hi: usize,
line_len: &F,
line_chars: &G,
) -> (Vec<usize>, Vec<char>, usize) {
let last_content = if raw_n_lines > 1 && line_len(raw_n_lines - 1) == 0 {
raw_n_lines - 2
} else {
raw_n_lines - 1
};
let hi = win_hi.min(last_content);
let mut win_lens: Vec<usize> = (win_lo..=hi).map(line_len).collect();
let mut flat: Vec<char> = Vec::new();
for r in win_lo..=hi {
flat.extend(line_chars(r));
if r < hi {
flat.push('\n');
}
}
if hi < last_content {
flat.push('\n');
win_lens.push(line_len(hi + 1));
}
(win_lens, flat, last_content)
}
pub fn is_wordchar(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub use hjkl_buffer::is_keyword_char;
pub fn abbrev_kind(lhs: &str, iskeyword: &str) -> AbbrevKind {
let chars: Vec<char> = lhs.chars().collect();
if chars.is_empty() {
return AbbrevKind::NonKw;
}
let last = *chars.last().unwrap();
let last_is_kw = is_keyword_char(last, iskeyword);
if !last_is_kw {
return AbbrevKind::NonKw;
}
let all_kw = chars.iter().all(|&c| is_keyword_char(c, iskeyword));
if all_kw {
AbbrevKind::Full
} else {
AbbrevKind::End
}
}
pub fn try_abbrev_expand(
abbrevs: &[Abbrev],
line_before: &str,
mincol: usize,
trigger: AbbrevTrigger,
iskeyword: &str,
) -> Option<(usize, String)> {
let chars: Vec<char> = line_before.chars().collect();
let cursor_col = chars.len();
for abbrev in abbrevs {
if !abbrev.insert {
continue;
}
let lhs_chars: Vec<char> = abbrev.lhs.chars().collect();
if lhs_chars.is_empty() {
continue;
}
let lhs_len = lhs_chars.len();
let kind = abbrev_kind(&abbrev.lhs, iskeyword);
match kind {
AbbrevKind::Full | AbbrevKind::End => {
let trigger_char_is_kw = match trigger {
AbbrevTrigger::NonKeyword(c) => is_keyword_char(c, iskeyword),
AbbrevTrigger::CtrlBracket | AbbrevTrigger::Cr | AbbrevTrigger::Esc => false,
};
if trigger_char_is_kw {
continue;
}
}
AbbrevKind::NonKw => {
match trigger {
AbbrevTrigger::Cr | AbbrevTrigger::Esc | AbbrevTrigger::CtrlBracket => {}
AbbrevTrigger::NonKeyword(_) => continue,
}
}
}
if cursor_col < lhs_len {
continue;
}
let lhs_start_col = cursor_col - lhs_len;
if lhs_start_col < mincol {
continue;
}
let text_slice: &[char] = &chars[lhs_start_col..cursor_col];
if text_slice != lhs_chars.as_slice() {
continue;
}
if lhs_start_col > 0 {
let ch_before = chars[lhs_start_col - 1];
match kind {
AbbrevKind::Full => {
if is_keyword_char(ch_before, iskeyword) {
continue; }
if lhs_len == 1 && ch_before != ' ' && ch_before != '\t' {
continue;
}
}
AbbrevKind::End => {
}
AbbrevKind::NonKw => {
if ch_before != ' ' && ch_before != '\t' {
continue;
}
}
}
}
return Some((lhs_len, abbrev.rhs.clone()));
}
None
}
pub fn check_and_apply_abbrev<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
trigger: AbbrevTrigger,
) -> bool {
use hjkl_buffer::{Edit, Position};
let cursor = buf_cursor_pos(ed.buffer());
let row = cursor.row;
let col = cursor.col;
let line_before: String = {
let line = buf_line(ed.buffer(), row).unwrap_or_default();
line.chars().take(col).collect()
};
let (mincol, on_start_row) = if let Some(ref s) = vim(ed).insert_session {
if row == s.start_row {
(s.start_col, true)
} else {
(0, false)
}
} else {
(0, false)
};
if on_start_row && col <= mincol {
return false;
}
let iskeyword = ed.settings().iskeyword.clone();
let abbrevs = ed.abbrevs();
let Some((lhs_len, rhs)) =
try_abbrev_expand(&abbrevs, &line_before, mincol, trigger, &iskeyword)
else {
return false;
};
let lhs_start = col.saturating_sub(lhs_len);
if lhs_len > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(row, lhs_start),
end: Position::new(row, col),
kind: hjkl_buffer::MotionKind::Char,
});
}
let insert_pos = Position::new(row, lhs_start);
if !rhs.is_empty() {
ed.mutate_edit(Edit::InsertStr {
at: insert_pos,
text: rhs.clone(),
});
}
let new_col = lhs_start + rhs.chars().count();
buf_set_cursor_rc(ed.buffer_mut(), row, new_col);
true
}
pub fn word_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
big: bool,
count: usize,
) -> Option<((usize, usize), (usize, usize))> {
let count = count.max(1);
let (row, col) = ed.cursor();
let line = buf_line(ed.buffer(), row)?;
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return None;
}
let len = chars.len();
let at = col.min(len.saturating_sub(1));
let classify = |c: char| -> u8 {
if c.is_whitespace() {
0
} else if big || is_wordchar(c) {
1
} else {
2
}
};
let cls = classify(chars[at]);
let mut start = at;
while start > 0 && classify(chars[start - 1]) == cls {
start -= 1;
}
let mut end = at;
while end + 1 < len && classify(chars[end + 1]) == cls {
end += 1;
}
let mut start_col = start;
let end_col;
if inner {
let mut rem = count - 1;
while rem > 0 && end + 1 < len {
let next_kind = classify(chars[end + 1]);
end += 1;
while end + 1 < len && classify(chars[end + 1]) == next_kind {
end += 1;
}
rem -= 1;
}
end_col = end + 1;
} else if cls == 0 {
let mut e = end;
let mut rem = count;
while rem > 0 && e + 1 < len {
while e + 1 < len && chars[e + 1].is_whitespace() {
e += 1;
}
if e + 1 >= len {
break;
}
e += 1;
let k = classify(chars[e]);
while e + 1 < len && classify(chars[e + 1]) == k {
e += 1;
}
rem -= 1;
}
end_col = e + 1;
} else {
let mut e = end;
let mut words_done = 1;
let mut included_trailing = false;
loop {
let mut t = e + 1;
let mut got_ws = false;
while t < len && chars[t].is_whitespace() {
got_ws = true;
t += 1;
}
if words_done == count {
if got_ws {
e = t - 1;
included_trailing = true;
}
break;
}
if t >= len {
break; }
e = t;
let k = classify(chars[e]);
while e + 1 < len && classify(chars[e + 1]) == k {
e += 1;
}
words_done += 1;
}
end_col = e + 1;
if !included_trailing {
let mut s = start;
while s > 0 && chars[s - 1].is_whitespace() {
s -= 1;
}
start_col = s;
}
}
Some(((row, start_col), (row, end_col)))
}
pub fn quote_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
q: char,
inner: bool,
) -> Option<((usize, usize), (usize, usize))> {
let (row, col) = ed.cursor();
let line = buf_line(ed.buffer(), row)?;
let chars: Vec<char> = line.chars().collect();
let mut positions: Vec<usize> = Vec::new();
for (i, &c) in chars.iter().enumerate() {
if c == q {
positions.push(i);
}
}
if positions.len() < 2 {
return None;
}
let mut open_idx: Option<usize> = None;
let mut close_idx: Option<usize> = None;
for pair in positions.chunks(2) {
if pair.len() < 2 {
break;
}
if col >= pair[0] && col <= pair[1] {
open_idx = Some(pair[0]);
close_idx = Some(pair[1]);
break;
}
if col < pair[0] {
open_idx = Some(pair[0]);
close_idx = Some(pair[1]);
break;
}
}
let open = open_idx?;
let close = close_idx?;
if inner {
if close <= open + 1 {
return None;
}
Some(((row, open + 1), (row, close)))
} else {
let after_close = close + 1; if after_close < chars.len() && chars[after_close].is_ascii_whitespace() {
let mut end = after_close;
while end < chars.len() && chars[end].is_ascii_whitespace() {
end += 1;
}
Some(((row, open), (row, end)))
} else if open > 0 && chars[open - 1].is_ascii_whitespace() {
let mut start = open;
while start > 0 && chars[start - 1].is_ascii_whitespace() {
start -= 1;
}
Some(((row, start), (row, close + 1)))
} else {
Some(((row, open), (row, close + 1)))
}
}
}
pub fn bracket_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
open: char,
inner: bool,
count: usize,
) -> Option<(Pos, Pos, RangeKind)> {
let close = match open {
'(' => ')',
'[' => ']',
'{' => '}',
'<' => '>',
_ => return None,
};
let (row, col) = ed.cursor();
let lines = rope_to_lines_vec(&hjkl_engine::types::Query::rope(ed.buffer()));
let lines = lines.as_slice();
let cursor_char = lines.get(row).and_then(|l| l.chars().nth(col));
let (open_pos, close_pos) = if cursor_char == Some(close) {
let open_pos = if col > 0 {
find_open_bracket(lines, row, col - 1, open, close)
} else if row > 0 {
let pr = row - 1;
let pc = lines[pr].chars().count().saturating_sub(1);
find_open_bracket(lines, pr, pc, open, close)
} else {
None
}?;
(open_pos, (row, col))
} else {
let open_pos = find_open_bracket(lines, row, col, open, close)
.or_else(|| find_next_open(lines, row, col, open))?;
let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
(open_pos, close_pos)
};
let (open_pos, close_pos) = {
let (mut op, mut cp) = (open_pos, close_pos);
for _ in 1..count.max(1) {
let outer = if op.1 > 0 {
find_open_bracket(lines, op.0, op.1 - 1, open, close)
} else if op.0 > 0 {
let pr = op.0 - 1;
let pc = lines[pr].chars().count().saturating_sub(1);
find_open_bracket(lines, pr, pc, open, close)
} else {
None
};
let oo = outer?;
let oc = find_close_bracket(lines, oo.0, oo.1 + 1, open, close)?;
op = oo;
cp = oc;
}
(op, cp)
};
if inner {
let open_line_len = lines[open_pos.0].chars().count();
let inner_start = if open_pos.1 + 1 >= open_line_len && open_pos.0 + 1 < lines.len() {
(open_pos.0 + 1, 0)
} else {
advance_pos(lines, open_pos)
};
if inner_start.0 > close_pos.0
|| (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
{
return Some((inner_start, inner_start, RangeKind::Exclusive));
}
if close_pos.0 > open_pos.0 {
let mut saw_ws = false;
let mut saw_other = false;
for r in inner_start.0..=close_pos.0 {
let line: Vec<char> = lines
.get(r)
.map(|l| l.chars().collect())
.unwrap_or_default();
let from = if r == inner_start.0 { inner_start.1 } else { 0 };
let to = if r == close_pos.0 {
close_pos.1
} else {
line.len()
};
for &c in line
.iter()
.take(to.min(line.len()))
.skip(from.min(line.len()))
{
if c == ' ' || c == '\t' {
saw_ws = true;
} else {
saw_other = true;
}
}
}
if saw_ws && !saw_other {
return Some((inner_start, inner_start, RangeKind::Exclusive));
}
}
Some((inner_start, close_pos, RangeKind::Exclusive))
} else {
Some((
open_pos,
advance_pos(lines, close_pos),
RangeKind::Exclusive,
))
}
}
pub fn find_open_bracket(
lines: &[String],
row: usize,
col: usize,
open: char,
close: char,
) -> Option<(usize, usize)> {
let mut depth: i32 = 0;
let mut r = row;
let mut c = col as isize;
loop {
let cur = &lines[r];
let chars: Vec<char> = cur.chars().collect();
if (c as usize) >= chars.len() {
c = chars.len() as isize - 1;
}
while c >= 0 {
let ch = chars[c as usize];
if ch == close {
depth += 1;
} else if ch == open {
if depth == 0 {
return Some((r, c as usize));
}
depth -= 1;
}
c -= 1;
}
if r == 0 {
return None;
}
r -= 1;
c = lines[r].chars().count() as isize - 1;
}
}
pub fn find_close_bracket(
lines: &[String],
row: usize,
start_col: usize,
open: char,
close: char,
) -> Option<(usize, usize)> {
let mut depth: i32 = 0;
let mut r = row;
let mut c = start_col;
loop {
let cur = &lines[r];
let chars: Vec<char> = cur.chars().collect();
while c < chars.len() {
let ch = chars[c];
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
return Some((r, c));
}
depth -= 1;
}
c += 1;
}
if r + 1 >= lines.len() {
return None;
}
r += 1;
c = 0;
}
}
pub fn find_next_open(
lines: &[String],
row: usize,
col: usize,
open: char,
) -> Option<(usize, usize)> {
let mut r = row;
let mut c = col;
while r < lines.len() {
let chars: Vec<char> = lines[r].chars().collect();
while c < chars.len() {
if chars[c] == open {
return Some((r, c));
}
c += 1;
}
r += 1;
c = 0;
}
None
}
pub fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
let (r, c) = pos;
let line_len = lines[r].chars().count();
if c < line_len {
(r, c + 1)
} else if r + 1 < lines.len() {
(r + 1, 0)
} else {
pos
}
}
pub fn paragraph_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
inner: bool,
count: usize,
) -> Option<((usize, usize), (usize, usize))> {
let count = count.max(1);
let (row, _) = ed.cursor();
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let n_lines = if raw_n_lines > 1 && rope_line_to_str(&rope, raw_n_lines - 1).is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
if n_lines == 0 {
return None;
}
let is_blank = |r: usize| -> bool {
if r >= n_lines {
return true;
}
rope_line_to_str(&rope, r).trim().is_empty()
};
if !inner {
let white = |lnum: usize| is_blank(lnum - 1);
let mut start = row + 1;
let white_in_front = white(start);
while start > 1 {
if white_in_front {
if !white(start - 1) {
break;
}
} else if white(start - 1) {
break;
}
start -= 1;
}
let mut end = start;
while end <= n_lines && white(end) {
end += 1;
}
end -= 1;
let mut i = count;
while i > 0 {
i -= 1;
if end == n_lines {
return None;
}
end += 1;
while end < n_lines && !white(end + 1) {
end += 1;
}
if i == 0 && white_in_front {
break;
}
while end < n_lines && white(end + 1) {
end += 1;
}
}
if !white_in_front && !white(end) {
while start > 1 && white(start - 1) {
start -= 1;
}
}
let end_col = rope_line_to_str(&rope, end - 1).chars().count();
return Some(((start - 1, 0), (end - 1, end_col)));
}
let mut top = row;
let mut bot = row;
if is_blank(row) {
while top > 0 && is_blank(top - 1) {
top -= 1;
}
while bot + 1 < n_lines && is_blank(bot + 1) {
bot += 1;
}
} else {
while top > 0 && !is_blank(top - 1) {
top -= 1;
}
while bot + 1 < n_lines && !is_blank(bot + 1) {
bot += 1;
}
}
let mut rem = count - 1;
while rem > 0 && bot + 1 < n_lines {
let blank_next = is_blank(bot + 1);
bot += 1;
while bot + 1 < n_lines && is_blank(bot + 1) == blank_next {
bot += 1;
}
rem -= 1;
}
if rem > 0 {
return None;
}
let end_col = rope_line_to_str(&rope, bot).chars().count();
Some(((top, 0), (bot, end_col)))
}
pub fn paragraph_extend_landing<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
cursor_row: usize,
anchor_row: usize,
inner: bool,
) -> Option<usize> {
if cursor_row == anchor_row {
return None;
}
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
let n_lines = if raw_n_lines > 1 && rope_line_to_str(&rope, raw_n_lines - 1).is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
let dir: isize = if cursor_row < anchor_row { -1 } else { 1 };
let first = cursor_row as isize + dir;
if first < 0 || first >= n_lines as isize {
return None;
}
let edge = |r: isize| r < 0 || r >= n_lines as isize;
let is_blank = |r: isize| rope_line_to_str(&rope, r as usize).trim().is_empty();
let boundary = if dir < 0 { 0 } else { n_lines as isize - 1 };
let mut land = first;
let mut prev_blank: Option<bool> = None;
for t in 0..2 {
if t == 1 {
land += dir;
if prev_blank == Some(is_blank(land)) {
land -= dir;
break;
}
}
let blank0 = is_blank(land);
while !edge(land + dir) && is_blank(land + dir) == blank0 {
land += dir;
}
if inner || land == boundary {
break;
}
prev_blank = Some(blank0);
}
Some(land as usize)
}
#[cfg(test)]
mod tests {
use super::*;
use hjkl_buffer::View;
use hjkl_engine::{DefaultHost, Editor, Options};
fn make_editor(content: &str) -> Editor<View, DefaultHost> {
let buf = View::from_str(content);
let host = DefaultHost::new();
crate::vim::vim_editor(buf, host, Options::default())
}
#[test]
fn sentence_text_object_is_exact_range_mid_paragraph() {
let mut ed = make_editor("First sentence. Second one.\n\nThird.");
ed.set_cursor_quiet(0, 16); assert_eq!(
sentence_text_object(&ed, true, 1),
Some(((0, 16), (0, 27))),
"is must span exactly \"Second one.\" (exclusive end after the '.')"
);
assert_eq!(
sentence_text_object(&ed, false, 1),
Some(((0, 16), (0, 27)))
);
}
#[test]
fn sentence_text_object_is_trim_trailing_whitespace() {
let mut ed = make_editor("abc def ");
ed.set_cursor_quiet(0, 0);
assert_eq!(sentence_text_object(&ed, true, 1), Some(((0, 0), (0, 7))));
assert_eq!(sentence_text_object(&ed, false, 1), Some(((0, 0), (0, 10))));
}
#[test]
fn sentence_text_object_counted_inner_alternates_units() {
let mut ed = make_editor("aaaa. bbbb. cccc.");
ed.set_cursor_quiet(0, 0);
assert_eq!(sentence_text_object(&ed, true, 1), Some(((0, 0), (0, 5))));
assert_eq!(sentence_text_object(&ed, true, 2), Some(((0, 0), (0, 6))));
assert_eq!(sentence_text_object(&ed, true, 3), Some(((0, 0), (0, 11))));
assert_eq!(sentence_text_object(&ed, true, 4), Some(((0, 0), (0, 12))));
assert_eq!(sentence_text_object(&ed, true, 9), Some(((0, 0), (0, 17))));
}
#[test]
fn sentence_text_object_counted_around_bundles_separator() {
let mut ed = make_editor("aaaa. bbbb. cccc.");
ed.set_cursor_quiet(0, 0);
assert_eq!(sentence_text_object(&ed, false, 1), Some(((0, 0), (0, 6))));
assert_eq!(sentence_text_object(&ed, false, 2), Some(((0, 0), (0, 12))));
assert_eq!(sentence_text_object(&ed, false, 3), Some(((0, 0), (0, 17))));
}
#[test]
fn sentence_text_object_counted_blank_start() {
let mut ed =
make_editor("one two three. four five. six. seven eight nine.\n ten eleven twelve.");
ed.set_cursor_quiet(0, 30);
assert_eq!(sentence_text_object(&ed, true, 1), Some(((0, 30), (0, 31))));
assert_eq!(sentence_text_object(&ed, true, 2), Some(((0, 30), (0, 48))));
assert_eq!(sentence_text_object(&ed, true, 3), Some(((0, 30), (1, 1))));
assert_eq!(sentence_text_object(&ed, true, 4), Some(((0, 30), (1, 19))));
}
#[test]
fn paragraph_text_object_counted_over_run_is_none() {
let mut ed = make_editor("aaa.\nbbb.\nccc.\n");
ed.set_cursor_quiet(0, 0);
assert_eq!(paragraph_text_object(&ed, true, 1), Some(((0, 0), (2, 4))));
assert_eq!(paragraph_text_object(&ed, true, 2), None);
assert_eq!(paragraph_text_object(&ed, true, 3), None);
assert_eq!(paragraph_text_object(&ed, false, 2), None);
let mut ed = make_editor("aaa.\n\nbbb.\n\nccc.\n");
ed.set_cursor_quiet(0, 0);
assert_eq!(paragraph_text_object(&ed, true, 1), Some(((0, 0), (0, 4))));
assert_eq!(paragraph_text_object(&ed, true, 2), Some(((0, 0), (1, 0))));
assert_eq!(paragraph_text_object(&ed, true, 3), Some(((0, 0), (2, 4))));
assert_eq!(paragraph_text_object(&ed, true, 4), Some(((0, 0), (3, 0))));
assert_eq!(paragraph_text_object(&ed, true, 5), Some(((0, 0), (4, 4))));
assert_eq!(paragraph_text_object(&ed, true, 6), None);
assert_eq!(paragraph_text_object(&ed, false, 1), Some(((0, 0), (1, 0))));
assert_eq!(paragraph_text_object(&ed, false, 2), Some(((0, 0), (3, 0))));
assert_eq!(paragraph_text_object(&ed, false, 3), Some(((0, 0), (4, 4))));
assert_eq!(paragraph_text_object(&ed, false, 4), None);
}
#[test]
fn sentence_boundary_matches_full_scan_across_paragraph_break() {
let mut ed = make_editor("First sentence. Second one.\n\nThird.");
ed.set_cursor_quiet(0, 0);
assert_eq!(sentence_boundary(&ed, true), Some((0, 16)));
ed.set_cursor_quiet(0, 16);
assert_eq!(sentence_boundary(&ed, true), Some((1, 0)));
assert_eq!(sentence_boundary(&ed, false), Some((0, 0)));
ed.set_cursor_quiet(0, 0);
assert_eq!(sentence_boundary(&ed, false), None);
}
#[test]
fn tag_text_object_exact_range_nested() {
let mut ed = make_editor("<div>\n <p>Hello</p>\n</div>");
ed.set_cursor_quiet(1, 5); assert_eq!(tag_text_object(&ed, true), Some(((1, 5), (1, 10))));
assert_eq!(tag_text_object(&ed, false), Some(((1, 2), (1, 14))));
}
#[test]
fn tag_text_object_edge_cases() {
let mut ed = make_editor("text <b>x</b>");
ed.set_cursor_quiet(0, 0);
assert_eq!(tag_text_object(&ed, true), Some(((0, 8), (0, 9))));
assert_eq!(tag_text_object(&ed, false), Some(((0, 5), (0, 13))));
let mut ed = make_editor("<a>\n body\n</a>");
ed.set_cursor_quiet(1, 3);
assert_eq!(tag_text_object(&ed, true), Some(((0, 3), (2, 0))));
assert_eq!(tag_text_object(&ed, false), Some(((0, 0), (2, 4))));
let ed = make_editor("</a>");
assert_eq!(tag_text_object(&ed, true), None);
}
fn old_sentence_boundary<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
forward: bool,
) -> Option<(usize, usize)> {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return None;
}
let lines: Vec<Vec<char>> = (0..raw_n_lines)
.map(|r| rope_line_to_str(&rope, r).chars().collect())
.collect();
let n_lines = if raw_n_lines > 1 && lines[raw_n_lines - 1].is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
if n_lines == 0 {
return None;
}
let boundaries = sentence_boundaries(&lines, n_lines);
let cursor = ed.cursor();
let cursor = (cursor.0.min(n_lines - 1), cursor.1);
if forward {
if let Some(&p) = boundaries.iter().find(|&&p| p > cursor) {
return Some(p);
}
let end = end_of_buffer_pos(&lines, n_lines);
(end > cursor).then_some(end)
} else {
boundaries.into_iter().rfind(|&p| p < cursor)
}
}
fn old_sentence_step_forward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> SentenceStep {
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let raw_n_lines = rope.len_lines();
if raw_n_lines == 0 {
return SentenceStep::AtEnd;
}
let lines: Vec<Vec<char>> = (0..raw_n_lines)
.map(|r| rope_line_to_str(&rope, r).chars().collect())
.collect();
let n_lines = if raw_n_lines > 1 && lines[raw_n_lines - 1].is_empty() {
raw_n_lines - 1
} else {
raw_n_lines
};
if n_lines == 0 {
return SentenceStep::AtEnd;
}
let cursor = ed.cursor();
let cursor = (cursor.0.min(n_lines - 1), cursor.1);
if let Some(&p) = sentence_boundaries(&lines, n_lines)
.iter()
.find(|&&p| p > cursor)
{
return SentenceStep::Boundary(p);
}
let end = end_of_buffer_pos(&lines, n_lines);
if end <= cursor {
return SentenceStep::AtEnd;
}
let mut tail = lines[n_lines - 1].as_slice();
while tail.last().is_some_and(|c| c.is_whitespace()) {
tail = &tail[..tail.len() - 1];
}
while tail.last().is_some_and(|c| is_sentence_closing(*c)) {
tail = &tail[..tail.len() - 1];
}
if tail.last().is_some_and(|c| is_sentence_terminator(*c)) {
SentenceStep::Boundary(end)
} else {
SentenceStep::EndOfBuffer(end)
}
}
fn cursor_samples(content: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
for (row, line) in content.split('\n').enumerate() {
let len = line.chars().count();
for col in 0..=len + 2 {
out.push((row, col));
}
}
out
}
const CORPUS: &[&str] = &[
"",
"\n",
"One.",
"One.\n",
"One. Two.",
"One. Two.\n",
"One. Two. Three!",
"One? Two!",
"One. Two.",
"One. \nTwo.",
"One.\nTwo.",
"One.\n\nTwo.",
"One.\n\n\nTwo.",
"One. \n Two.",
"One. \n \n Two.",
"One.) Two.",
"One.\" Two.",
"One.'] Two.",
"Hello world",
"Hello world\n",
" One. Two. ",
"One. Two",
"Really?! Yes.",
"A.\nB.\nC.",
"First sentence. Second one.\n\nThird.",
"\n\n",
"One.\n\n",
];
fn corpus_buffers() -> Vec<String> {
let mut bufs: Vec<String> = CORPUS.iter().map(|s| (*s).to_string()).collect();
bufs.push("x\n".repeat(300) + "One. Two.\n" + &"y\n".repeat(300));
bufs
}
#[test]
fn sentence_boundary_matches_full_scan_on_corpus() {
for buf in corpus_buffers() {
let mut ed = make_editor(&buf);
for (row, col) in cursor_samples(&buf) {
ed.set_cursor_quiet(row, col);
for forward in [true, false] {
assert_eq!(
sentence_boundary(&ed, forward),
old_sentence_boundary(&ed, forward),
"buffer {:?} cursor ({row},{col}) forward={forward}",
buf
);
}
}
}
}
#[test]
fn sentence_text_object_matches_full_scan_on_corpus() {
for buf in corpus_buffers() {
let mut ed = make_editor(&buf);
for (row, col) in cursor_samples(&buf) {
ed.set_cursor_quiet(row, col);
for inner in [true, false] {
assert_eq!(
sentence_text_object(&ed, inner, 1),
sentence_text_object_full(&ed, inner, 1),
"buffer {:?} cursor ({row},{col}) inner={inner}",
buf
);
}
}
}
}
#[test]
fn sentence_step_forward_matches_full_scan_on_corpus() {
for buf in corpus_buffers() {
let mut ed = make_editor(&buf);
for (row, col) in cursor_samples(&buf) {
ed.set_cursor_quiet(row, col);
assert_eq!(
sentence_step_forward(&ed),
old_sentence_step_forward(&ed),
"buffer {:?} cursor ({row},{col})",
buf
);
}
}
}
}