bracket_highlights.rs

  1use crate::{Editor, RangeToAnchorExt};
  2use gpui::{Context, HighlightStyle, Hsla, Window};
  3use itertools::Itertools;
  4use language::CursorShape;
  5use multi_buffer::ToPoint;
  6use text::{Bias, Point};
  7
  8enum MatchingBracketHighlight {}
  9
 10struct RainbowBracketHighlight;
 11
 12#[derive(Debug, PartialEq, Eq)]
 13pub(crate) enum BracketRefreshReason {
 14    BufferEdited,
 15    ScrollPositionChanged,
 16    SelectionsChanged,
 17}
 18
 19impl Editor {
 20    // todo! run with a debounce
 21    pub(crate) fn refresh_bracket_highlights(
 22        &mut self,
 23        refresh_reason: BracketRefreshReason,
 24        window: &mut Window,
 25        cx: &mut Context<Editor>,
 26    ) {
 27        const COLORS: [Hsla; 4] = [gpui::red(), gpui::yellow(), gpui::green(), gpui::blue()];
 28
 29        let snapshot = self.snapshot(window, cx);
 30        let multi_buffer_snapshot = &snapshot.buffer_snapshot();
 31
 32        let multi_buffer_visible_start = snapshot
 33            .scroll_anchor
 34            .anchor
 35            .to_point(multi_buffer_snapshot);
 36
 37        // todo! deduplicate?
 38        let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
 39            multi_buffer_visible_start
 40                + Point::new(self.visible_line_count().unwrap_or(40.).ceil() as u32, 0),
 41            Bias::Left,
 42        );
 43
 44        let bracket_matches = multi_buffer_snapshot
 45            .range_to_buffer_ranges(multi_buffer_visible_start..multi_buffer_visible_end)
 46            .into_iter()
 47            .filter_map(|(buffer_snapshot, buffer_range, _)| {
 48                let buffer_brackets =
 49                    buffer_snapshot.bracket_ranges(buffer_range.start..buffer_range.end);
 50
 51                // todo! is there a good way to use the excerpt_id instead?
 52                let mut excerpt = multi_buffer_snapshot.excerpt_containing(buffer_range.clone())?;
 53
 54                Some(
 55                    buffer_brackets
 56                        .into_iter()
 57                        .filter_map(|pair| {
 58                            let buffer_range = pair.open_range.start..pair.close_range.end;
 59                            if excerpt.contains_buffer_range(buffer_range) {
 60                                Some((
 61                                    pair.depth,
 62                                    excerpt.map_range_from_buffer(pair.open_range),
 63                                    excerpt.map_range_from_buffer(pair.close_range),
 64                                ))
 65                            } else {
 66                                None
 67                            }
 68                        })
 69                        .collect::<Vec<_>>(),
 70                )
 71            })
 72            .flatten()
 73            .into_group_map_by(|&(depth, ..)| depth);
 74
 75        for (depth, bracket_highlights) in bracket_matches {
 76            let style = HighlightStyle {
 77                color: Some({
 78                    // todo! these colors lack contrast for this/are not actually good for that?
 79                    // cx.theme().accents().color_for_index(depth as u32);
 80                    COLORS[depth as usize % COLORS.len()]
 81                }),
 82                ..HighlightStyle::default()
 83            };
 84
 85            self.highlight_text_key::<RainbowBracketHighlight>(
 86                depth,
 87                bracket_highlights
 88                    .into_iter()
 89                    .flat_map(|(_, open, close)| {
 90                        [
 91                            open.to_anchors(&multi_buffer_snapshot),
 92                            close.to_anchors(&multi_buffer_snapshot),
 93                        ]
 94                    })
 95                    .collect(),
 96                style,
 97                cx,
 98            );
 99        }
100
101        if refresh_reason == BracketRefreshReason::ScrollPositionChanged {
102            return;
103        }
104        self.clear_background_highlights::<MatchingBracketHighlight>(cx);
105
106        let newest_selection = self.selections.newest::<usize>(&snapshot);
107        // Don't highlight brackets if the selection isn't empty
108        if !newest_selection.is_empty() {
109            return;
110        }
111
112        let head = newest_selection.head();
113        if head > snapshot.buffer_snapshot().len() {
114            log::error!("bug: cursor offset is out of range while refreshing bracket highlights");
115            return;
116        }
117
118        let mut tail = head;
119        if (self.cursor_shape == CursorShape::Block || self.cursor_shape == CursorShape::Hollow)
120            && head < snapshot.buffer_snapshot().len()
121        {
122            if let Some(tail_ch) = snapshot.buffer_snapshot().chars_at(tail).next() {
123                tail += tail_ch.len_utf8();
124            }
125        }
126
127        if let Some((opening_range, closing_range)) = snapshot
128            .buffer_snapshot()
129            .innermost_enclosing_bracket_ranges(head..tail, None)
130        {
131            self.highlight_background::<MatchingBracketHighlight>(
132                &[
133                    opening_range.to_anchors(&snapshot.buffer_snapshot()),
134                    closing_range.to_anchors(&snapshot.buffer_snapshot()),
135                ],
136                |theme| theme.colors().editor_document_highlight_bracket_background,
137                cx,
138            )
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
147    use indoc::indoc;
148    use language::{BracketPair, BracketPairConfig, Language, LanguageConfig, LanguageMatcher};
149    use multi_buffer::AnchorRangeExt as _;
150
151    #[gpui::test]
152    async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) {
153        init_test(cx, |_| {});
154
155        let mut cx = EditorLspTestContext::new(
156            Language::new(
157                LanguageConfig {
158                    name: "Rust".into(),
159                    matcher: LanguageMatcher {
160                        path_suffixes: vec!["rs".to_string()],
161                        ..Default::default()
162                    },
163                    brackets: BracketPairConfig {
164                        pairs: vec![
165                            BracketPair {
166                                start: "{".to_string(),
167                                end: "}".to_string(),
168                                close: false,
169                                surround: false,
170                                newline: true,
171                            },
172                            BracketPair {
173                                start: "(".to_string(),
174                                end: ")".to_string(),
175                                close: false,
176                                surround: false,
177                                newline: true,
178                            },
179                        ],
180                        ..Default::default()
181                    },
182                    ..Default::default()
183                },
184                Some(tree_sitter_rust::LANGUAGE.into()),
185            )
186            .with_brackets_query(indoc! {r#"
187                ("{" @open "}" @close)
188                ("(" @open ")" @close)
189                "#})
190            .unwrap(),
191            Default::default(),
192            cx,
193        )
194        .await;
195
196        // positioning cursor inside bracket highlights both
197        cx.set_state(indoc! {r#"
198            pub fn test("Test ˇargument") {
199                another_test(1, 2, 3);
200            }
201        "#});
202        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
203            pub fn test«(»"Test argument"«)» {
204                another_test(1, 2, 3);
205            }
206        "#});
207
208        cx.set_state(indoc! {r#"
209            pub fn test("Test argument") {
210                another_test(1, ˇ2, 3);
211            }
212        "#});
213        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
214            pub fn test("Test argument") {
215                another_test«(»1, 2, 3«)»;
216            }
217        "#});
218
219        cx.set_state(indoc! {r#"
220            pub fn test("Test argument") {
221                anotherˇ_test(1, 2, 3);
222            }
223        "#});
224        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
225            pub fn test("Test argument") «{»
226                another_test(1, 2, 3);
227            «}»
228        "#});
229
230        // positioning outside of brackets removes highlight
231        cx.set_state(indoc! {r#"
232            pub fˇn test("Test argument") {
233                another_test(1, 2, 3);
234            }
235        "#});
236        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
237            pub fn test("Test argument") {
238                another_test(1, 2, 3);
239            }
240        "#});
241
242        // non empty selection dismisses highlight
243        cx.set_state(indoc! {r#"
244            pub fn test("Te«st argˇ»ument") {
245                another_test(1, 2, 3);
246            }
247        "#});
248        cx.assert_editor_background_highlights::<MatchingBracketHighlight>(indoc! {r#"
249            pub fn test«("Test argument") {
250                another_test(1, 2, 3);
251            }
252        "#});
253    }
254
255    #[gpui::test]
256    async fn test_rainbow_bracket_highlights(cx: &mut gpui::TestAppContext) {
257        init_test(cx, |_| {});
258
259        let mut cx = EditorLspTestContext::new(
260            Language::new(
261                LanguageConfig {
262                    name: "Rust".into(),
263                    matcher: LanguageMatcher {
264                        path_suffixes: vec!["rs".to_string()],
265                        ..Default::default()
266                    },
267                    brackets: BracketPairConfig {
268                        pairs: vec![
269                            BracketPair {
270                                start: "{".to_string(),
271                                end: "}".to_string(),
272                                close: false,
273                                surround: false,
274                                newline: true,
275                            },
276                            BracketPair {
277                                start: "(".to_string(),
278                                end: ")".to_string(),
279                                close: false,
280                                surround: false,
281                                newline: true,
282                            },
283                        ],
284                        ..Default::default()
285                    },
286                    ..Default::default()
287                },
288                Some(tree_sitter_rust::LANGUAGE.into()),
289            )
290            .with_brackets_query(indoc! {r#"
291                ("{" @open "}" @close)
292                ("(" @open ")" @close)
293                "#})
294            .unwrap(),
295            Default::default(),
296            cx,
297        )
298        .await;
299
300        // taken from r-a https://github.com/rust-lang/rust-analyzer/blob/d733c07552a2dc0ec0cc8f4df3f0ca969a93fd90/crates/ide/src/inlay_hints.rs#L81-L297
301        cx.set_state(indoc! {r#302            pub(crate) fn inlay_hints(
303                db: &RootDatabase,
304                file_id: FileId,
305                range_limit: Option<TextRange>,
306                config: &InlayHintsConfig,
307            ) -> Vec<InlayHint> {
308                let _p = tracing::info_span!("inlay_hints").entered();
309                let sema = Semantics::new(db);
310                let file_id = sema
311                    .attach_first_edition(file_id)
312                    .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id));
313                let file = sema.parse(file_id);
314                let file = file.syntax();
315
316                let mut acc = Vec::new();
317
318                let Some(scope) = sema.scope(file) else {
319                    return acc;
320                };
321                let famous_defs = FamousDefs(&sema, scope.krate());
322                let display_target = famous_defs.1.to_display_target(sema.db);
323
324                let ctx = &mut InlayHintCtx::default();
325                let mut hints = |event| {
326                    if let Some(node) = handle_event(ctx, event) {
327                        hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node);
328                    }
329                };
330                let mut preorder = file.preorder();
331                salsa::attach(sema.db, || {
332                    while let Some(event) = preorder.next() {
333                        if matches!((&event, range_limit), (WalkEvent::Enter(node), Some(range)) if range.intersect(node.text_range()).is_none())
334                        {
335                            preorder.skip_subtree();
336                            continue;
337                        }
338                        hints(event);
339                    }
340                });
341                if let Some(range_limit) = range_limit {
342                    acc.retain(|hint| range_limit.contains_range(hint.range));
343                }
344                acc
345            }
346
347            #[derive(Default)]
348            struct InlayHintCtx {
349                lifetime_stacks: Vec<Vec<SmolStr>>,
350                extern_block_parent: Option<ast::ExternBlock>,
351            }
352
353            pub(crate) fn inlay_hints_resolve(
354                db: &RootDatabase,
355                file_id: FileId,
356                resolve_range: TextRange,
357                hash: u64,
358                config: &InlayHintsConfig,
359                hasher: impl Fn(&InlayHint) -> u64,
360            ) -> Option<InlayHint> {
361                let _p = tracing::info_span!("inlay_hints_resolve").entered();
362                let sema = Semantics::new(db);
363                let file_id = sema
364                    .attach_first_edition(file_id)
365                    .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id));
366                let file = sema.parse(file_id);
367                let file = file.syntax();
368
369                let scope = sema.scope(file)?;
370                let famous_defs = FamousDefs(&sema, scope.krate());
371                let mut acc = Vec::new();
372
373                let display_target = famous_defs.1.to_display_target(sema.db);
374
375                let ctx = &mut InlayHintCtx::default();
376                let mut hints = |event| {
377                    if let Some(node) = handle_event(ctx, event) {
378                        hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node);
379                    }
380                };
381
382                let mut preorder = file.preorder();
383                while let Some(event) = preorder.next() {
384                    // FIXME: This can miss some hints that require the parent of the range to calculate
385                    if matches!(&event, WalkEvent::Enter(node) if resolve_range.intersect(node.text_range()).is_none())
386                    {
387                        preorder.skip_subtree();
388                        continue;
389                    }
390                    hints(event);
391                }
392                acc.into_iter().find(|hint| hasher(hint) == hash)
393            }
394
395            fn handle_event(ctx: &mut InlayHintCtx, node: WalkEvent<SyntaxNode>) -> Option<SyntaxNode> {
396                match node {
397                    WalkEvent::Enter(node) => {
398                        if let Some(node) = ast::AnyHasGenericParams::cast(node.clone()) {
399                            let params = node
400                                .generic_param_list()
401                                .map(|it| {
402                                    it.lifetime_params()
403                                        .filter_map(|it| {
404                                            it.lifetime().map(|it| format_smolstr!("{}", &it.text()[1..]))
405                                        })
406                                        .collect()
407                                })
408                                .unwrap_or_default();
409                            ctx.lifetime_stacks.push(params);
410                        }
411                        if let Some(node) = ast::ExternBlock::cast(node.clone()) {
412                            ctx.extern_block_parent = Some(node);
413                        }
414                        Some(node)
415                    }
416                    WalkEvent::Leave(n) => {
417                        if ast::AnyHasGenericParams::can_cast(n.kind()) {
418                            ctx.lifetime_stacks.pop();
419                        }
420                        if ast::ExternBlock::can_cast(n.kind()) {
421                            ctx.extern_block_parent = None;
422                        }
423                        None
424                    }
425                }
426            }
427
428            // FIXME: At some point when our hir infra is fleshed out enough we should flip this and traverse the
429            // HIR instead of the syntax tree.
430            fn hints(
431                hints: &mut Vec<InlayHint>,
432                ctx: &mut InlayHintCtx,
433                famous_defs @ FamousDefs(sema, _krate): &FamousDefs<'_, '_>,
434                config: &InlayHintsConfig,
435                file_id: EditionedFileId,
436                display_target: DisplayTarget,
437                node: SyntaxNode,
438            ) {
439                closing_brace::hints(
440                    hints,
441                    sema,
442                    config,
443                    display_target,
444                    InRealFile { file_id, value: node.clone() },
445                );
446                if let Some(any_has_generic_args) = ast::AnyHasGenericArgs::cast(node.clone()) {
447                    generic_param::hints(hints, famous_defs, config, any_has_generic_args);
448                }
449
450                match_ast! {
451                    match node {
452                        ast::Expr(expr) => {
453                            chaining::hints(hints, famous_defs, config, display_target, &expr);
454                            adjustment::hints(hints, famous_defs, config, display_target, &expr);
455                            match expr {
456                                ast::Expr::CallExpr(it) => param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it)),
457                                ast::Expr::MethodCallExpr(it) => {
458                                    param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it))
459                                }
460                                ast::Expr::ClosureExpr(it) => {
461                                    closure_captures::hints(hints, famous_defs, config, it.clone());
462                                    closure_ret::hints(hints, famous_defs, config, display_target, it)
463                                },
464                                ast::Expr::RangeExpr(it) => range_exclusive::hints(hints, famous_defs, config, it),
465                                _ => Some(()),
466                            }
467                        },
468                        ast::Pat(it) => {
469                            binding_mode::hints(hints, famous_defs, config, &it);
470                            match it {
471                                ast::Pat::IdentPat(it) => {
472                                    bind_pat::hints(hints, famous_defs, config, display_target, &it);
473                                }
474                                ast::Pat::RangePat(it) => {
475                                    range_exclusive::hints(hints, famous_defs, config, it);
476                                }
477                                _ => {}
478                            }
479                            Some(())
480                        },
481                        ast::Item(it) => match it {
482                            ast::Item::Fn(it) => {
483                                implicit_drop::hints(hints, famous_defs, config, display_target, &it);
484                                if let Some(extern_block) = &ctx.extern_block_parent {
485                                    extern_block::fn_hints(hints, famous_defs, config, &it, extern_block);
486                                }
487                                lifetime::fn_hints(hints, ctx, famous_defs, config,  it)
488                            },
489                            ast::Item::Static(it) => {
490                                if let Some(extern_block) = &ctx.extern_block_parent {
491                                    extern_block::static_hints(hints, famous_defs, config, &it, extern_block);
492                                }
493                                implicit_static::hints(hints, famous_defs, config,  Either::Left(it))
494                            },
495                            ast::Item::Const(it) => implicit_static::hints(hints, famous_defs, config, Either::Right(it)),
496                            ast::Item::Enum(it) => discriminant::enum_hints(hints, famous_defs, config, it),
497                            ast::Item::ExternBlock(it) => extern_block::extern_block_hints(hints, famous_defs, config, it),
498                            _ => None,
499                        },
500                        // FIXME: trait object type elisions
501                        ast::Type(ty) => match ty {
502                            ast::Type::FnPtrType(ptr) => lifetime::fn_ptr_hints(hints, ctx, famous_defs, config,  ptr),
503                            ast::Type::PathType(path) => {
504                                lifetime::fn_path_hints(hints, ctx, famous_defs, config, &path);
505                                implied_dyn_trait::hints(hints, famous_defs, config, Either::Left(path));
506                                Some(())
507                            },
508                            ast::Type::DynTraitType(dyn_) => {
509                                implied_dyn_trait::hints(hints, famous_defs, config, Either::Right(dyn_));
510                                Some(())
511                            },
512                            _ => Some(()),
513                        },
514                        ast::GenericParamList(it) => bounds::hints(hints, famous_defs, config,  it),
515                        _ => Some(()),
516                    }
517                };
518            }
519        "#});
520
521        let snapshot = cx.update_editor(|editor, window, cx| editor.snapshot(window, cx));
522        let actual_ranges = snapshot
523            .all_text_highlight_ranges::<RainbowBracketHighlight>()
524            .iter()
525            .flat_map(|ranges| {
526                ranges
527                    .1
528                    .iter()
529                    .map(|range| (ranges.0.color, range.to_point(&snapshot.buffer_snapshot())))
530            })
531            .collect::<Vec<_>>();
532        let last_bracket = actual_ranges
533            .iter()
534            .max_by_key(|(_, p)| p.end.row)
535            .unwrap()
536            .clone();
537
538        cx.update_editor(|editor, window, cx| {
539            let was_scrolled = editor.set_scroll_position(
540                gpui::Point::new(0.0, last_bracket.1.end.row as f64 * 2.0),
541                window,
542                cx,
543            );
544            assert!(was_scrolled.0);
545        });
546        cx.executor().run_until_parked();
547        let snapshot = cx.update_editor(|editor, window, cx| editor.snapshot(window, cx));
548
549        let actual_ranges = snapshot
550            .all_text_highlight_ranges::<RainbowBracketHighlight>()
551            .iter()
552            .flat_map(|ranges| {
553                ranges
554                    .1
555                    .iter()
556                    .map(|range| (ranges.0.color, range.to_point(&snapshot.buffer_snapshot())))
557            })
558            .collect::<Vec<_>>();
559        let new_last_bracket = actual_ranges
560            .iter()
561            .max_by_key(|(_, p)| p.end.row)
562            .unwrap()
563            .clone();
564        // todo! we do scroll down and get new brackets matched by tree-sitter,
565        // but something still prevents the `text_key_highlight_ranges` to return the right, newest visible bracket on the lower row we scrolled to
566        assert_ne!(
567            last_bracket, new_last_bracket,
568            "After scrolling down, we should have highlighted more brackets"
569        );
570    }
571}