use hjkl_vim_types::{InsertReason, InsertSession, LastChange, Mode};
use hjkl_engine::rope_util::rope_row_range_str;
use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_line, buf_line_chars, buf_set_cursor_rc};
pub fn comment_prefixes_for_lang(lang: &str) -> &'static [&'static str] {
match lang {
"rust" => &["/// ", "//! ", "// "],
"c" | "cpp" => &["// "],
"python" | "sh" | "bash" | "zsh" | "fish" | "toml" | "yaml" => &["# "],
"lua" => &["-- "],
"sql" => &["-- "],
"vim" | "viml" => &["\" "],
_ => &[],
}
}
pub fn detect_comment_on_line(lang: &str, line: &str) -> Option<(String, &'static str)> {
let indent_end = line
.char_indices()
.find(|(_, c)| *c != ' ' && *c != '\t')
.map_or(line.len(), |(i, _)| i);
let indent = line[..indent_end].to_string();
let rest = &line[indent_end..];
for &prefix in comment_prefixes_for_lang(lang) {
if rest.starts_with(prefix) {
return Some((indent, prefix));
}
let bare = prefix.trim_end_matches(' ');
if rest == bare || rest.starts_with(&format!("{bare} ")) {
return Some((indent, prefix));
}
}
None
}
pub fn continue_comment(
buffer: &hjkl_buffer::View,
settings: &hjkl_engine::Settings,
row: usize,
) -> Option<String> {
if settings.filetype.is_empty() {
return None;
}
let line = hjkl_engine::buf_helpers::buf_line(buffer, row)?;
let (indent, prefix) = detect_comment_on_line(&settings.filetype, &line)?;
Some(format!("{indent}{prefix}"))
}
pub fn try_dedent_close_bracket<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
cursor: hjkl_buffer::Position,
ch: char,
) -> bool {
use hjkl_buffer::{Edit, MotionKind, Position};
if !ed.settings().smartindent {
return false;
}
if !matches!(ch, '}' | ')' | ']') {
return false;
}
let Some(line) = buf_line(ed.buffer(), cursor.row) else {
return false;
};
let before: String = line.chars().take(cursor.col).collect();
if !before.chars().all(|c| c == ' ' || c == '\t') {
return false;
}
if before.is_empty() {
return false;
}
let unit_len: usize = if ed.settings().expandtab {
if ed.settings().softtabstop > 0 {
ed.settings().softtabstop
} else {
ed.settings().shiftwidth
}
} else {
1
};
let strip_len = if ed.settings().expandtab {
let spaces = before.chars().filter(|c| *c == ' ').count();
if spaces < unit_len {
return false;
}
unit_len
} else {
if !before.starts_with('\t') {
return false;
}
1
};
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(cursor.row, 0),
end: Position::new(cursor.row, strip_len),
kind: MotionKind::Char,
});
let new_col = cursor.col.saturating_sub(strip_len);
ed.mutate_edit(Edit::InsertChar {
at: Position::new(cursor.row, new_col),
ch,
});
true
}
pub fn finish_insert_session<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
let Some(session) = vim_mut(ed).insert_session.take() else {
return;
};
let after_rope = hjkl_engine::types::Query::rope(ed.buffer());
let before_n = session.before_rope.len_lines();
let after_n = after_rope.len_lines();
let after_end = session.row_max.min(after_n.saturating_sub(1));
let before_end = session.row_max.min(before_n.saturating_sub(1));
let before = if before_end >= session.row_min && session.row_min < before_n {
rope_row_range_str(&session.before_rope, session.row_min, before_end)
} else {
String::new()
};
let after = if after_end >= session.row_min && session.row_min < after_n {
rope_row_range_str(&after_rope, session.row_min, after_end)
} else {
String::new()
};
let inserted = if matches!(session.reason, InsertReason::Replace) {
changed_run(&before, &after)
} else {
extract_inserted(&before, &after)
};
if !vim(ed).replaying && !inserted.is_empty() {
vim_mut(ed).last_insert_text = Some(inserted.clone());
}
let open_line = matches!(session.reason, InsertReason::Open { .. });
if session.count > 1 && !vim(ed).replaying {
use hjkl_buffer::{Edit, Position};
if open_line {
let (start_row, _) = ed.cursor();
let typed = buf_line(ed.buffer(), start_row).unwrap_or_default();
for at_row in start_row..start_row + (session.count - 1) {
let end = buf_line_chars(ed.buffer(), at_row);
ed.mutate_edit(Edit::InsertStr {
at: Position::new(at_row, end),
text: format!("\n{typed}"),
});
}
} else if !inserted.is_empty() {
for _ in 0..session.count - 1 {
let (row, col) = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, col),
text: inserted.clone(),
});
}
}
}
if let InsertReason::BlockEdge {
top,
bot,
col,
pad,
cursor_col,
} = session.reason
{
let to_eol = pad && vim(ed).block_to_eol;
if !inserted.is_empty() && !vim(ed).replaying {
let repeated = inserted.repeat(session.count.max(1));
if top < bot {
replicate_block_text(ed, &repeated, top, bot, col, pad, to_eol);
buf_set_cursor_rc(ed.buffer_mut(), top, cursor_col);
}
let cols = if pad {
(col + 1).saturating_sub(cursor_col)
} else {
1
};
vim_mut(ed).last_change = Some(LastChange::VisualBlockInsert {
text: repeated,
rows: bot - top + 1,
cols,
to_eol,
append: pad,
});
}
return;
}
if let InsertReason::BlockChange { top, bot, col } = session.reason {
if !inserted.is_empty() && !vim(ed).replaying {
if top < bot {
replicate_block_text(ed, &inserted, top, bot, col, false, false);
let ins_chars = inserted.chars().count();
let line_len = buf_line_chars(ed.buffer(), top);
let target_col = (col + ins_chars).min(line_len);
buf_set_cursor_rc(ed.buffer_mut(), top, target_col);
}
if let Some(LastChange::VisualOp { inserted: ins, .. }) =
vim_mut(ed).last_change.as_mut()
{
*ins = Some(inserted);
}
}
return;
}
if vim(ed).replaying {
return;
}
match session.reason {
InsertReason::Enter(entry) => {
vim_mut(ed).last_change = Some(LastChange::InsertAt {
entry,
inserted,
count: session.count,
});
}
InsertReason::Open { above } => {
vim_mut(ed).last_change = Some(LastChange::OpenLine { above, inserted });
}
InsertReason::AfterChange => {
if let Some(
LastChange::OpMotion { inserted: ins, .. }
| LastChange::OpTextObj { inserted: ins, .. }
| LastChange::LineOp { inserted: ins, .. }
| LastChange::GnOp { inserted: ins, .. }
| LastChange::VisualOp { inserted: ins, .. },
) = vim_mut(ed).last_change.as_mut()
{
*ins = Some(inserted);
}
if let Some(start) = vim_mut(ed).change_mark_start.take() {
let end = ed.cursor();
ed.set_mark('[', start);
ed.set_mark(']', end);
}
}
InsertReason::DeleteToEol => {
vim_mut(ed).last_change = Some(LastChange::DeleteToEol {
inserted: Some(inserted),
});
}
InsertReason::ReplayOnly => {}
InsertReason::BlockEdge { .. } => unreachable!("handled above"),
InsertReason::BlockChange { .. } => unreachable!("handled above"),
InsertReason::Replace => {
vim_mut(ed).last_change = Some(LastChange::ReplaceMode { text: inserted });
}
}
}
pub fn begin_insert_noundo<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
count: usize,
reason: InsertReason,
) {
let reason = if vim(ed).replaying {
InsertReason::ReplayOnly
} else {
reason
};
let (row, col) = ed.cursor();
vim_mut(ed).insert_session = Some(InsertSession {
count,
row_min: row,
row_max: row,
before_rope: hjkl_engine::types::Query::rope(ed.buffer()),
reason,
start_row: row,
start_col: col,
});
vim_mut(ed).mode = Mode::Insert;
vim_mut(ed).current_mode = hjkl_engine::VimMode::Insert;
drop_blame_if_left_normal(ed);
}
pub fn begin_insert<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
count: usize,
reason: InsertReason,
) {
if !ed.settings().modifiable {
return;
}
if ed.view_mode() == hjkl_engine::ViewMode::Blame {
ed.set_view_mode(hjkl_engine::ViewMode::Normal);
return;
}
let record = !matches!(reason, InsertReason::ReplayOnly);
if record {
ed.push_undo();
}
begin_insert_noundo(ed, count, reason);
}
pub fn break_undo_group_in_insert<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
) {
if !ed.settings().undo_break_on_motion {
return;
}
if vim(ed).replaying {
return;
}
if vim(ed).insert_session.is_none() {
return;
}
ed.push_undo();
let before_rope = hjkl_engine::types::Query::rope(ed.buffer());
let row = hjkl_engine::types::Cursor::cursor(ed.buffer()).line as usize;
if let Some(ref mut session) = vim_mut(ed).insert_session {
session.before_rope = before_rope;
session.row_min = row;
session.row_max = row;
}
}
pub fn maybe_word_undo_break<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
next: char,
) {
use hjkl_engine::UndoGranularity;
use hjkl_engine::buf_helpers::{buf_cursor_pos, buf_line};
if ed.settings().undo_granularity != UndoGranularity::Word {
return;
}
if vim(ed).replaying {
return;
}
let Some(session) = vim(ed).insert_session.as_ref() else {
return;
};
let cursor = buf_cursor_pos(ed.buffer());
let is_first_pos = cursor.row == session.start_row && cursor.col == session.start_col;
if is_first_pos {
return;
}
let should_break = if next == '\n' {
true
} else if next.is_whitespace() {
false
} else {
let prev_char = buf_line(ed.buffer(), cursor.row)
.as_deref()
.and_then(|line| line.chars().nth(cursor.col.wrapping_sub(1)));
match prev_char {
Some(p) if p.is_whitespace() => true,
None if cursor.col == 0 => false, _ => false,
}
};
if should_break {
ed.push_undo();
let before_rope = hjkl_engine::types::Query::rope(ed.buffer());
let row = cursor.row;
if let Some(ref mut session) = vim_mut(ed).insert_session {
session.before_rope = before_rope;
session.row_min = row;
session.row_max = row;
}
}
}
#[cfg(test)]
mod comment_continuation_tests {
use super::*;
use hjkl_buffer::View;
use hjkl_engine::{DefaultHost, Editor, Options};
fn make_editor_with_lang(lang: &str, content: &str) -> Editor<View, DefaultHost> {
let buf = View::from_str(content);
let host = DefaultHost::new();
let opts = Options {
filetype: lang.to_string(),
formatoptions: "ro".to_string(),
..Options::default()
};
crate::vim::vim_editor(buf, host, opts)
}
#[test]
fn detect_rust_doc_comment() {
let result = detect_comment_on_line("rust", "/// foo bar");
assert!(result.is_some());
let (indent, prefix) = result.unwrap();
assert_eq!(indent, "");
assert_eq!(prefix, "/// ");
}
#[test]
fn detect_rust_inner_doc_comment() {
let result = detect_comment_on_line("rust", "//! crate docs");
assert!(result.is_some());
let (_, prefix) = result.unwrap();
assert_eq!(prefix, "//! ");
}
#[test]
fn detect_rust_plain_comment() {
let result = detect_comment_on_line("rust", "// normal comment");
assert!(result.is_some());
let (_, prefix) = result.unwrap();
assert_eq!(prefix, "// ");
}
#[test]
fn detect_indented_comment() {
let result = detect_comment_on_line("rust", " // indented");
assert!(result.is_some());
let (indent, prefix) = result.unwrap();
assert_eq!(indent, " ");
assert_eq!(prefix, "// ");
}
#[test]
fn detect_python_hash() {
let result = detect_comment_on_line("python", "# comment");
assert!(result.is_some());
let (_, prefix) = result.unwrap();
assert_eq!(prefix, "# ");
}
#[test]
fn detect_lua_double_dash() {
let result = detect_comment_on_line("lua", "-- a lua comment");
assert!(result.is_some());
let (_, prefix) = result.unwrap();
assert_eq!(prefix, "-- ");
}
#[test]
fn detect_non_comment_is_none() {
assert!(detect_comment_on_line("rust", "let x = 1;").is_none());
assert!(detect_comment_on_line("python", "x = 1").is_none());
}
#[test]
fn detect_bare_double_slash_still_matches() {
assert!(detect_comment_on_line("rust", "//").is_some());
}
#[test]
fn rust_doc_before_plain() {
let result = detect_comment_on_line("rust", "/// outer doc");
let (_, prefix) = result.unwrap();
assert_eq!(prefix, "/// ", "/// must match before //");
}
#[test]
fn continue_comment_returns_prefix_for_comment_row() {
let ed = make_editor_with_lang("rust", "/// hello\n");
let cont = continue_comment(ed.buffer(), ed.settings(), 0);
assert_eq!(cont, Some("/// ".to_string()));
}
#[test]
fn continue_comment_returns_none_for_non_comment() {
let ed = make_editor_with_lang("rust", "let x = 1;\n");
let cont = continue_comment(ed.buffer(), ed.settings(), 0);
assert!(cont.is_none());
}
#[test]
fn continue_comment_returns_none_when_filetype_empty() {
let buf = View::from_str("// hello\n");
let host = DefaultHost::new();
let ed = crate::vim::vim_editor(buf, host, Options::default());
let cont = continue_comment(ed.buffer(), ed.settings(), 0);
assert!(cont.is_none());
}
}
#[cfg(test)]
mod comment_toggle_tests {
use super::*;
use hjkl_buffer::View;
use hjkl_engine::{DefaultHost, Editor, Options};
fn make_rust_editor(content: &str) -> Editor<View, DefaultHost> {
let buf = View::from_str(content);
let host = DefaultHost::new();
let opts = Options {
filetype: "rust".to_string(),
..Options::default()
};
crate::vim::vim_editor(buf, host, opts)
}
fn line(ed: &Editor<View, DefaultHost>, row: usize) -> String {
buf_line(ed.buffer(), row).unwrap_or_default()
}
#[test]
fn gcc_comments_rust_line() {
let mut ed = make_rust_editor("let x = 1;");
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), "// let x = 1;");
}
#[test]
fn gcc_uncomments_rust_line() {
let mut ed = make_rust_editor("// let x = 1;");
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), "let x = 1;");
}
#[test]
fn gcc_indent_preserving() {
let mut ed = make_rust_editor(" let x = 1;");
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), " // let x = 1;");
}
#[test]
fn gcc_indent_preserving_uncomment() {
let mut ed = make_rust_editor(" // let x = 1;");
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), " let x = 1;");
}
#[test]
fn toggle_multi_line_all_uncommented() {
let content = "let a = 1;\nlet b = 2;\nlet c = 3;";
let mut ed = make_rust_editor(content);
ed.toggle_comment_range(0, 2);
assert_eq!(line(&ed, 0), "// let a = 1;");
assert_eq!(line(&ed, 1), "// let b = 2;");
assert_eq!(line(&ed, 2), "// let c = 3;");
}
#[test]
fn toggle_multi_line_all_commented() {
let content = "// let a = 1;\n// let b = 2;\n// let c = 3;";
let mut ed = make_rust_editor(content);
ed.toggle_comment_range(0, 2);
assert_eq!(line(&ed, 0), "let a = 1;");
assert_eq!(line(&ed, 1), "let b = 2;");
assert_eq!(line(&ed, 2), "let c = 3;");
}
#[test]
fn toggle_mixed_state_comments_all() {
let content = "let a = 1;\n// let b = 2;\nlet c = 3;\n// let d = 4;\nlet e = 5;";
let mut ed = make_rust_editor(content);
ed.toggle_comment_range(0, 4);
for r in 0..5 {
assert!(
line(&ed, r).trim_start().starts_with("//"),
"row {r} not commented: {:?}",
line(&ed, r)
);
}
}
#[test]
fn blank_lines_not_commented() {
let content = "let a = 1;\n\nlet b = 2;";
let mut ed = make_rust_editor(content);
ed.toggle_comment_range(0, 2);
assert_eq!(line(&ed, 0), "// let a = 1;");
assert_eq!(line(&ed, 1), ""); assert_eq!(line(&ed, 2), "// let b = 2;");
}
#[test]
fn python_comment_toggle() {
let buf = View::from_str("x = 1\ny = 2");
let host = DefaultHost::new();
let opts = Options {
filetype: "python".to_string(),
..Options::default()
};
let mut ed = crate::vim::vim_editor(buf, host, opts);
ed.toggle_comment_range(0, 1);
assert_eq!(line(&ed, 0), "# x = 1");
assert_eq!(line(&ed, 1), "# y = 2");
ed.toggle_comment_range(0, 1);
assert_eq!(line(&ed, 0), "x = 1");
assert_eq!(line(&ed, 1), "y = 2");
}
#[test]
fn commentstring_override_via_setting() {
let buf = View::from_str("hello world");
let host = DefaultHost::new();
let opts = Options {
filetype: "rust".to_string(),
..Options::default()
};
let mut ed = crate::vim::vim_editor(buf, host, opts);
ed.settings_mut().commentstring = "# %s".to_string();
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), "# hello world");
}
#[test]
fn unknown_lang_no_op() {
let buf = View::from_str("hello");
let host = DefaultHost::new();
let opts = Options::default(); let mut ed = crate::vim::vim_editor(buf, host, opts);
ed.toggle_comment_range(0, 0);
assert_eq!(line(&ed, 0), "hello");
}
}