on master help sign in
Implement relative indent queries,

i.e. also take into account the indentation of a previous line when computing the indentation for a new line.

commitd29a66f267afd79f83c0ddc2e483bfb407f4ed98 authorDaniel Ebert <daniel.ebert@protonmail.com> dateSat 16 Sep 2023, 18:22 (3 years ago) parent23fd145
2 files changed+213−85
helix-core/src/indent.rs+211−83 helix-core/tests/indent.rs+2−2
helix-core/src/indent.rs+211−83
@@ -4,6 +4,7 @@ use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
44
55use crate::{
66 chars::{char_is_line_ending, char_is_whitespace},
7+ find_first_non_whitespace_char,
78 graphemes::{grapheme_width, tab_width_at},
89 syntax::{LanguageConfiguration, RopeProvider, Syntax},
910 tree_sitter::Node,
@@ -196,6 +197,19 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize, indent_width: us
196197 len / indent_width
197198}
198199
200+/// Create a string of tabs & spaces that has the same visual width as the given RopeSlice (independent of the tab width).
201+fn whitespace_with_same_width(text: RopeSlice) -> String {
202+ let mut s = String::new();
203+ for grapheme in RopeGraphemes::new(text) {
204+ if grapheme == "\t" {
205+ s.push('\t');
206+ } else {
207+ s.extend(std::iter::repeat(' ').take(grapheme_width(&Cow::from(grapheme))));
208+ }
209+ }
210+ s
211+}
212+
199213/// Computes for node and all ancestors whether they are the first node on their line.
200214/// The first entry in the return value represents the root node, the last one the node itself
201215fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bool> {
@@ -241,21 +255,21 @@ fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bo
241255/// - max(0, indent - outdent) tabs, if tabs are used for indentation
242256/// - max(0, indent - outdent)*indent_width spaces, if spaces are used for indentation
243257#[derive(Default, Debug, PartialEq, Eq, Clone)]
244−pub struct Indentation {
258+pub struct Indentation<'a> {
245259 indent: usize,
246260 indent_always: usize,
247261 outdent: usize,
248262 outdent_always: usize,
249263 /// The alignment, as a string containing only tabs & spaces. Storing this as a string instead of e.g.
250264 /// the (visual) width ensures that the alignment is preserved even if the tab width changes.
251− align: Option<String>,
265+ align: Option<RopeSlice<'a>>,
252266}
253267
254−impl Indentation {
268+impl<'a> Indentation<'a> {
255269 /// Add some other [Indentation] to this.
256270 /// The added indent should be the total added indent from one line.
257271 /// Indent should always be added starting from the bottom (or equivalently, the innermost tree-sitter node).
258− fn add_line(&mut self, added: Indentation) {
272+ fn add_line(&mut self, added: Indentation<'a>) {
259273 // Align overrides the indent from outer scopes.
260274 if self.align.is_some() {
261275 return;
@@ -274,7 +288,7 @@ impl Indentation {
274288 /// Only captures that apply to the same line should be added together in this way (otherwise use `add_line`)
275289 /// and the captures should be added starting from the innermost tree-sitter node (currently this only matters
276290 /// if multiple `@align` patterns occur on the same line).
277− fn add_capture(&mut self, added: IndentCaptureType) {
291+ fn add_capture(&mut self, added: IndentCaptureType<'a>) {
278292 match added {
279293 IndentCaptureType::Indent => {
280294 if self.indent_always == 0 {
@@ -303,7 +317,60 @@ impl Indentation {
303317 }
304318 }
305319 }
306− fn into_string(self, indent_style: &IndentStyle) -> String {
320+ fn net_indent(&self) -> isize {
321+ (self.indent + self.indent_always) as isize
322+ - ((self.outdent + self.outdent_always) as isize)
323+ }
324+ /// Convert `self` into a string, taking into account the computed and actual indentation of some other line.
325+ fn relative_indent(
326+ &self,
327+ other_computed_indent: &Self,
328+ other_leading_whitespace: RopeSlice,
329+ indent_style: &IndentStyle,
330+ tab_width: usize,
331+ ) -> Option<String> {
332+ if self.align == other_computed_indent.align {
333+ // If self and baseline are either not aligned to anything or both aligned the same way,
334+ // we can simply take `other_leading_whitespace` and add some indent / outdent to it (in the second
335+ // case, the alignment should already be accounted for in `other_leading_whitespace`).
336+ let indent_diff = self.net_indent() - other_computed_indent.net_indent();
337+ if indent_diff >= 0 {
338+ let mut indent = other_leading_whitespace.to_string();
339+ indent.push_str(&indent_style.as_str().repeat(indent_diff as usize));
340+ Some(indent)
341+ } else {
342+ // It's not entirely clear how to subtract a given indent level from the other line if its indentation is
343+ // complex (e.g. a weird alignment or a mixture of tabs and spaces). Therefore, we only consider the indent level
344+ // of `baseline_leading_whitespace`, add `indent_diff` to it and convert this indent level back to a string. If we ever encounter
345+ // cases where some other behavior is expected, the behavior for strings that don't exactly correspond to some indent
346+ // level could be re-evaluated.
347+ let actual_baseline_indent_level = indent_level_for_line(
348+ other_leading_whitespace,
349+ tab_width,
350+ indent_style.indent_width(tab_width),
351+ );
352+ let total_indent = actual_baseline_indent_level as isize + indent_diff;
353+ if total_indent < 0 {
354+ log::warn!(
355+ "Computed negative indent during a relative indent computation (actual baseline indent: {}, computed baseline indent: {}, computed line indent: {})",
356+ actual_baseline_indent_level,
357+ other_computed_indent.net_indent(),
358+ self.net_indent(),
359+ );
360+ Some(String::new())
361+ } else {
362+ Some(indent_style.as_str().repeat(total_indent as usize))
363+ }
364+ }
365+ } else if self.align.is_some() {
366+ Some(self.to_string(indent_style))
367+ } else {
368+ // If the other line has some alignment and `self` does not, there is no reasonable way to take
369+ // into account `other_leading_whitespace`.
370+ None
371+ }
372+ }
373+ pub fn to_string(&self, indent_style: &IndentStyle) -> String {
307374 let indent = self.indent_always + self.indent;
308375 let outdent = self.outdent_always + self.outdent;
309376
@@ -314,7 +381,7 @@ impl Indentation {
314381 0
315382 };
316383 let mut indent_string = if let Some(align) = self.align {
317− align
384+ whitespace_with_same_width(align)
318385 } else {
319386 String::new()
320387 };
@@ -325,21 +392,21 @@ impl Indentation {
325392
326393/// An indent definition which corresponds to a capture from the indent query
327394#[derive(Debug)]
328−struct IndentCapture {
329− capture_type: IndentCaptureType,
395+struct IndentCapture<'a> {
396+ capture_type: IndentCaptureType<'a>,
330397 scope: IndentScope,
331398}
332399#[derive(Debug, Clone, PartialEq)]
333−enum IndentCaptureType {
400+enum IndentCaptureType<'a> {
334401 Indent,
335402 IndentAlways,
336403 Outdent,
337404 OutdentAlways,
338405 /// Alignment given as a string of whitespace
339− Align(String),
406+ Align(RopeSlice<'a>),
340407}
341408
342−impl IndentCaptureType {
409+impl<'a> IndentCaptureType<'a> {
343410 fn default_scope(&self) -> IndentScope {
344411 match self {
345412 IndentCaptureType::Indent | IndentCaptureType::IndentAlways => IndentScope::Tail,
@@ -371,8 +438,8 @@ enum ExtendCapture {
371438/// each node (identified by its ID) the relevant captures (already filtered
372439/// by predicates).
373440#[derive(Debug)]
374−struct IndentQueryResult {
375− indent_captures: HashMap<usize, Vec<IndentCapture>>,
441+struct IndentQueryResult<'a> {
442+ indent_captures: HashMap<usize, Vec<IndentCapture<'a>>>,
376443 extend_captures: HashMap<usize, Vec<ExtendCapture>>,
377444}
378445
@@ -393,14 +460,14 @@ fn get_node_end_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
393460 node_line
394461}
395462
396−fn query_indents(
463+fn query_indents<'a>(
397464 query: &Query,
398465 syntax: &Syntax,
399466 cursor: &mut QueryCursor,
400− text: RopeSlice,
467+ text: RopeSlice<'a>,
401468 range: std::ops::Range<usize>,
402469 new_line_byte_pos: Option<usize>,
403−) -> IndentQueryResult {
470+) -> IndentQueryResult<'a> {
404471 let mut indent_captures: HashMap<usize, Vec<IndentCapture>> = HashMap::new();
405472 let mut extend_captures: HashMap<usize, Vec<ExtendCapture>> = HashMap::new();
406473 cursor.set_byte_range(range);
@@ -488,7 +555,7 @@ fn query_indents(
488555 "outdent" => IndentCaptureType::Outdent,
489556 "outdent.always" => IndentCaptureType::OutdentAlways,
490557 // The alignment will be updated to the correct value at the end, when the anchor is known.
491− "align" => IndentCaptureType::Align(String::from("")),
558+ "align" => IndentCaptureType::Align(RopeSlice::from("")),
492559 "anchor" => {
493560 if anchor.is_some() {
494561 log::error!("Invalid indent query: Encountered more than one @anchor in the same match.")
@@ -560,22 +627,10 @@ fn query_indents(
560627 }
561628 Some(anchor) => anchor,
562629 };
563− // Create a string of tabs & spaces that should have the same width
564− // as the string that precedes the anchor (independent of the tab width).
565− let mut align = String::new();
566− for grapheme in RopeGraphemes::new(
630+ capture.capture_type = IndentCaptureType::Align(
567631 text.line(anchor.start_position().row)
568632 .byte_slice(0..anchor.start_position().column),
569− ) {
570− if grapheme == "\t" {
571− align.push('\t');
572− } else {
573− align.extend(
574− std::iter::repeat(' ').take(grapheme_width(&Cow::from(grapheme))),
575− );
576− }
577− }
578− capture.capture_type = IndentCaptureType::Align(align);
633+ );
579634 }
580635 indent_captures
581636 .entry(node_id)
@@ -661,56 +716,20 @@ fn extend_nodes<'a>(
661716 }
662717}
663718
664−/// Use the syntax tree to determine the indentation for a given position.
665−/// This can be used in 2 ways:
666−///
667−/// - To get the correct indentation for an existing line (new_line=false), not necessarily equal to the current indentation.
668−/// - In this case, pos should be inside the first tree-sitter node on that line.
669−/// In most cases, this can just be the first non-whitespace on that line.
670−/// - To get the indentation for a new line (new_line=true). This behaves like the first usecase if the part of the current line
671−/// after pos were moved to a new line.
672−///
673−/// The indentation is determined by traversing all the tree-sitter nodes containing the position.
674−/// Each of these nodes produces some [Indentation] for:
675−///
676−/// - The line of the (beginning of the) node. This is defined by the scope `all` if this is the first node on its line.
677−/// - The line after the node. This is defined by:
678−/// - The scope `tail`.
679−/// - The scope `all` if this node is not the first node on its line.
680−/// Intuitively, `all` applies to everything contained in this node while `tail` applies to everything except for the first line of the node.
681−/// The indents from different nodes for the same line are then combined.
682−/// The result [Indentation] is simply the sum of the [Indentation] for all lines.
683−///
684−/// Specifying which line exactly an [Indentation] applies to is important because indents on the same line combine differently than indents on different lines:
685−/// ```ignore
686−/// some_function(|| {
687−/// // Both the function parameters as well as the contained block should be indented.
688−/// // Because they are on the same line, this only yields one indent level
689−/// });
690−/// ```
691−///
692−/// ```ignore
693−/// some_function(
694−/// param1,
695−/// || {
696−/// // Here we get 2 indent levels because the 'parameters' and the 'block' node begin on different lines
697−/// },
698−/// );
699−/// ```
719+/// Prepare an indent query by computing:
720+/// - The node from which to start the query (this is non-trivial due to `@extend` captures)
721+/// - The indent captures for all relevant nodes.
700722#[allow(clippy::too_many_arguments)]
701−pub fn treesitter_indent_for_pos(
723+fn init_indent_query<'a, 'b>(
702724 query: &Query,
703− syntax: &Syntax,
704− indent_style: &IndentStyle,
725+ syntax: &'a Syntax,
726+ text: RopeSlice<'b>,
705727 tab_width: usize,
706728 indent_width: usize,
707− text: RopeSlice,
708729 line: usize,
709− pos: usize,
710− new_line: bool,
711−) -> Option<String> {
712− let byte_pos = text.char_to_byte(pos);
713− let new_line_byte_pos = new_line.then_some(byte_pos);
730+ byte_pos: usize,
731+ new_line_byte_pos: Option<usize>,
732+) -> Option<(Node<'a>, HashMap<usize, Vec<IndentCapture<'b>>>)> {
714733 // The innermost tree-sitter node which is considered for the indent
715734 // computation. It may change if some predeceding node is extended
716735 let mut node = syntax
@@ -754,7 +773,6 @@ pub fn treesitter_indent_for_pos(
754773 (query_result, deepest_preceding)
755774 })
756775 };
757− let mut indent_captures = query_result.indent_captures;
758776 let extend_captures = query_result.extend_captures;
759777
760778 // Check for extend captures, potentially changing the node that the indent calculation starts with
@@ -769,6 +787,68 @@ pub fn treesitter_indent_for_pos(
769787 indent_width,
770788 );
771789 }
790+ Some((node, query_result.indent_captures))
791+}
792+
793+/// Use the syntax tree to determine the indentation for a given position.
794+/// This can be used in 2 ways:
795+///
796+/// - To get the correct indentation for an existing line (new_line=false), not necessarily equal to the current indentation.
797+/// - In this case, pos should be inside the first tree-sitter node on that line.
798+/// In most cases, this can just be the first non-whitespace on that line.
799+/// - To get the indentation for a new line (new_line=true). This behaves like the first usecase if the part of the current line
800+/// after pos were moved to a new line.
801+///
802+/// The indentation is determined by traversing all the tree-sitter nodes containing the position.
803+/// Each of these nodes produces some [Indentation] for:
804+///
805+/// - The line of the (beginning of the) node. This is defined by the scope `all` if this is the first node on its line.
806+/// - The line after the node. This is defined by:
807+/// - The scope `tail`.
808+/// - The scope `all` if this node is not the first node on its line.
809+/// Intuitively, `all` applies to everything contained in this node while `tail` applies to everything except for the first line of the node.
810+/// The indents from different nodes for the same line are then combined.
811+/// The result [Indentation] is simply the sum of the [Indentation] for all lines.
812+///
813+/// Specifying which line exactly an [Indentation] applies to is important because indents on the same line combine differently than indents on different lines:
814+/// ```ignore
815+/// some_function(|| {
816+/// // Both the function parameters as well as the contained block should be indented.
817+/// // Because they are on the same line, this only yields one indent level
818+/// });
819+/// ```
820+///
821+/// ```ignore
822+/// some_function(
823+/// param1,
824+/// || {
825+/// // Here we get 2 indent levels because the 'parameters' and the 'block' node begin on different lines
826+/// },
827+/// );
828+/// ```
829+#[allow(clippy::too_many_arguments)]
830+pub fn treesitter_indent_for_pos<'a>(
831+ query: &Query,
832+ syntax: &Syntax,
833+ tab_width: usize,
834+ indent_width: usize,
835+ text: RopeSlice<'a>,
836+ line: usize,
837+ pos: usize,
838+ new_line: bool,
839+) -> Option<Indentation<'a>> {
840+ let byte_pos = text.char_to_byte(pos);
841+ let new_line_byte_pos = new_line.then_some(byte_pos);
842+ let (mut node, mut indent_captures) = init_indent_query(
843+ query,
844+ syntax,
845+ text,
846+ tab_width,
847+ indent_width,
848+ line,
849+ byte_pos,
850+ new_line_byte_pos,
851+ )?;
772852 let mut first_in_line = get_first_in_line(node, new_line.then_some(byte_pos));
773853
774854 let mut result = Indentation::default();
@@ -836,7 +916,7 @@ pub fn treesitter_indent_for_pos(
836916 break;
837917 }
838918 }
839− Some(result.into_string(indent_style))
919+ Some(result)
840920}
841921
842922/// Returns the indentation for a new line.
@@ -860,7 +940,6 @@ pub fn indent_for_newline(
860940 if let Some(indent) = treesitter_indent_for_pos(
861941 query,
862942 syntax,
863− indent_style,
864943 tab_width,
865944 indent_width,
866945 text,
@@ -868,9 +947,55 @@ pub fn indent_for_newline(
868947 line_before_end_pos,
869948 true,
870949 ) {
871− return indent;
950+ // We want to compute the indentation not only based on the
951+ // syntax tree but also on the actual indentation of a previous
952+ // line. This makes indentation computation more resilient to
953+ // incomplete queries, incomplete source code & differing indentation
954+ // styles for the same language.
955+ // However, using the indent of a previous line as a baseline may not
956+ // make sense, e.g. if it has a different alignment than the new line.
957+ // In order to prevent edge cases with long running times, we only try
958+ // a constant number of (non-empty) lines.
959+ const MAX_ATTEMPTS: usize = 2;
960+ let mut num_attempts = 0;
961+ for line_idx in (0..=line_before).rev() {
962+ let line = text.line(line_idx);
963+ let first_non_whitespace_char = match find_first_non_whitespace_char(line) {
964+ Some(i) => i,
965+ None => {
966+ continue;
967+ }
968+ };
969+ if let Some(indent) = (|| {
970+ let computed_indent = treesitter_indent_for_pos(
971+ query,
972+ syntax,
973+ tab_width,
974+ indent_width,
975+ text,
976+ line_idx,
977+ text.line_to_char(line_idx) + first_non_whitespace_char,
978+ false,
979+ )?;
980+ let leading_whitespace = line.slice(0..first_non_whitespace_char);
981+ indent.relative_indent(
982+ &computed_indent,
983+ leading_whitespace,
984+ indent_style,
985+ tab_width,
986+ )
987+ })() {
988+ return indent;
989+ }
990+ num_attempts += 1;
991+ if num_attempts == MAX_ATTEMPTS {
992+ break;
993+ }
994+ }
995+ return indent.to_string(indent_style);
872996 };
873997 }
998+ // Fallback in case we either don't have indent queries or they failed for some reason
874999 let indent_level = indent_level_for_line(text.line(current_line), tab_width, indent_width);
8751000 indent_style.as_str().repeat(indent_level)
8761001}
@@ -962,10 +1087,13 @@ mod test {
9621087 ..Default::default()
9631088 };
9641089
965− let add_capture = |mut indent: Indentation, capture| {
1090+ fn add_capture<'a>(
1091+ mut indent: Indentation<'a>,
1092+ capture: IndentCaptureType<'a>,
1093+ ) -> Indentation<'a> {
9661094 indent.add_capture(capture);
9671095 indent
968− };
1096+ }
9691097
9701098 // adding an indent to no indent makes an indent
9711099 assert_eq!(
helix-core/tests/indent.rs+2−2
@@ -210,7 +210,6 @@ fn test_treesitter_indent(
210210 let suggested_indent = treesitter_indent_for_pos(
211211 indent_query,
212212 &syntax,
213− &indent_style,
214213 tab_width,
215214 indent_style.indent_width(tab_width),
216215 text,
@@ -218,7 +217,8 @@ fn test_treesitter_indent(
218217 text.line_to_char(i) + pos,
219218 false,
220219 )
221− .unwrap();
220+ .unwrap()
221+ .to_string(&indent_style);
222222 assert!(
223223 line.get_slice(..pos).map_or(false, |s| s == suggested_indent),
224224 "Wrong indentation for file {:?} on line {}:\n\"{}\" (original line)\n\"{}\" (suggested indentation)\n",