diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs index 536b710abf0f..4270218746ae 100644 --- a/helix-core/src/comment.rs +++ b/helix-core/src/comment.rs @@ -9,6 +9,24 @@ use crate::{ use helix_stdx::rope::RopeSliceExt; use std::borrow::Cow; +pub const DEFAULT_COMMENT_TOKEN: &str = "//"; + +/// Returns the longest matching comment token of the given line (if it exists). +pub fn get_comment_token<'a, S: AsRef>( + text: RopeSlice, + tokens: &'a [S], + line_num: usize, +) -> Option<&'a str> { + let line = text.line(line_num); + let start = line.first_non_whitespace_char()?; + + tokens + .iter() + .map(AsRef::as_ref) + .filter(|token| line.slice(start..).starts_with(token)) + .max_by_key(|token| token.len()) +} + /// Given text, a comment token, and a set of line indices, returns the following: /// - Whether the given lines should be considered commented /// - If any of the lines are uncommented, all lines are considered as such. @@ -28,21 +46,20 @@ fn find_line_comment( let mut min = usize::MAX; // minimum col for first_non_whitespace_char let mut margin = 1; let token_len = token.chars().count(); + for line in lines { let line_slice = text.line(line); if let Some(pos) = line_slice.first_non_whitespace_char() { let len = line_slice.len_chars(); - if pos < min { - min = pos; - } + min = std::cmp::min(min, pos); // line can be shorter than pos + token len let fragment = Cow::from(line_slice.slice(pos..std::cmp::min(pos + token.len(), len))); + // as soon as one of the non-blank lines doesn't have a comment, the whole block is + // considered uncommented. if fragment != token { - // as soon as one of the non-blank lines doesn't have a comment, the whole block is - // considered uncommented. commented = false; } @@ -56,6 +73,7 @@ fn find_line_comment( to_change.push(line); } } + (commented, to_change, min, margin) } @@ -63,7 +81,7 @@ fn find_line_comment( pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&str>) -> Transaction { let text = doc.slice(..); - let token = token.unwrap_or("//"); + let token = token.unwrap_or(DEFAULT_COMMENT_TOKEN); let comment = Tendril::from(format!("{} ", token)); let mut lines: Vec = Vec::with_capacity(selection.len()); @@ -317,56 +335,87 @@ pub fn split_lines_of_selection(text: RopeSlice, selection: &Selection) -> Selec mod test { use super::*; - #[test] - fn test_find_line_comment() { - // four lines, two space indented, except for line 1 which is blank. - let mut doc = Rope::from(" 1\n\n 2\n 3"); - // select whole document - let mut selection = Selection::single(0, doc.len_chars() - 1); + mod find_line_comment { + use super::*; - let text = doc.slice(..); + #[test] + fn not_commented() { + // four lines, two space indented, except for line 1 which is blank. + let doc = Rope::from(" 1\n\n 2\n 3"); - let res = find_line_comment("//", text, 0..3); - // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 0) - assert_eq!(res, (false, vec![0, 2], 2, 0)); + let text = doc.slice(..); - // comment - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); + let res = find_line_comment("//", text, 0..3); + // (commented = false, to_change = [line 0, line 2], min = col 2, margin = 0) + assert_eq!(res, (false, vec![0, 2], 2, 0)); + } - assert_eq!(doc, " // 1\n\n // 2\n // 3"); + #[test] + fn is_commented() { + // three lines where the second line is empty. + let doc = Rope::from("// hello\n\n// there"); - // uncomment - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, " 1\n\n 2\n 3"); - assert!(selection.len() == 1); // to ignore the selection unused warning + let res = find_line_comment("//", doc.slice(..), 0..3); - // 0 margin comments - doc = Rope::from(" //1\n\n //2\n //3"); - // reset the selection. - selection = Selection::single(0, doc.len_chars() - 1); + // (commented = true, to_change = [line 0, line 2], min = col 0, margin = 1) + assert_eq!(res, (true, vec![0, 2], 0, 1)); + } + } - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, " 1\n\n 2\n 3"); - assert!(selection.len() == 1); // to ignore the selection unused warning + // TODO: account for uncommenting with uneven comment indentation + mod toggle_line_comment { + use super::*; - // 0 margin comments, with no space - doc = Rope::from("//"); - // reset the selection. - selection = Selection::single(0, doc.len_chars() - 1); + #[test] + fn comment() { + // four lines, two space indented, except for line 1 which is blank. + let mut doc = Rope::from(" 1\n\n 2\n 3"); + // select whole document + let selection = Selection::single(0, doc.len_chars() - 1); - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, ""); - assert!(selection.len() == 1); // to ignore the selection unused warning + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + + assert_eq!(doc, " // 1\n\n // 2\n // 3"); + } - // TODO: account for uncommenting with uneven comment indentation + #[test] + fn uncomment() { + let mut doc = Rope::from(" // 1\n\n // 2\n // 3"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning + } + + #[test] + fn uncomment_0_margin_comments() { + let mut doc = Rope::from(" //1\n\n //2\n //3"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning + } + + #[test] + fn uncomment_0_margin_comments_with_no_space() { + let mut doc = Rope::from("//"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + assert_eq!(doc, ""); + assert!(selection.len() == 1); // to ignore the selection unused warning + } } #[test] @@ -413,4 +462,32 @@ mod test { transaction.apply(&mut doc); assert_eq!(doc, ""); } + + /// Test, if `get_comment_tokens` works, even if the content of the file includes chars, whose + /// byte size unequal the amount of chars + #[test] + fn test_get_comment_with_char_boundaries() { + let rope = Rope::from("ยทยท"); + let tokens = ["//", "///"]; + + assert_eq!( + super::get_comment_token(rope.slice(..), tokens.as_slice(), 0), + None + ); + } + + /// Test for `get_comment_token`. + /// + /// Assuming the comment tokens are stored as `["///", "//"]`, `get_comment_token` should still + /// return `///` instead of `//` if the user is in a doc-comment section. + #[test] + fn test_use_longest_comment() { + let text = Rope::from(" /// amogus"); + let tokens = ["///", "//"]; + + assert_eq!( + super::get_comment_token(text.slice(..), tokens.as_slice(), 0), + Some("///") + ); + } } diff --git a/helix-stdx/src/rope.rs b/helix-stdx/src/rope.rs index 2695555e39d6..5cb45c2cd14f 100644 --- a/helix-stdx/src/rope.rs +++ b/helix-stdx/src/rope.rs @@ -51,7 +51,7 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> { if len < text.len() { return false; } - self.get_byte_slice(..len - text.len()) + self.get_byte_slice(..text.len()) .map_or(false, |start| start == text) } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 835283adae07..de6107f59e41 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -22,8 +22,8 @@ use helix_core::{ encoding, find_workspace, graphemes::{self, next_grapheme_boundary, RevRopeGraphemes}, history::UndoKind, - increment, indent, - indent::IndentStyle, + increment, + indent::{self, IndentStyle}, line_ending::{get_line_ending_of_str, line_end_char_index}, match_brackets, movement::{self, move_vertically_visual, Direction}, @@ -3424,31 +3424,51 @@ fn open(cx: &mut Context, open: Open) { ) }; - let indent = indent::indent_for_newline( - doc.language_config(), - doc.syntax(), - &doc.config.load().indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - line_num, - line_end_index, - cursor_line, - ); + let continue_comment_token = doc + .language_config() + .and_then(|config| config.comment_tokens.as_ref()) + .and_then(|tokens| comment::get_comment_token(text, tokens, cursor_line)); + + let line = text.line(cursor_line); + let indent = match line.first_non_whitespace_char() { + Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), + _ => indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.config.load().indent_heuristic, + &doc.indent_style, + doc.tab_width(), + text, + line_num, + line_end_index, + cursor_line, + ), + }; let indent_len = indent.len(); let mut text = String::with_capacity(1 + indent_len); text.push_str(doc.line_ending.as_str()); text.push_str(&indent); + + if let Some(token) = continue_comment_token { + text.push_str(token); + text.push(' '); + } + let text = text.repeat(count); // calculate new selection ranges let pos = offs + line_end_index + line_end_offset_width; + let comment_len = continue_comment_token + .map(|token| token.len() + 1) // `+ 1` for the extra space added + .unwrap_or_default(); for i in 0..count { // pos -> beginning of reference line, - // + (i * (1+indent_len)) -> beginning of i'th line from pos - // + indent_len -> -> indent for i'th line - ranges.push(Range::point(pos + (i * (1 + indent_len)) + indent_len)); + // + (i * (1+indent_len + comment_len)) -> beginning of i'th line from pos (possibly including comment token) + // + indent_len + comment_len -> -> indent for i'th line + ranges.push(Range::point( + pos + (i * (1 + indent_len + comment_len)) + indent_len + comment_len, + )); } offs += text.chars().count(); @@ -3886,6 +3906,11 @@ pub mod insert { let mut new_text = String::new(); + let continue_comment_token = doc + .language_config() + .and_then(|config| config.comment_tokens.as_ref()) + .and_then(|tokens| comment::get_comment_token(text, tokens, current_line)); + // If the current line is all whitespace, insert a line ending at the beginning of // the current line. This makes the current line empty and the new line contain the // indentation of the old line. @@ -3895,17 +3920,22 @@ pub mod insert { (line_start, line_start, new_text.chars().count()) } else { - let indent = indent::indent_for_newline( - doc.language_config(), - doc.syntax(), - &doc.config.load().indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - current_line, - pos, - current_line, - ); + let line = text.line(current_line); + + let indent = match line.first_non_whitespace_char() { + Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), + _ => indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.config.load().indent_heuristic, + &doc.indent_style, + doc.tab_width(), + text, + current_line, + pos, + current_line, + ), + }; // If we are between pairs (such as brackets), we want to // insert an additional line which is indented one level @@ -3915,19 +3945,30 @@ pub mod insert { .and_then(|pairs| pairs.get(prev)) .map_or(false, |pair| pair.open == prev && pair.close == curr); - let local_offs = if on_auto_pair { + let local_offs = if let Some(token) = continue_comment_token { + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + new_text.push_str(token); + new_text.push(' '); + new_text.chars().count() + } else if on_auto_pair { + // line where the cursor will be let inner_indent = indent.clone() + doc.indent_style.as_str(); new_text.reserve_exact(2 + indent.len() + inner_indent.len()); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&inner_indent); + + // line where the matching pair will be let local_offs = new_text.chars().count(); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); + local_offs } else { new_text.reserve_exact(1 + indent.len()); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); + new_text.chars().count() };