Unnamed repository; edit this file 'description' to name the repository.
feat: folding ranges for chained expressions
| -rw-r--r-- | crates/ide/src/folding_ranges.rs | 358 | ||||
| -rw-r--r-- | crates/ide/src/lib.rs | 7 | ||||
| -rw-r--r-- | crates/ide/src/static_index.rs | 2 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/handlers/request.rs | 6 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/lsp/capabilities.rs | 14 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/lsp/to_proto.rs | 22 |
6 files changed, 353 insertions, 56 deletions
diff --git a/crates/ide/src/folding_ranges.rs b/crates/ide/src/folding_ranges.rs index 3969490e8d..ebd6c274a2 100644 --- a/crates/ide/src/folding_ranges.rs +++ b/crates/ide/src/folding_ranges.rs @@ -1,10 +1,12 @@ use ide_db::{FxHashSet, syntax_helpers::node_ext::vis_eq}; +use itertools::Itertools; use syntax::{ - Direction, NodeOrToken, SourceFile, - SyntaxKind::{self, *}, + Direction, NodeOrToken, SourceFile, SyntaxElement, + SyntaxKind::*, SyntaxNode, TextRange, TextSize, - ast::{self, AstNode, AstToken}, + ast::{self, AstNode, AstToken, HasArgList, edit::AstNodeEdit}, match_ast, + syntax_editor::Element, }; use std::hash::Hash; @@ -12,7 +14,7 @@ use std::hash::Hash; const REGION_START: &str = "// region:"; const REGION_END: &str = "// endregion"; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum FoldKind { Comment, Imports, @@ -29,21 +31,36 @@ pub enum FoldKind { Consts, Statics, TypeAliases, + TraitAliases, ExternCrates, // endregion: item runs + Stmt, + TailExpr, } #[derive(Debug)] pub struct Fold { pub range: TextRange, pub kind: FoldKind, + pub collapsed_text: Option<String>, +} + +impl Fold { + pub fn new(range: TextRange, kind: FoldKind) -> Self { + Self { range, kind, collapsed_text: None } + } + + pub fn with_text(mut self, text: String) -> Self { + self.collapsed_text = Some(text); + self + } } // Feature: Folding // // Defines folding regions for curly braced blocks, runs of consecutive use, mod, const or static // items, and `region` / `endregion` comment markers. -pub(crate) fn folding_ranges(file: &SourceFile) -> Vec<Fold> { +pub(crate) fn folding_ranges(file: &SourceFile, collapsed_text: bool) -> Vec<Fold> { let mut res = vec![]; let mut visited_comments = FxHashSet::default(); let mut visited_nodes = FxHashSet::default(); @@ -53,39 +70,36 @@ pub(crate) fn folding_ranges(file: &SourceFile) -> Vec<Fold> { for element in file.syntax().descendants_with_tokens() { // Fold items that span multiple lines - if let Some(kind) = fold_kind(element.kind()) { + if let Some(kind) = fold_kind(element.clone()) { let is_multiline = match &element { NodeOrToken::Node(node) => node.text().contains_char('\n'), NodeOrToken::Token(token) => token.text().contains('\n'), }; + if is_multiline { - // for the func with multiline param list - if matches!(element.kind(), FN) - && let NodeOrToken::Node(node) = &element - && let Some(fn_node) = ast::Fn::cast(node.clone()) + if let NodeOrToken::Node(node) = &element + && let Some(fn_) = ast::Fn::cast(node.clone()) { - if !fn_node + if !fn_ .param_list() .map(|param_list| param_list.syntax().text().contains_char('\n')) - .unwrap_or(false) + .unwrap_or_default() { continue; } - if fn_node.body().is_some() { + if let Some(body) = fn_.body() { // Get the actual start of the function (excluding doc comments) - let fn_start = fn_node + let fn_start = fn_ .fn_token() .map(|token| token.text_range().start()) .unwrap_or(node.text_range().start()); - res.push(Fold { - range: TextRange::new(fn_start, node.text_range().end()), - kind: FoldKind::Function, - }); + res.push(build_fold(&element, kind, collapsed_text)); continue; } } - res.push(Fold { range: element.text_range(), kind }); + + res.push(build_fold(&element, kind, collapsed_text)); continue; } } @@ -102,15 +116,15 @@ pub(crate) fn folding_ranges(file: &SourceFile) -> Vec<Fold> { region_starts.push(comment.syntax().text_range().start()); } else if text.starts_with(REGION_END) { if let Some(region) = region_starts.pop() { - res.push(Fold { - range: TextRange::new(region, comment.syntax().text_range().end()), - kind: FoldKind::Region, - }) + res.push(Fold::new( + TextRange::new(region, comment.syntax().text_range().end()), + FoldKind::Region, + )); } } else if let Some(range) = contiguous_range_for_comment(comment, &mut visited_comments) { - res.push(Fold { range, kind: FoldKind::Comment }) + res.push(Fold::new(range, FoldKind::Comment)); } } } @@ -123,37 +137,42 @@ pub(crate) fn folding_ranges(file: &SourceFile) -> Vec<Fold> { module, &mut visited_nodes, ) { - res.push(Fold { range, kind: FoldKind::Modules }) + res.push(Fold::new(range, FoldKind::Modules)); } }, ast::Use(use_) => { if let Some(range) = contiguous_range_for_item_group(use_, &mut visited_nodes) { - res.push(Fold { range, kind: FoldKind::Imports }) + res.push(Fold::new(range, FoldKind::Imports)); } }, ast::Const(konst) => { if let Some(range) = contiguous_range_for_item_group(konst, &mut visited_nodes) { - res.push(Fold { range, kind: FoldKind::Consts }) + res.push(Fold::new(range, FoldKind::Consts)); } }, ast::Static(statik) => { if let Some(range) = contiguous_range_for_item_group(statik, &mut visited_nodes) { - res.push(Fold { range, kind: FoldKind::Statics }) + res.push(Fold::new(range, FoldKind::Statics)); } }, ast::TypeAlias(alias) => { if let Some(range) = contiguous_range_for_item_group(alias, &mut visited_nodes) { - res.push(Fold { range, kind: FoldKind::TypeAliases }) + res.push(Fold::new(range, FoldKind::TypeAliases)); + } + }, + ast::TraitAlias(alias) => { + if let Some(range) = contiguous_range_for_item_group(alias, &mut visited_nodes) { + res.push(Fold::new(range, FoldKind::TraitAliases)); } }, ast::ExternCrate(extern_crate) => { if let Some(range) = contiguous_range_for_item_group(extern_crate, &mut visited_nodes) { - res.push(Fold { range, kind: FoldKind::ExternCrates }) + res.push(Fold::new(range, FoldKind::ExternCrates)); } }, ast::MatchArm(match_arm) => { if let Some(range) = fold_range_for_multiline_match_arm(match_arm) { - res.push(Fold {range, kind: FoldKind::MatchArm}) + res.push(Fold::new(range, FoldKind::MatchArm)); } }, _ => (), @@ -166,8 +185,93 @@ pub(crate) fn folding_ranges(file: &SourceFile) -> Vec<Fold> { res } -fn fold_kind(kind: SyntaxKind) -> Option<FoldKind> { - match kind { +/// Builds a fold for the given syntax element. +/// +/// This function creates a `Fold` object that represents a collapsible region in the code. +/// If `collapsed_text` is enabled, it generates a preview text for certain fold kinds that +/// shows a summarized version of the folded content. +fn build_fold(element: &SyntaxElement, kind: FoldKind, collapsed_text: bool) -> Fold { + if !collapsed_text { + return Fold::new(element.text_range(), kind); + } + + let fold_with_collapsed_text = match kind { + FoldKind::TailExpr => { + let expr = ast::Expr::cast(element.as_node().unwrap().clone()).unwrap(); + + let indent_level = expr.indent_level().0; + let indents = " ".repeat(indent_level as usize); + + let mut fold = Fold::new(element.text_range(), kind); + if let Some(collapsed_expr) = collapsed_text_from_expr(expr) { + fold = fold.with_text(format!("{indents}{collapsed_expr}")); + } + Some(fold) + } + FoldKind::Stmt => 'blk: { + let node = element.as_node().unwrap(); + + match_ast! { + match node { + ast::ExprStmt(expr) => { + let Some(expr) = expr.expr() else { + break 'blk None; + }; + + let indent_level = expr.indent_level().0; + let indents = " ".repeat(indent_level as usize); + + let mut fold = Fold::new(element.text_range(), kind); + if let Some(collapsed_expr) = collapsed_text_from_expr(expr) { + fold = fold.with_text(format!("{indents}{collapsed_expr};")); + } + Some(fold) + }, + ast::LetStmt(let_stmt) => { + if let_stmt.let_else().is_some() { + break 'blk None; + } + + let Some(expr) = let_stmt.initializer() else { + break 'blk None; + }; + + let expr_offset = + expr.syntax().text_range().start() - let_stmt.syntax().text_range().start(); + let text_before_expr = let_stmt.syntax().text().slice(..expr_offset); + if text_before_expr.contains_char('\n') { + break 'blk None; + } + + let indent_level = let_stmt.indent_level().0; + let indents = " ".repeat(indent_level as usize); + + let mut fold = Fold::new(element.text_range(), kind); + if let Some(collapsed_expr) = collapsed_text_from_expr(expr) { + fold = fold.with_text(format!("{indents}{text_before_expr}{collapsed_expr};")); + } + Some(fold) + }, + _ => None, + } + } + } + _ => None, + }; + + fold_with_collapsed_text.unwrap_or_else(|| Fold::new(element.text_range(), kind)) +} + +fn fold_kind(element: SyntaxElement) -> Option<FoldKind> { + // handle tail_expr + if let Some(node) = element.as_node() + && let Some(block) = node.parent().and_then(|it| it.parent()).and_then(ast::BlockExpr::cast) // tail_expr -> stmt_list -> block + && block.tail_expr().is_some_and(|tail| tail.syntax() == node) + { + return Some(FoldKind::TailExpr); + } + + match element.kind() { COMMENT => Some(FoldKind::Comment), ARG_LIST | PARAM_LIST | GENERIC_ARG_LIST | GENERIC_PARAM_LIST => Some(FoldKind::ArgList), ARRAY_EXPR => Some(FoldKind::Array), @@ -185,10 +289,105 @@ fn fold_kind(kind: SyntaxKind) -> Option<FoldKind> { | MATCH_ARM_LIST | VARIANT_LIST | TOKEN_TREE => Some(FoldKind::Block), + EXPR_STMT | LET_STMT => Some(FoldKind::Stmt), _ => None, } } +/// Generates a collapsed text representation of a chained expression. +/// +/// This function analyzes an expression and creates a concise string representation +/// that shows the structure of method chains, field accesses, and function calls. +/// It's particularly useful for folding long chained expressions like: +/// `obj.method1()?.field.method2(args)` -> `obj.method1()?.field.method2(…)` +/// +/// The function traverses the expression tree from the outermost expression inward, +/// collecting method names, field names, and call signatures. It accumulates try +/// operators (`?`) and applies them to the appropriate parts of the chain. +/// +/// # Parameters +/// - `expr`: The expression to generate collapsed text for +/// +/// # Returns +/// - `Some(String)`: A dot-separated chain representation if the expression is chainable +/// - `None`: If the expression is not suitable for collapsing (e.g., simple literals) +/// +/// # Examples +/// - `foo.bar().baz?` -> `"foo.bar().baz?"` +/// - `obj.method(arg1, arg2)` -> `"obj.method(…)"` +/// - `value?.field` -> `"value?.field"` +fn collapsed_text_from_expr(mut expr: ast::Expr) -> Option<String> { + let mut names = Vec::new(); + let mut try_marks = String::with_capacity(1); + + let fold_general_expr = |expr: ast::Expr, try_marks: &mut String| { + let text = expr.syntax().text(); + let name = if text.contains_char('\n') { + format!("<expr>{try_marks}") + } else { + format!("{text}{try_marks}") + }; + try_marks.clear(); + name + }; + + loop { + let receiver = match expr { + ast::Expr::MethodCallExpr(call) => { + let name = call + .name_ref() + .map(|name| name.text().to_owned()) + .unwrap_or_else(|| "�".into()); + if call.arg_list().and_then(|arg_list| arg_list.args().next()).is_some() { + names.push(format!("{name}(…){try_marks}")); + } else { + names.push(format!("{name}(){try_marks}")); + } + try_marks.clear(); + call.receiver() + } + ast::Expr::FieldExpr(field) => { + let name = match field.field_access() { + Some(ast::FieldKind::Name(name)) => format!("{name}{try_marks}"), + Some(ast::FieldKind::Index(index)) => format!("{index}{try_marks}"), + None => format!("�{try_marks}"), + }; + names.push(name); + try_marks.clear(); + field.expr() + } + ast::Expr::TryExpr(try_expr) => { + try_marks.push('?'); + try_expr.expr() + } + ast::Expr::CallExpr(call) => { + let name = fold_general_expr(call.expr().unwrap(), &mut try_marks); + if call.arg_list().and_then(|arg_list| arg_list.args().next()).is_some() { + names.push(format!("{name}(…){try_marks}")); + } else { + names.push(format!("{name}(){try_marks}")); + } + try_marks.clear(); + None + } + e => { + if names.is_empty() { + return None; + } + names.push(fold_general_expr(e, &mut try_marks)); + None + } + }; + if let Some(receiver) = receiver { + expr = receiver; + } else { + break; + } + } + + Some(names.iter().rev().join(".")) +} + fn contiguous_range_for_item_group<N>( first: N, visited: &mut FxHashSet<SyntaxNode>, @@ -297,7 +496,7 @@ fn contiguous_range_for_comment( } fn fold_range_for_multiline_match_arm(match_arm: ast::MatchArm) -> Option<TextRange> { - if fold_kind(match_arm.expr()?.syntax().kind()).is_some() { + if fold_kind(match_arm.expr()?.syntax().syntax_element()).is_some() { None } else if match_arm.expr()?.syntax().text().contains_char('\n') { Some(match_arm.expr()?.syntax().text_range()) @@ -314,10 +513,33 @@ mod tests { #[track_caller] fn check(#[rust_analyzer::rust_fixture] ra_fixture: &str) { + check_inner(ra_fixture, true); + } + + fn check_without_collapsed_text(#[rust_analyzer::rust_fixture] ra_fixture: &str) { + check_inner(ra_fixture, false); + } + + fn check_inner(ra_fixture: &str, enable_collapsed_text: bool) { let (ranges, text) = extract_tags(ra_fixture, "fold"); + let ranges = ranges + .into_iter() + .map(|(range, text)| { + let (attr, collapsed_text) = match text { + Some(text) => match text.split_once(':') { + Some((attr, collapsed_text)) => { + (Some(attr.to_owned()), Some(collapsed_text.to_owned())) + } + None => (Some(text), None), + }, + None => (None, None), + }; + (range, attr, collapsed_text) + }) + .collect_vec(); let parse = SourceFile::parse(&text, span::Edition::CURRENT); - let mut folds = folding_ranges(&parse.tree()); + let mut folds = folding_ranges(&parse.tree(), enable_collapsed_text); folds.sort_by_key(|fold| (fold.range.start(), fold.range.end())); assert_eq!( @@ -326,7 +548,7 @@ mod tests { "The amount of folds is different than the expected amount" ); - for (fold, (range, attr)) in folds.iter().zip(ranges.into_iter()) { + for (fold, (range, attr, collapsed_text)) in folds.iter().zip(ranges.into_iter()) { assert_eq!(fold.range.start(), range.start(), "mismatched start of folding ranges"); assert_eq!(fold.range.end(), range.end(), "mismatched end of folding ranges"); @@ -346,8 +568,15 @@ mod tests { FoldKind::MatchArm => "matcharm", FoldKind::Function => "function", FoldKind::ExternCrates => "externcrates", + FoldKind::Stmt => "stmt", + FoldKind::TailExpr => "tailexpr", }; assert_eq!(kind, &attr.unwrap()); + if enable_collapsed_text { + assert_eq!(fold.collapsed_text, collapsed_text); + } else { + assert_eq!(fold.collapsed_text, None); + } } } @@ -511,10 +740,10 @@ macro_rules! foo <fold block>{ check( r#" fn main() <fold block>{ - match 0 <fold block>{ + <fold tailexpr>match 0 <fold block>{ 0 => 0, _ => 1, - }</fold> + }</fold></fold> }</fold> "#, ); @@ -525,7 +754,7 @@ fn main() <fold block>{ check( r#" fn main() <fold block>{ - match foo <fold block>{ + <fold tailexpr>match foo <fold block>{ block => <fold block>{ }</fold>, matcharm => <fold matcharm>some. @@ -544,7 +773,7 @@ fn main() <fold block>{ structS => <fold matcharm>StructS <fold block>{ a: 31, }</fold></fold>, - }</fold> + }</fold></fold> }</fold> "#, ) @@ -555,11 +784,11 @@ fn main() <fold block>{ check( r#" fn main() <fold block>{ - frobnicate<fold arglist>( + <fold tailexpr: frobnicate(…)>frobnicate<fold arglist>( 1, 2, 3, - )</fold> + )</fold></fold> }</fold> "#, ) @@ -698,4 +927,49 @@ type Foo<T, U> = foo<fold arglist>< "#, ); } + fn test_fold_tail_expr() { + check( + r#" +fn f() <fold block>{ + let x = 1; + + <fold tailexpr: some_function().chain().method()>some_function() + .chain() + .method()</fold> +}</fold> +"#, + ) + } + + #[test] + fn test_fold_let_stmt_with_chained_methods() { + check( + r#" +fn main() <fold block>{ + <fold stmt: let result = some_value.method1().method2()?.method3();>let result = some_value + .method1() + .method2()? + .method3();</fold> + + println!("{}", result); +}</fold> +"#, + ) + } + + #[test] + fn test_fold_let_stmt_with_chained_methods_without_collapsed_text() { + check_without_collapsed_text( + r#" +fn main() <fold block>{ + <fold stmt>let result = some_value + .method1() + .method2()? + .method3();</fold> + + println!("{}", result); +}</fold> +"#, + ) + } } diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 930eaf2262..be0b96d783 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -501,12 +501,15 @@ impl Analysis { } /// Returns the set of folding ranges. - pub fn folding_ranges(&self, file_id: FileId) -> Cancellable<Vec<Fold>> { + pub fn folding_ranges(&self, file_id: FileId, collapsed_text: bool) -> Cancellable<Vec<Fold>> { self.with_db(|db| { let editioned_file_id_wrapper = EditionedFileId::current_edition_guess_origin(&self.db, file_id); - folding_ranges::folding_ranges(&db.parse(editioned_file_id_wrapper).tree()) + folding_ranges::folding_ranges( + &db.parse(editioned_file_id_wrapper).tree(), + collapsed_text, + ) }) } diff --git a/crates/ide/src/static_index.rs b/crates/ide/src/static_index.rs index aba6b64f97..6dd73e4e26 100644 --- a/crates/ide/src/static_index.rs +++ b/crates/ide/src/static_index.rs @@ -159,7 +159,7 @@ pub enum VendoredLibrariesConfig<'a> { impl StaticIndex<'_> { fn add_file(&mut self, file_id: FileId) { let current_crate = crates_for(self.db, file_id).pop().map(Into::into); - let folds = self.analysis.folding_ranges(file_id).unwrap(); + let folds = self.analysis.folding_ranges(file_id, true).unwrap(); let inlay_hints = self .analysis .inlay_hints( diff --git a/crates/rust-analyzer/src/handlers/request.rs b/crates/rust-analyzer/src/handlers/request.rs index ad07da7759..2cb7825f8e 100644 --- a/crates/rust-analyzer/src/handlers/request.rs +++ b/crates/rust-analyzer/src/handlers/request.rs @@ -1264,11 +1264,15 @@ pub(crate) fn handle_folding_range( params: FoldingRangeParams, ) -> anyhow::Result<Option<Vec<FoldingRange>>> { let _p = tracing::info_span!("handle_folding_range").entered(); + let file_id = try_default!(from_proto::file_id(&snap, ¶ms.text_document.uri)?); - let folds = snap.analysis.folding_ranges(file_id)?; + let collapsed_text = snap.config.folding_range_collapsed_text(); + let folds = snap.analysis.folding_ranges(file_id, collapsed_text)?; + let text = snap.analysis.file_text(file_id)?; let line_index = snap.file_line_index(file_id)?; let line_folding_only = snap.config.line_folding_only(); + let res = folds .into_iter() .map(|it| to_proto::folding_range(&text, &line_index, line_folding_only, it)) diff --git a/crates/rust-analyzer/src/lsp/capabilities.rs b/crates/rust-analyzer/src/lsp/capabilities.rs index d6a694be91..3ad4cb70b4 100644 --- a/crates/rust-analyzer/src/lsp/capabilities.rs +++ b/crates/rust-analyzer/src/lsp/capabilities.rs @@ -335,6 +335,20 @@ impl ClientCapabilities { .unwrap_or_default() } + pub fn folding_range_collapsed_text(&self) -> bool { + (|| -> _ { + self.0 + .text_document + .as_ref()? + .folding_range + .as_ref()? + .folding_range + .as_ref()? + .collapsed_text + })() + .unwrap_or_default() + } + pub fn hierarchical_symbols(&self) -> bool { (|| -> _ { self.0 diff --git a/crates/rust-analyzer/src/lsp/to_proto.rs b/crates/rust-analyzer/src/lsp/to_proto.rs index 6f0f57725f..ea613ec656 100644 --- a/crates/rust-analyzer/src/lsp/to_proto.rs +++ b/crates/rust-analyzer/src/lsp/to_proto.rs @@ -907,9 +907,9 @@ pub(crate) fn folding_range( text: &str, line_index: &LineIndex, line_folding_only: bool, - fold: Fold, + Fold { range: text_range, kind, collapsed_text }: Fold, ) -> lsp_types::FoldingRange { - let kind = match fold.kind { + let kind = match kind { FoldKind::Comment => Some(lsp_types::FoldingRangeKind::Comment), FoldKind::Imports => Some(lsp_types::FoldingRangeKind::Imports), FoldKind::Region => Some(lsp_types::FoldingRangeKind::Region), @@ -924,17 +924,19 @@ pub(crate) fn folding_range( | FoldKind::Array | FoldKind::ExternCrates | FoldKind::MatchArm - | FoldKind::Function => None, + | FoldKind::Function + | FoldKind::Stmt + | FoldKind::TailExpr => None, }; - let range = range(line_index, fold.range); + let range = range(line_index, text_range); if line_folding_only { // Clients with line_folding_only == true (such as VSCode) will fold the whole end line // even if it contains text not in the folding range. To prevent that we exclude // range.end.line from the folding region if there is more text after range.end // on the same line. - let has_more_text_on_end_line = text[TextRange::new(fold.range.end(), TextSize::of(text))] + let has_more_text_on_end_line = text[TextRange::new(text_range.end(), TextSize::of(text))] .chars() .take_while(|it| *it != '\n') .any(|it| !it.is_whitespace()); @@ -951,7 +953,7 @@ pub(crate) fn folding_range( end_line, end_character: None, kind, - collapsed_text: None, + collapsed_text, } } else { lsp_types::FoldingRange { @@ -960,7 +962,7 @@ pub(crate) fn folding_range( end_line: range.end.line, end_character: Some(range.end.character), kind, - collapsed_text: None, + collapsed_text, } } } @@ -2031,8 +2033,8 @@ fn main() { }"#; let (analysis, file_id) = Analysis::from_single_file(text.to_owned()); - let folds = analysis.folding_ranges(file_id).unwrap(); - assert_eq!(folds.len(), 4); + let folds = analysis.folding_ranges(file_id, true).unwrap(); + assert_eq!(folds.len(), 5); let line_index = LineIndex { index: Arc::new(ide::LineIndex::new(text)), @@ -2042,7 +2044,7 @@ fn main() { let converted: Vec<lsp_types::FoldingRange> = folds.into_iter().map(|it| folding_range(text, &line_index, true, it)).collect(); - let expected_lines = [(0, 2), (4, 10), (5, 6), (7, 9)]; + let expected_lines = [(0, 2), (4, 10), (5, 9), (5, 6), (7, 9)]; assert_eq!(converted.len(), expected_lines.len()); for (folding_range, (start_line, end_line)) in converted.iter().zip(expected_lines.iter()) { assert_eq!(folding_range.start_line, *start_line); |