//! Implementation of "closing brace" inlay hints: //! ```no_run //! fn g() { //! } /* fn g */ //! ``` use hir::{DisplayTarget, HirDisplay, InRealFile, Semantics}; use ide_db::{FileRange, RootDatabase}; use syntax::{ SyntaxKind, SyntaxNode, T, ast::{self, AstNode, HasLoopBody, HasName}, match_ast, }; use crate::{ InlayHint, InlayHintLabel, InlayHintPosition, InlayHintsConfig, InlayKind, inlay_hints::LazyProperty, }; const ELLIPSIS: &str = "…"; pub(super) fn hints( acc: &mut Vec, sema: &Semantics<'_, RootDatabase>, config: &InlayHintsConfig<'_>, display_target: DisplayTarget, InRealFile { file_id, value: node }: InRealFile, ) -> Option<()> { let min_lines = config.closing_brace_hints_min_lines?; let name = |it: ast::Name| it.syntax().text_range(); let mut node = node.clone(); let mut closing_token; let (label, name_range) = if let Some(item_list) = ast::AssocItemList::cast(node.clone()) { closing_token = item_list.r_curly_token()?; let parent = item_list.syntax().parent()?; match_ast! { match parent { ast::Impl(imp) => { let imp = sema.to_def(&imp)?; let ty = imp.self_ty(sema.db); let trait_ = imp.trait_(sema.db); let hint_text = match trait_ { Some(tr) => format!( "impl {} for {}", tr.name(sema.db).display(sema.db, display_target.edition), ty.display_truncated(sema.db, config.max_length, display_target, )), None => format!("impl {}", ty.display_truncated(sema.db, config.max_length, display_target)), }; (hint_text, None) }, ast::Trait(tr) => { (format!("trait {}", tr.name()?), tr.name().map(name)) }, _ => return None, } } } else if let Some(list) = ast::ItemList::cast(node.clone()) { closing_token = list.r_curly_token()?; let module = ast::Module::cast(list.syntax().parent()?)?; (format!("mod {}", module.name()?), module.name().map(name)) } else if let Some(match_arm_list) = ast::MatchArmList::cast(node.clone()) { closing_token = match_arm_list.r_curly_token()?; let match_expr = ast::MatchExpr::cast(match_arm_list.syntax().parent()?)?; let label = format_match_label(&match_expr, config)?; (label, None) } else if let Some(label) = ast::Label::cast(node.clone()) { // in this case, `ast::Label` could be seen as a part of `ast::BlockExpr` // the actual number of lines in this case should be the line count of the parent BlockExpr, // which the `min_lines` config cares about node = node.parent()?; let parent = label.syntax().parent()?; let block; match_ast! { match parent { ast::BlockExpr(block_expr) => { block = block_expr.stmt_list()?; }, ast::AnyHasLoopBody(loop_expr) => { block = loop_expr.loop_body()?.stmt_list()?; }, _ => return None, } } closing_token = block.r_curly_token()?; let lifetime = label.lifetime()?.to_string(); (lifetime, Some(label.syntax().text_range())) } else if let Some(block) = ast::BlockExpr::cast(node.clone()) { closing_token = block.stmt_list()?.r_curly_token()?; let parent = block.syntax().parent()?; match_ast! { match parent { ast::Fn(it) => { (format!("{}fn {}", fn_qualifiers(&it), it.name()?), it.name().map(name)) }, ast::Static(it) => (format!("static {}", it.name()?), it.name().map(name)), ast::Const(it) => { if it.underscore_token().is_some() { ("const _".into(), None) } else { (format!("const {}", it.name()?), it.name().map(name)) } }, ast::LoopExpr(loop_expr) => { if loop_expr.label().is_some() { return None; } ("loop".into(), None) }, ast::WhileExpr(while_expr) => { if while_expr.label().is_some() { return None; } (keyword_with_condition("while", while_expr.condition(), config), None) }, ast::ForExpr(for_expr) => { if for_expr.label().is_some() { return None; } let label = format_for_label(&for_expr, config)?; (label, None) }, ast::IfExpr(if_expr) => { let label = label_for_if_block(&if_expr, &block, config)?; (label, None) }, ast::LetElse(let_else) => { let label = format_let_else_label(&let_else, config)?; (label, None) }, _ => return None, } } } else if let Some(mac) = ast::MacroCall::cast(node.clone()) { let last_token = mac.syntax().last_token()?; if last_token.kind() != T![;] && last_token.kind() != SyntaxKind::R_CURLY { return None; } closing_token = last_token; ( format!("{}!", mac.path()?), mac.path().and_then(|it| it.segment()).map(|it| it.syntax().text_range()), ) } else { return None; }; if let Some(mut next) = closing_token.next_token() { if next.kind() == T![;] && let Some(tok) = next.next_token() { closing_token = next; next = tok; } if !(next.kind() == SyntaxKind::WHITESPACE && next.text().contains('\n')) { // Only display the hint if the `}` is the last token on the line return None; } } let mut lines = 1; node.text().for_each_chunk(|s| lines += s.matches('\n').count()); if lines < min_lines { return None; } let linked_location = name_range.map(|range| FileRange { file_id: file_id.file_id(sema.db), range }); acc.push(InlayHint { range: closing_token.text_range(), kind: InlayKind::ClosingBrace, label: InlayHintLabel::simple(label, None, linked_location.map(LazyProperty::Computed)), text_edit: None, position: InlayHintPosition::After, pad_left: true, pad_right: false, resolve_parent: Some(node.text_range()), }); None } fn fn_qualifiers(func: &ast::Fn) -> String { let mut qualifiers = String::new(); if func.const_token().is_some() { qualifiers.push_str("const "); } if func.async_token().is_some() { qualifiers.push_str("async "); } if func.unsafe_token().is_some() { qualifiers.push_str("unsafe "); } qualifiers } fn keyword_with_condition( keyword: &str, condition: Option, config: &InlayHintsConfig<'_>, ) -> String { if let Some(expr) = condition { return format!("{keyword} {}", snippet_from_node(expr.syntax(), config)); } keyword.to_owned() } fn format_for_label(for_expr: &ast::ForExpr, config: &InlayHintsConfig<'_>) -> Option { let pat = for_expr.pat()?; let iterable = for_expr.iterable()?; Some(format!( "for {} in {}", snippet_from_node(pat.syntax(), config), snippet_from_node(iterable.syntax(), config) )) } fn format_match_label( match_expr: &ast::MatchExpr, config: &InlayHintsConfig<'_>, ) -> Option { let expr = match_expr.expr()?; Some(format!("match {}", snippet_from_node(expr.syntax(), config))) } fn label_for_if_block( if_expr: &ast::IfExpr, block: &ast::BlockExpr, config: &InlayHintsConfig<'_>, ) -> Option { if if_expr.then_branch().is_some_and(|then_branch| then_branch.syntax() == block.syntax()) { Some(keyword_with_condition("if", if_expr.condition(), config)) } else if matches!( if_expr.else_branch(), Some(ast::ElseBranch::Block(else_block)) if else_block.syntax() == block.syntax() ) { Some("else".into()) } else { None } } fn format_let_else_label(let_else: &ast::LetElse, config: &InlayHintsConfig<'_>) -> Option { let stmt = let_else.syntax().parent().and_then(ast::LetStmt::cast)?; let pat = stmt.pat()?; let initializer = stmt.initializer()?; Some(format!( "let {} = {} else", snippet_from_node(pat.syntax(), config), snippet_from_node(initializer.syntax(), config) )) } fn snippet_from_node(node: &SyntaxNode, config: &InlayHintsConfig<'_>) -> String { let mut text = node.text().to_string(); if text.contains('\n') { return ELLIPSIS.into(); } let Some(limit) = config.max_length else { return text; }; if limit == 0 { return ELLIPSIS.into(); } if text.len() <= limit { return text; } let boundary = text.floor_char_boundary(limit.min(text.len())); if boundary == text.len() { return text; } let cut = text[..boundary] .char_indices() .rev() .find(|&(_, ch)| ch == ' ') .map(|(idx, _)| idx) .unwrap_or(0); text.truncate(cut); text.push_str(ELLIPSIS); text } #[cfg(test)] mod tests { use expect_test::expect; use crate::{ InlayHintsConfig, inlay_hints::tests::{DISABLED_CONFIG, check_expect, check_with_config}, }; #[test] fn hints_closing_brace() { check_with_config( InlayHintsConfig { closing_brace_hints_min_lines: Some(2), ..DISABLED_CONFIG }, r#" fn a() {} fn f() { } // no hint unless `}` is the last token on the line fn g() { } //^ fn g fn h(with: T, arguments: u8, ...) { } //^ fn h async fn async_fn() { } //^ async fn async_fn trait Tr { fn f(); fn g() { } //^ fn g } //^ trait Tr impl Tr for () { } //^ impl Tr for () impl dyn Tr { } //^ impl dyn Tr + 'static static S0: () = 0; static S1: () = {}; static S2: () = { }; //^ static S2 const _: () = { }; //^ const _ mod m { } //^ mod m m! {} m!(); m!( ); //^ m! m! { } //^ m! fn f() { let v = vec![ ]; } //^ fn f "#, ); } #[test] fn hints_closing_brace_for_block_expr() { check_with_config( InlayHintsConfig { closing_brace_hints_min_lines: Some(2), ..DISABLED_CONFIG }, r#" fn test() { 'end: { 'do_a: { 'do_b: { } //^ 'do_b break 'end; } //^ 'do_a } //^ 'end 'a: loop { 'b: for i in 0..5 { 'c: while true { } //^ 'c } //^ 'b } //^ 'a } //^ fn test "#, ); } #[test] fn hints_closing_brace_additional_blocks() { check_expect( InlayHintsConfig { closing_brace_hints_min_lines: Some(2), ..DISABLED_CONFIG }, r#" fn demo() { loop { } while let Some(value) = next() { } for value in iter { } if cond { } if let Some(x) = maybe { } if other { } else { } let Some(v) = maybe else { }; match maybe { Some(v) => { } value if check(value) => { } None => {} } } "#, expect![[r#" [ ( 364..365, [ InlayHintLabelPart { text: "fn demo", linked_location: Some( Computed( FileRangeWrapper { file_id: FileId( 0, ), range: 3..7, }, ), ), tooltip: "", }, ], ), ( 28..29, [ "loop", ], ), ( 73..74, [ "while let Some(value) = next()", ], ), ( 105..106, [ "for value in iter", ], ), ( 127..128, [ "if cond", ], ), ( 164..165, [ "if let Some(x) = maybe", ], ), ( 200..201, [ "else", ], ), ( 240..241, [ "let Some(v) = maybe else", ], ), ( 362..363, [ "match maybe", ], ), ] "#]], ); } }