use teksilo_text::text_document::{MoveMode, MoveOperation};
use super::config::CodeConfig;
use super::state::CodeEditorState;
#[derive(Debug, Clone)]
struct LineInfo {
start: usize,
len: usize,
text: String,
}
impl LineInfo {
fn end(&self) -> usize {
self.start + self.len
}
}
fn line_at(st: &CodeEditorState, pos: usize) -> Option<LineInfo> {
let block = st
.document
.snapshot_block_at_position_without_highlights(pos)?;
Some(LineInfo {
start: block.position,
len: block.length,
text: block.text,
})
}
fn next_line(st: &CodeEditorState, line: &LineInfo) -> Option<LineInfo> {
let next_start = line.end() + 1;
let next = line_at(st, next_start)?;
(next.start == next_start).then_some(next)
}
fn leading_whitespace(line: &str) -> String {
line.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
fn char_in_line(line: &LineInfo, col: usize) -> Option<char> {
if col >= line.len {
return None;
}
line.text.chars().nth(col)
}
fn char_after(st: &CodeEditorState, pos: usize) -> Option<char> {
let line = line_at(st, pos)?;
char_in_line(&line, pos - line.start)
}
fn char_before(st: &CodeEditorState, pos: usize) -> Option<char> {
let line = line_at(st, pos)?;
let col = pos - line.start;
if col == 0 {
return None;
}
line.text.chars().nth(col - 1)
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub(super) fn word_prefix_before_caret(st: &CodeEditorState, pos: usize) -> (usize, String) {
let Some(line) = line_at(st, pos) else {
return (pos, String::new());
};
let col = pos - line.start;
let chars: Vec<char> = line.text.chars().collect();
let mut start = col.min(chars.len());
while start > 0 && is_word_char(chars[start - 1]) {
start -= 1;
}
(
line.start + start,
chars[start..col.min(chars.len())].iter().collect(),
)
}
pub(super) fn identifier_end(st: &CodeEditorState, pos: usize) -> usize {
let Some(line) = line_at(st, pos) else {
return pos;
};
let chars: Vec<char> = line.text.chars().collect();
let mut end = (pos - line.start).min(chars.len());
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
line.start + end
}
pub(super) fn accept_completion(
st: &mut CodeEditorState,
replace_start: usize,
replace_end: usize,
insert: &str,
) {
st.cursor.set_position(replace_start, MoveMode::MoveAnchor);
st.cursor
.set_position(replace_end.max(replace_start), MoveMode::KeepAnchor);
let _ = st.cursor.insert_text(insert);
}
fn caret_pos(st: &CodeEditorState, i: usize) -> usize {
if i == 0 {
st.cursor.position()
} else {
st.extra_carets[i - 1].position()
}
}
fn caret_span(st: &CodeEditorState, i: usize) -> (usize, usize) {
let c = if i == 0 {
&st.cursor
} else {
&st.extra_carets[i - 1]
};
(c.selection_start(), c.selection_end())
}
fn caret_at(st: &mut CodeEditorState, i: usize) -> &mut teksilo_text::text_document::TextCursor {
if i == 0 {
&mut st.cursor
} else {
&mut st.extra_carets[i - 1]
}
}
fn carets_back_to_front(st: &CodeEditorState) -> Vec<usize> {
let mut order: Vec<usize> = (0..=st.extra_carets.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(caret_pos(st, i)));
order
}
fn carets_by_trailing_edge(st: &CodeEditorState) -> Vec<usize> {
let mut order: Vec<usize> = (0..=st.extra_carets.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(caret_span(st, i).1));
order
}
fn touched_lines(st: &CodeEditorState) -> Vec<LineInfo> {
let mut lines: Vec<LineInfo> = Vec::new();
let mut seen: Vec<usize> = Vec::new();
for i in 0..=st.extra_carets.len() {
let (a, b) = caret_span(st, i);
let Some(first) = line_at(st, a) else {
continue;
};
let mut cur = first;
loop {
if !seen.contains(&cur.start) {
seen.push(cur.start);
lines.push(cur.clone());
}
let next_start = cur.end() + 1;
if b > next_start
&& let Some(next) = line_at(st, next_start)
{
cur = next;
} else {
break;
}
}
}
lines.sort_by_key(|l| l.start);
lines
}
pub(super) fn indent_or_tab(st: &mut CodeEditorState) {
let any_selection = st.all_carets().any(|c| c.has_selection());
if any_selection {
indent_lines(st);
} else {
insert_tab_at_carets(st);
}
}
fn insert_tab_at_carets(st: &mut CodeEditorState) {
let config = st.config.clone();
let multi = !st.extra_carets.is_empty();
if multi {
st.cursor.begin_edit_block();
}
for i in carets_back_to_front(st) {
let pos = caret_pos(st, i);
let col = line_at(st, pos).map(|l| pos - l.start).unwrap_or(0);
let text = tab_insertion(&config, col);
let _ = caret_at(st, i).insert_text(&text);
}
if multi {
st.cursor.end_edit_block();
}
st.merge_collided_carets();
}
fn tab_insertion(config: &CodeConfig, col: usize) -> String {
match config.indent {
super::config::IndentStyle::Spaces(n) => {
let n = n.max(1) as usize;
let to_next = n - (col % n);
" ".repeat(to_next)
}
super::config::IndentStyle::Tabs { .. } => "\t".to_string(),
}
}
fn indent_lines(st: &mut CodeEditorState) {
let unit = st.config.indent.unit();
let lines = touched_lines(st);
if lines.is_empty() {
return;
}
let ed = st.document.cursor();
let grouped = lines.len() > 1;
if grouped {
ed.begin_edit_block();
}
for line in lines.iter().rev() {
ed.set_position(line.start, MoveMode::MoveAnchor);
let _ = ed.insert_text(&unit);
}
if grouped {
ed.end_edit_block();
}
st.merge_collided_carets();
}
pub(super) fn dedent(st: &mut CodeEditorState) {
let width = st.config.indent.width().max(1) as usize;
let lines = touched_lines(st);
if lines.is_empty() {
return;
}
let ed = st.document.cursor();
let grouped = lines.len() > 1;
if grouped {
ed.begin_edit_block();
}
for line in lines.iter().rev() {
let remove = dedent_count(&line.text, width);
if remove == 0 {
continue;
}
ed.set_position(line.start, MoveMode::MoveAnchor);
ed.set_position(line.start + remove, MoveMode::KeepAnchor);
let _ = ed.remove_selected_text();
}
if grouped {
ed.end_edit_block();
}
st.merge_collided_carets();
}
fn dedent_count(line: &str, width: usize) -> usize {
let mut removed = 0;
for c in line.chars() {
if removed >= width {
break;
}
match c {
'\t' => {
if removed == 0 {
removed = 1;
}
break;
}
' ' => removed += 1,
_ => break,
}
}
removed
}
pub(super) fn toggle_line_comment(st: &mut CodeEditorState) {
let Some(token) = st.config.line_comment.clone() else {
return;
};
if token.is_empty() {
return;
}
let token_len = token.chars().count();
let lines = touched_lines(st);
if lines.is_empty() {
return;
}
let non_blank: Vec<&LineInfo> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
if non_blank.is_empty() {
return;
}
let all_commented = non_blank
.iter()
.all(|l| l.text.trim_start().starts_with(&token));
let ed = st.document.cursor();
let grouped = non_blank.len() > 1;
if grouped {
ed.begin_edit_block();
}
if all_commented {
for line in non_blank.iter().rev() {
let indent = leading_whitespace(&line.text).chars().count();
let after_token: String = line.text.chars().skip(indent + token_len).collect();
let space = usize::from(after_token.starts_with(' '));
let remove = token_len + space;
let at = line.start + indent;
ed.set_position(at, MoveMode::MoveAnchor);
ed.set_position(at + remove, MoveMode::KeepAnchor);
let _ = ed.remove_selected_text();
}
} else {
let min_indent = non_blank
.iter()
.map(|l| leading_whitespace(&l.text).chars().count())
.min()
.unwrap_or(0);
let insertion = format!("{token} ");
for line in non_blank.iter().rev() {
let at = line.start + min_indent;
ed.set_position(at, MoveMode::MoveAnchor);
let _ = ed.insert_text(&insertion);
}
}
if grouped {
ed.end_edit_block();
}
st.merge_collided_carets();
}
pub(super) fn newline(st: &mut CodeEditorState) {
let config = st.config.clone();
st.cursor.begin_edit_block();
for i in carets_back_to_front(st) {
newline_one(st, i, &config);
}
st.cursor.end_edit_block();
st.merge_collided_carets();
}
fn newline_one(st: &mut CodeEditorState, i: usize, config: &CodeConfig) {
let pos = caret_pos(st, i);
let (sel_start, sel_end) = caret_span(st, i);
let has_sel = sel_start != sel_end;
let indent = if config.auto_indent {
line_at(st, sel_start)
.map(|l| leading_whitespace(&l.text))
.unwrap_or_default()
} else {
String::new()
};
let expand = !has_sel
&& match (char_before(st, pos), char_after(st, pos)) {
(Some(open), Some(close)) => config.closing_for(open) == Some(close),
_ => false,
};
let c = caret_at(st, i);
if expand {
let unit = config.indent.unit();
let _ = c.insert_block();
let _ = c.insert_text(&format!("{indent}{unit}"));
let middle = c.position();
let _ = c.insert_block();
let _ = c.insert_text(&indent);
c.set_position(middle, MoveMode::MoveAnchor);
} else {
let _ = c.insert_block();
if !indent.is_empty() {
let _ = c.insert_text(&indent);
}
}
}
pub(super) fn duplicate(st: &mut CodeEditorState) {
st.cursor.begin_edit_block();
for i in carets_by_trailing_edge(st) {
duplicate_one(st, i);
}
st.cursor.end_edit_block();
st.merge_collided_carets();
}
fn duplicate_one(st: &mut CodeEditorState, i: usize) {
let has_sel = caret_pos(st, i) != {
let c = if i == 0 {
&st.cursor
} else {
&st.extra_carets[i - 1]
};
c.anchor()
};
if has_sel {
let (s, e) = caret_span(st, i);
let text = {
let c = if i == 0 {
&st.cursor
} else {
&st.extra_carets[i - 1]
};
c.selected_text().unwrap_or_default()
};
let len = text.chars().count();
let c = caret_at(st, i);
c.set_position(e, MoveMode::MoveAnchor);
let _ = c.insert_text(&text);
c.set_position(s + len, MoveMode::MoveAnchor);
c.set_position(e + len, MoveMode::KeepAnchor);
} else {
let pos = caret_pos(st, i);
let Some(line) = line_at(st, pos) else { return };
let col = pos - line.start;
let text = line.text.clone();
let c = caret_at(st, i);
c.set_position(line.end(), MoveMode::MoveAnchor);
let _ = c.insert_block();
let _ = c.insert_text(&text);
c.set_position(line.end() + 1 + col, MoveMode::MoveAnchor);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MoveDir {
Up,
Down,
}
pub(super) fn move_lines(st: &mut CodeEditorState, dir: MoveDir) {
st.clear_extra_carets();
let a = st.cursor.selection_start();
let b = st.cursor.selection_end();
let Some(first) = line_at(st, a) else { return };
let last = last_touched_line(st, &first, b);
match dir {
MoveDir::Up => {
if first.start == 0 {
return; }
let Some(prev) = line_at(st, first.start - 1) else {
return;
};
let shift = prev.len + 1;
let block_end = last.end();
let ed = st.document.cursor();
ed.begin_edit_block();
ed.set_position(prev.start, MoveMode::MoveAnchor);
ed.set_position(first.start, MoveMode::KeepAnchor);
let _ = ed.remove_selected_text();
ed.set_position(block_end - shift, MoveMode::MoveAnchor);
let _ = ed.insert_block();
let _ = ed.insert_text(&prev.text);
ed.end_edit_block();
st.cursor.set_position(a - shift, MoveMode::MoveAnchor);
st.cursor.set_position(b - shift, MoveMode::KeepAnchor);
}
MoveDir::Down => {
let Some(next) = next_line(st, &last) else {
return; };
let shift = next.len + 1;
let ed = st.document.cursor();
ed.begin_edit_block();
ed.set_position(last.end(), MoveMode::MoveAnchor);
ed.set_position(next.end(), MoveMode::KeepAnchor);
let _ = ed.remove_selected_text();
ed.set_position(first.start, MoveMode::MoveAnchor);
let _ = ed.insert_text(&next.text);
let _ = ed.insert_block();
ed.end_edit_block();
st.cursor.set_position(a + shift, MoveMode::MoveAnchor);
st.cursor.set_position(b + shift, MoveMode::KeepAnchor);
}
}
}
fn last_touched_line(st: &CodeEditorState, first: &LineInfo, b: usize) -> LineInfo {
let mut cur = first.clone();
loop {
let next_start = cur.end() + 1;
if b > next_start
&& let Some(next) = line_at(st, next_start)
{
cur = next;
} else {
return cur;
}
}
}
pub(super) fn wants_bracket_handling(st: &CodeEditorState, s: &str) -> bool {
if !st.config.auto_close_brackets {
return false;
}
let mut chars = s.chars();
let (Some(ch), None) = (chars.next(), chars.next()) else {
return false; };
st.config.closing_for(ch).is_some() || st.config.opening_for(ch).is_some()
}
pub(super) fn type_bracket_char(st: &mut CodeEditorState, ch: char) {
let batch = std::mem::take(&mut st.pending_chars);
if !batch.is_empty() {
super::frame_loop::insert_at_every_caret(st, &batch);
}
let config = st.config.clone();
if let Some(close) = config.closing_for(ch) {
auto_close_open(st, ch, close);
} else if let Some(_open) = config.opening_for(ch) {
type_close(st, ch);
}
}
fn auto_close_open(st: &mut CodeEditorState, open: char, close: char) {
let config = st.config.clone();
st.cursor.begin_edit_block();
for i in carets_by_trailing_edge(st) {
let (s, e) = caret_span(st, i);
if s != e {
let c = caret_at(st, i);
c.set_position(e, MoveMode::MoveAnchor);
let _ = c.insert_text(&close.to_string());
c.set_position(s, MoveMode::MoveAnchor);
let _ = c.insert_text(&open.to_string());
c.set_position(s + 1, MoveMode::MoveAnchor);
c.set_position(e + 1, MoveMode::KeepAnchor);
} else {
let pos = caret_pos(st, i);
let after = char_after(st, pos);
let should_close = match after {
None => true,
Some(a) => a.is_whitespace() || config.opening_for(a).is_some(),
};
let c = caret_at(st, i);
if should_close {
let _ = c.insert_text(&format!("{open}{close}"));
c.set_position(pos + 1, MoveMode::MoveAnchor);
} else {
let _ = c.insert_text(&open.to_string());
}
}
}
st.cursor.end_edit_block();
st.merge_collided_carets();
}
fn type_close(st: &mut CodeEditorState, close: char) {
let multi = !st.extra_carets.is_empty();
if multi {
st.cursor.begin_edit_block();
}
for i in carets_by_trailing_edge(st) {
let pos = caret_pos(st, i);
let (s, e) = caret_span(st, i);
let has_sel = s != e;
if !has_sel && char_after(st, pos) == Some(close) {
caret_at(st, i).move_position(MoveOperation::Right, MoveMode::MoveAnchor, 1);
} else {
let _ = caret_at(st, i).insert_text(&close.to_string());
}
}
if multi {
st.cursor.end_edit_block();
}
st.merge_collided_carets();
}
pub(super) fn try_pair_backspace(st: &mut CodeEditorState) -> bool {
if !st.config.auto_close_brackets || !st.extra_carets.is_empty() {
return false;
}
if st.cursor.has_selection() {
return false;
}
let batch = std::mem::take(&mut st.pending_chars);
if !batch.is_empty() {
let _ = st.cursor.insert_text(&batch);
}
let pos = st.cursor.position();
let (Some(before), Some(after)) = (char_before(st, pos), char_after(st, pos)) else {
return false;
};
if st.config.closing_for(before) != Some(after) {
return false;
}
st.cursor.begin_edit_block();
let _ = st.cursor.delete_char(); let _ = st.cursor.delete_previous_char(); st.cursor.end_edit_block();
true
}
pub(super) fn add_caret_above(st: &mut CodeEditorState) {
let top = st
.all_carets()
.map(|c| c.position())
.min()
.unwrap_or_else(|| st.cursor.position());
let Some(line) = line_at(st, top) else { return };
if line.start == 0 {
return;
}
let col = top - line.start;
let Some(prev) = line_at(st, line.start - 1) else {
return;
};
add_caret_at_column(st, &prev, col);
}
pub(super) fn add_caret_below(st: &mut CodeEditorState) {
let bottom = st
.all_carets()
.map(|c| c.position())
.max()
.unwrap_or_else(|| st.cursor.position());
let Some(line) = line_at(st, bottom) else {
return;
};
let col = bottom - line.start;
let Some(next) = next_line(st, &line) else {
return;
};
add_caret_at_column(st, &next, col);
}
fn add_caret_at_column(st: &mut CodeEditorState, line: &LineInfo, col: usize) {
let target = line.start + col.min(line.len);
if st.cursor.position() == target || st.extra_carets.iter().any(|c| c.position() == target) {
return;
}
let c = st.document.cursor();
c.set_position(target, MoveMode::MoveAnchor);
st.extra_carets.push(c);
}
pub(super) fn current_bracket_match(st: &CodeEditorState) -> Option<(usize, usize)> {
if st.config.brackets.is_empty() {
return None;
}
let pos = st.cursor.position();
if let Some(ch) = char_before(st, pos)
&& let Some(m) = match_from(st, pos - 1, ch)
{
return Some((pos - 1, m));
}
if let Some(ch) = char_after(st, pos)
&& let Some(m) = match_from(st, pos, ch)
{
return Some((pos, m));
}
None
}
const MATCH_SCAN_CHAR_CAP: usize = 50_000;
fn match_from(st: &CodeEditorState, at: usize, ch: char) -> Option<usize> {
if let Some(close) = st.config.closing_for(ch) {
scan(st, at, ch, close, true)
} else if let Some(open) = st.config.opening_for(ch) {
scan(st, at, open, ch, false)
} else {
None
}
}
fn scan(
st: &CodeEditorState,
origin: usize,
open: char,
close: char,
forward: bool,
) -> Option<usize> {
let mut depth = 0i32;
let mut scanned = 0usize;
let mut line = line_at(st, origin)?;
let mut chars: Vec<char> = line.text.chars().collect();
let mut col = origin - line.start;
loop {
if scanned > MATCH_SCAN_CHAR_CAP {
return None;
}
if col < line.len {
let c = chars[col];
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
}
if depth == 0 {
return Some(line.start + col);
}
scanned += 1;
}
if forward {
col += 1;
if col >= line.len {
line = next_line(st, &line)?;
chars = line.text.chars().collect();
col = 0;
}
} else {
if col == 0 {
if line.start == 0 {
return None;
}
line = line_at(st, line.start - 1)?;
chars = line.text.chars().collect();
if line.len == 0 {
continue;
}
col = line.len - 1;
} else {
col -= 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_whitespace_stops_at_content() {
assert_eq!(leading_whitespace(" code"), " ");
assert_eq!(leading_whitespace("\t\tcode"), "\t\t");
assert_eq!(leading_whitespace("code"), "");
assert_eq!(leading_whitespace(" "), " ");
}
#[test]
fn tab_insertion_reaches_the_next_stop() {
let c = CodeConfig {
indent: super::super::config::IndentStyle::Spaces(4),
..CodeConfig::default()
};
assert_eq!(tab_insertion(&c, 0), " ", "column 0 → 4 spaces");
assert_eq!(tab_insertion(&c, 2), " ", "column 2 → 2 spaces to reach 4");
assert_eq!(
tab_insertion(&c, 4),
" ",
"column 4 → a full 4 to reach 8"
);
assert_eq!(tab_insertion(&c, 5), " ", "column 5 → 3 to reach 8");
}
#[test]
fn hard_tab_inserts_one_character() {
let c = CodeConfig {
indent: super::super::config::IndentStyle::Tabs { width: 4 },
..CodeConfig::default()
};
assert_eq!(tab_insertion(&c, 3), "\t");
}
#[test]
fn dedent_count_removes_one_level_of_spaces() {
assert_eq!(dedent_count(" x", 4), 4, "8 spaces → remove 4");
assert_eq!(
dedent_count(" x", 4),
2,
"under-indented → remove what is there"
);
assert_eq!(dedent_count("x", 4), 0, "no indent → nothing");
}
#[test]
fn dedent_count_removes_one_tab() {
assert_eq!(dedent_count("\t\tx", 4), 1, "one tab is one level");
assert_eq!(dedent_count("\tx", 8), 1);
}
}