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 super::vim_buf::{VimBuf, is_vim_white, walk_vim_buf, with_vim_buf};
use super::word_object::word_text_object;
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, count).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, ')' | ']' | '"' | '\'')
}
fn row_skippable(line: &[char]) -> bool {
!line.is_empty() && line.iter().all(|&c| c.is_whitespace())
}
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();
if inner && let Some(rung) = blank_run_rung(&starts, &lines, cursor) {
starts.push(rung);
starts.sort_unstable();
}
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()
}
fn blank_run_rung(starts: &[Pos], lines: &[Vec<char>], cursor: Pos) -> Option<Pos> {
let index = starts.iter().rposition(|&start| start <= cursor)?;
let standing_in = starts
.get(index + 1)
.and_then(|&next| blank_gap(lines, starts[index], next))
.filter(|&(row, _)| row <= cursor.0);
standing_in.or_else(|| {
let prev = *starts.get(index.checked_sub(1)?)?;
blank_gap(lines, prev, starts[index])
})
}
fn blank_gap(lines: &[Vec<char>], prev: Pos, next: Pos) -> Option<Pos> {
let first = prev.0 + 1;
(first < next.0
&& lines[first..next.0]
.iter()
.all(|line| line.iter().all(|ch| ch.is_whitespace())))
.then_some((first, 0))
}
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
}
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));
}
}
}
pub fn sentence_forward_for_operator<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
count: usize,
) -> Option<(Pos, bool)> {
let found = with_vim_buf(ed, |buf, cursor| {
let pos = buf.findsent(cursor, true, count.max(1))?;
if pos.1 > 0 && pos.1 == buf.len(pos.0) {
Some(((pos.0, pos.1 - 1), true))
} else {
Some((pos, false))
}
});
found.unwrap_or_else(|| Some((ed.cursor(), false)))
}
pub fn sentence_forward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
count: usize,
) -> Option<Pos> {
walk_vim_buf(ed, |buf, cursor| buf.findsent(cursor, true, count.max(1)))
}
pub fn sentence_backward<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
count: usize,
) -> Option<Pos> {
walk_vim_buf(ed, |buf, cursor| buf.findsent(cursor, false, count.max(1)))
}
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))> {
with_vim_buf(ed, |buf, cursor| {
buf.current_sent(cursor, inner, count.max(1))
})
}
#[derive(Debug, PartialEq, Eq)]
pub enum VisualTextObject {
Select(Pos, Pos),
Extend(Pos),
}
pub fn sentence_visual<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
anchor: Pos,
inner: bool,
count: usize,
) -> Option<VisualTextObject> {
with_vim_buf(ed, |buf, cursor| {
buf.current_sent_visual(cursor, anchor, inner, count.max(1))
})
}
struct SentenceFound {
start: Pos,
last: Pos,
skipped: Pos,
}
fn is_sentence_punct(c: char) -> bool {
is_sentence_terminator(c) || is_sentence_closing(c)
}
impl VimBuf<'_> {
fn start_ps(&self, row: usize) -> bool {
self.len(row) == 0 || self.gchar((row, 0)) == Some('\x0c')
}
fn find_first_blank(&self, p: &mut Pos) {
while self.decl(p) != -1 {
if !self.gchar(*p).is_some_and(is_vim_white) {
self.incl(p);
break;
}
}
}
fn in_leading_white(&self, p: Pos) -> bool {
(0..p.1).all(|col| self.gchar((p.0, col)).is_some_and(is_vim_white))
}
fn findsent(&self, mut pos: Pos, forward: bool, mut count: usize) -> Option<Pos> {
let step = |p: &mut Pos| if forward { self.incl(p) } else { self.decl(p) };
let mut noskip = false;
while count > 0 {
count -= 1;
let prev_pos = pos;
'found: {
if self.gchar(pos).is_none() {
while step(&mut pos) != -1 && self.gchar(pos).is_none() {}
if forward {
break 'found;
}
} else if forward && pos.1 == 0 && self.start_ps(pos.0) {
if pos.0 + 1 == self.n_lines {
return None;
}
pos.0 += 1;
break 'found;
} else if !forward {
self.decl(&mut pos);
}
let mut found_dot = false;
while let Some(c) = self.gchar(pos)
&& (is_vim_white(c) || is_sentence_punct(c))
{
let mut tpos = pos;
if self.decl(&mut tpos) == -1 || (self.len(tpos.0) == 0 && forward) {
break;
}
if found_dot {
break;
}
if is_sentence_terminator(c) {
found_dot = true;
}
if is_sentence_closing(c) && !self.gchar(tpos).is_some_and(is_sentence_punct) {
break;
}
self.decl(&mut pos);
}
let start_row = pos.0;
loop {
let c = self.gchar(pos);
if c.is_none() || (pos.1 == 0 && self.start_ps(pos.0)) {
if !forward && pos.0 != start_row {
pos.0 += 1;
}
break;
}
if c.is_some_and(is_sentence_terminator) {
let mut tpos = pos;
let after = loop {
if self.inc(&mut tpos) == -1 {
break None;
}
let c = self.gchar(tpos);
if !c.is_some_and(is_sentence_closing) {
break Some(c);
}
};
if matches!(after, None | Some(None) | Some(Some(' ' | '\t'))) {
pos = tpos;
if self.gchar(pos).is_none() {
self.inc(&mut pos);
}
break;
}
}
if step(&mut pos) == -1 {
if count > 0 {
return None;
}
noskip = true;
break;
}
}
}
while !noskip && self.gchar(pos).is_some_and(is_vim_white) {
if self.incl(&mut pos) == -1 {
break;
}
}
if pos == prev_pos {
if step(&mut pos) == -1 {
if count > 0 {
return None;
}
break;
}
count += 1;
}
}
Some(pos)
}
fn current_sent(&self, cursor: Pos, inner: bool, count: usize) -> (Pos, Pos) {
let found = self.current_sent_found(cursor, inner, count);
let (start, mut cur) = (found.start, found.last);
self.incl(&mut cur);
let end = if cur.1 == 0 && cur.0 > 0 && !self.in_leading_white(start) {
(cur.0 - 1, self.len(cur.0 - 1))
} else {
cur
};
(start, end)
}
fn current_sent_visual(
&self,
cursor: Pos,
anchor: Pos,
inner: bool,
count: usize,
) -> VisualTextObject {
if anchor != cursor {
let next = self.findsent(cursor, true, 1).unwrap_or(cursor);
let landing = self.current_sent_extend(cursor, cursor, next, anchor, inner, count);
return VisualTextObject::Extend(landing);
}
let found = self.current_sent_found(cursor, inner, count);
if found.start == found.last {
let landing =
self.current_sent_extend(cursor, found.skipped, found.last, anchor, inner, count);
return VisualTextObject::Extend(landing);
}
VisualTextObject::Select(found.start, found.last)
}
fn current_sent_found(&self, cursor: Pos, inner: bool, count: usize) -> SentenceFound {
let mut start = cursor;
let mut pos = cursor;
let mut cur = self.findsent(cursor, true, 1).unwrap_or(cursor);
while self.gchar(pos).is_some_and(is_vim_white) {
if self.incl(&mut pos) == -1 {
break;
}
}
let start_blank = pos == cur;
if start_blank {
self.find_first_blank(&mut start);
} else {
cur = self.findsent(cur, false, 1).unwrap_or(cur);
start = cur;
}
let ncount = if !inner {
count * 2
} else {
count - usize::from(start_blank)
};
if ncount > 0 {
cur = self.findsent_forward(cur, ncount, true);
} else {
self.decl(&mut cur);
}
if !inner {
if start_blank {
self.find_first_blank(&mut cur);
if self.gchar(cur).is_some_and(is_vim_white) {
self.decl(&mut cur);
}
} else if !self.gchar(cur).is_some_and(is_vim_white) {
self.find_first_blank(&mut start);
}
}
SentenceFound {
start,
last: cur,
skipped: pos,
}
}
fn current_sent_extend(
&self,
start_pos: Pos,
mut pos: Pos,
mut cur: Pos,
anchor: Pos,
inner: bool,
count: usize,
) -> Pos {
let count = if inner { count } else { count * 2 };
let not_white = |p: Pos| !self.gchar(p).is_some_and(is_vim_white);
if start_pos < anchor {
let mut at_start_sent = true;
self.decl(&mut pos);
while pos < cur {
if not_white(pos) {
at_start_sent = false;
break;
}
self.incl(&mut pos);
}
if !at_start_sent {
cur = self.findsent(cur, false, 1).unwrap_or(cur);
if cur == start_pos {
at_start_sent = true;
} else {
cur = self.findsent(cur, true, 1).unwrap_or(cur);
}
}
for _ in 0..count {
if at_start_sent {
self.find_first_blank(&mut cur);
}
if !at_start_sent || (inner && not_white(cur)) {
cur = self.findsent(cur, false, 1).unwrap_or(cur);
}
at_start_sent = !at_start_sent;
}
cur
} else {
self.incl(&mut pos);
let mut at_start_sent = true;
if pos != cur {
at_start_sent = false;
while pos < cur {
if not_white(pos) {
at_start_sent = true;
break;
}
self.incl(&mut pos);
}
cur = if at_start_sent {
self.findsent(cur, false, 1).unwrap_or(cur)
} else {
start_pos
};
}
self.findsent_forward(cur, count, at_start_sent)
}
}
fn findsent_forward(&self, mut pos: Pos, count: usize, mut at_start_sent: bool) -> Pos {
for left in (0..count).rev() {
if let Some(p) = self.findsent(pos, true, 1) {
pos = p;
}
if at_start_sent {
self.find_first_blank(&mut pos);
}
if left == 0 || at_start_sent {
self.decl(&mut pos);
}
at_start_sent = !at_start_sent;
}
pos
}
}
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 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 quote_text_object<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
q: char,
inner: bool,
count: usize,
) -> 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 (open, close) = if chars.get(col) == Some(&q) {
let &[open, close] = positions
.as_chunks::<2>()
.0
.iter()
.find(|[open, close]| (*open..=*close).contains(&col))?;
(open, close)
} else {
let open = positions
.iter()
.rev()
.find(|&&p| p < col)
.or_else(|| positions.iter().find(|&&p| p > col))
.copied()?;
let close = positions.iter().find(|&&p| p > open).copied()?;
(open, close)
};
if inner && count >= 2 {
Some(((row, open), (row, close + 1)))
} else 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 = closing_bracket(open)?;
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) {
match find_open_before(lines, (row, col), open, close) {
Some(open_pos) => (open_pos, (row, col)),
None => {
let open_pos = 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)
}
}
} 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 oo = find_open_before(lines, op, open, close)?;
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,
))
}
}
fn closing_bracket(open: char) -> Option<char> {
match open {
'(' => Some(')'),
'[' => Some(']'),
'{' => Some('}'),
'<' => Some('>'),
_ => None,
}
}
fn find_open_before(lines: &[String], pos: Pos, open: char, close: char) -> Option<Pos> {
if pos.1 > 0 {
find_open_bracket(lines, pos.0, pos.1 - 1, open, close)
} else if pos.0 > 0 {
let pr = pos.0 - 1;
let pc = lines[pr].chars().count().saturating_sub(1);
find_open_bracket(lines, pr, pc, open, close)
} else {
None
}
}
fn find_open_after(lines: &[String], pos: Pos, open: char, close: char) -> Option<Pos> {
let mut depth = 0usize;
let mut col = pos.1 + 1;
for (row, line) in lines.iter().enumerate().skip(pos.0) {
for (c, ch) in line.chars().enumerate().skip(col) {
if ch == close {
depth += 1;
} else if ch == open {
if depth == 0 {
return Some((row, c));
}
depth -= 1;
}
}
col = 0;
}
None
}
pub fn bracket_visual<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
anchor: Pos,
open: char,
inner: bool,
count: usize,
) -> Option<(Pos, Pos)> {
let close = closing_bracket(open)?;
let lines = rope_to_lines_vec(&hjkl_engine::types::Query::rope(ed.buffer()));
let (old_start, old_end) = super::order(anchor, ed.cursor());
let enclosed = find_open_before(&lines, old_start, open, close).is_some();
let mut open_pos = old_start;
for _ in 0..count.max(1) {
open_pos = if enclosed {
find_open_before(&lines, open_pos, open, close)?
} else {
find_open_after(&lines, open_pos, open, close)?
};
}
let mut close_pos = find_close_bracket(&lines, open_pos.0, open_pos.1 + 1, open, close)?;
if !inner {
return Some((open_pos, close_pos));
}
with_vim_buf(ed, |buf, _| {
loop {
let mut start = open_pos;
buf.incl(&mut start);
let mut end = close_pos;
let mut sol = end.1 == 0;
buf.decl(&mut end);
while in_indent(buf, end) {
sol = true;
if buf.decl(&mut end) != 0 {
break;
}
}
if start >= old_start && end <= old_end {
let mut from = old_start;
buf.decl(&mut from);
open_pos = find_open_before(&lines, from, open, close)?;
close_pos = find_close_bracket(&lines, open_pos.0, open_pos.1 + 1, open, close)?;
continue;
}
if sol && buf.gchar(end).is_some() {
buf.inc(&mut end);
}
return Some((start, end));
}
})
.flatten()
}
fn in_indent(buf: &VimBuf<'_>, p: Pos) -> bool {
(0..=p.1).all(|col| buf.gchar((p.0, col)).is_some_and(is_vim_white))
}
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,
count: usize,
) -> Option<usize> {
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 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 = cursor_row as isize;
for _ in 0..count.max(1) {
if land == boundary {
break;
}
let mut prev_blank: Option<bool> = None;
for _ in 0..2 {
land += dir;
let blank0 = is_blank(land);
if prev_blank == Some(blank0) {
land -= dir;
break;
}
while land != boundary && is_blank(land + dir) == blank0 {
land += dir;
}
if inner || land == boundary {
break;
}
prev_blank = Some(blank0);
}
}
(land != cursor_row as isize).then_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, 15), (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 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);
}
}