bracket_colorization.rs

  1use crate::Editor;
  2use collections::HashMap;
  3use gpui::{Context, HighlightStyle};
  4use language::language_settings;
  5use ui::{ActiveTheme, utils::ensure_minimum_contrast};
  6
  7struct RainbowBracketHighlight;
  8
  9impl Editor {
 10    pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context<Editor>) {
 11        if !self.mode.is_full() {
 12            return;
 13        }
 14
 15        let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
 16        let bracket_matches = self.visible_excerpts(cx).into_iter().fold(
 17            HashMap::default(),
 18            |mut acc, (excerpt_id, (buffer, _, buffer_range))| {
 19                let buffer_snapshot = buffer.read(cx).snapshot();
 20                if language_settings::language_settings(
 21                    buffer_snapshot.language().map(|language| language.name()),
 22                    buffer_snapshot.file(),
 23                    cx,
 24                )
 25                .colorize_brackets
 26                {
 27                    for (depth, open_range, close_range) in buffer_snapshot
 28                        .bracket_ranges(buffer_range.start..buffer_range.end)
 29                        .into_iter()
 30                        .filter_map(|pair| {
 31                            let buffer_open_range = buffer_snapshot
 32                                .anchor_before(pair.open_range.start)
 33                                ..buffer_snapshot.anchor_after(pair.open_range.end);
 34                            let multi_buffer_open_range = multi_buffer_snapshot
 35                                .anchor_in_excerpt(excerpt_id, buffer_open_range.start)?
 36                                ..multi_buffer_snapshot
 37                                    .anchor_in_excerpt(excerpt_id, buffer_open_range.end)?;
 38                            let buffer_close_range = buffer_snapshot
 39                                .anchor_before(pair.close_range.start)
 40                                ..buffer_snapshot.anchor_after(pair.close_range.end);
 41                            let multi_buffer_close_range = multi_buffer_snapshot
 42                                .anchor_in_excerpt(excerpt_id, buffer_close_range.start)?
 43                                ..multi_buffer_snapshot
 44                                    .anchor_in_excerpt(excerpt_id, buffer_close_range.end)?;
 45                            Some((
 46                                pair.depth,
 47                                multi_buffer_open_range,
 48                                multi_buffer_close_range,
 49                            ))
 50                        })
 51                    {
 52                        let ranges = acc.entry(depth).or_insert_with(Vec::new);
 53                        ranges.push(open_range);
 54                        ranges.push(close_range);
 55                    }
 56                }
 57
 58                acc
 59            },
 60        );
 61
 62        if invalidate {
 63            self.clear_highlights::<RainbowBracketHighlight>(cx);
 64        }
 65
 66        // todo! can we skip the re-highlighting entirely, if it's not adding anything on top?
 67        let editor_background = cx.theme().colors().editor_background;
 68        for (depth, bracket_highlights) in bracket_matches {
 69            let bracket_color = cx.theme().accents().color_for_index(depth as u32);
 70            let adjusted_color = ensure_minimum_contrast(bracket_color, editor_background, 55.0);
 71            let style = HighlightStyle {
 72                color: Some(adjusted_color),
 73                ..HighlightStyle::default()
 74            };
 75
 76            self.highlight_text_key::<RainbowBracketHighlight>(
 77                depth,
 78                bracket_highlights,
 79                style,
 80                cx,
 81            );
 82        }
 83    }
 84}
 85
 86#[cfg(test)]
 87mod tests {
 88    use std::time::Duration;
 89
 90    use super::*;
 91    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
 92    use indoc::indoc;
 93    use language::{BracketPair, BracketPairConfig, Language, LanguageConfig, LanguageMatcher};
 94    use multi_buffer::AnchorRangeExt as _;
 95
 96    #[gpui::test]
 97    async fn test_rainbow_bracket_highlights(cx: &mut gpui::TestAppContext) {
 98        init_test(cx, |language_settings| {
 99            language_settings.defaults.colorize_brackets = Some(true);
100        });
101
102        let mut cx = EditorLspTestContext::new(
103            Language::new(
104                LanguageConfig {
105                    name: "Rust".into(),
106                    matcher: LanguageMatcher {
107                        path_suffixes: vec!["rs".to_string()],
108                        ..LanguageMatcher::default()
109                    },
110                    brackets: BracketPairConfig {
111                        pairs: vec![
112                            BracketPair {
113                                start: "{".to_string(),
114                                end: "}".to_string(),
115                                close: false,
116                                surround: false,
117                                newline: true,
118                            },
119                            BracketPair {
120                                start: "(".to_string(),
121                                end: ")".to_string(),
122                                close: false,
123                                surround: false,
124                                newline: true,
125                            },
126                        ],
127                        ..BracketPairConfig::default()
128                    },
129                    ..LanguageConfig::default()
130                },
131                Some(tree_sitter_rust::LANGUAGE.into()),
132            )
133            .with_brackets_query(indoc! {r#"
134                ("{" @open "}" @close)
135                ("(" @open ")" @close)
136                "#})
137            .unwrap(),
138            lsp::ServerCapabilities::default(),
139            cx,
140        )
141        .await;
142
143        // taken from r-a https://github.com/rust-lang/rust-analyzer/blob/d733c07552a2dc0ec0cc8f4df3f0ca969a93fd90/crates/ide/src/inlay_hints.rs#L81-L297
144        cx.set_state(indoc! {r#145            pub(crate) fn inlay_hints(
146                db: &RootDatabase,
147                file_id: FileId,
148                range_limit: Option<TextRange>,
149                config: &InlayHintsConfig,
150            ) -> Vec<InlayHint> {
151                let _p = tracing::info_span!("inlay_hints").entered();
152                let sema = Semantics::new(db);
153                let file_id = sema
154                    .attach_first_edition(file_id)
155                    .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id));
156                let file = sema.parse(file_id);
157                let file = file.syntax();
158
159                let mut acc = Vec::new();
160
161                let Some(scope) = sema.scope(file) else {
162                    return acc;
163                };
164                let famous_defs = FamousDefs(&sema, scope.krate());
165                let display_target = famous_defs.1.to_display_target(sema.db);
166
167                let ctx = &mut InlayHintCtx::default();
168                let mut hints = |event| {
169                    if let Some(node) = handle_event(ctx, event) {
170                        hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node);
171                    }
172                };
173                let mut preorder = file.preorder();
174                salsa::attach(sema.db, || {
175                    while let Some(event) = preorder.next() {
176                        if matches!((&event, range_limit), (WalkEvent::Enter(node), Some(range)) if range.intersect(node.text_range()).is_none())
177                        {
178                            preorder.skip_subtree();
179                            continue;
180                        }
181                        hints(event);
182                    }
183                });
184                if let Some(range_limit) = range_limit {
185                    acc.retain(|hint| range_limit.contains_range(hint.range));
186                }
187                acc
188            }
189
190            #[derive(Default)]
191            struct InlayHintCtx {
192                lifetime_stacks: Vec<Vec<SmolStr>>,
193                extern_block_parent: Option<ast::ExternBlock>,
194            }
195
196            pub(crate) fn inlay_hints_resolve(
197                db: &RootDatabase,
198                file_id: FileId,
199                resolve_range: TextRange,
200                hash: u64,
201                config: &InlayHintsConfig,
202                hasher: impl Fn(&InlayHint) -> u64,
203            ) -> Option<InlayHint> {
204                let _p = tracing::info_span!("inlay_hints_resolve").entered();
205                let sema = Semantics::new(db);
206                let file_id = sema
207                    .attach_first_edition(file_id)
208                    .unwrap_or_else(|| EditionedFileId::current_edition(db, file_id));
209                let file = sema.parse(file_id);
210                let file = file.syntax();
211
212                let scope = sema.scope(file)?;
213                let famous_defs = FamousDefs(&sema, scope.krate());
214                let mut acc = Vec::new();
215
216                let display_target = famous_defs.1.to_display_target(sema.db);
217
218                let ctx = &mut InlayHintCtx::default();
219                let mut hints = |event| {
220                    if let Some(node) = handle_event(ctx, event) {
221                        hints(&mut acc, ctx, &famous_defs, config, file_id, display_target, node);
222                    }
223                };
224
225                let mut preorder = file.preorder();
226                while let Some(event) = preorder.next() {
227                    // FIXME: This can miss some hints that require the parent of the range to calculate
228                    if matches!(&event, WalkEvent::Enter(node) if resolve_range.intersect(node.text_range()).is_none())
229                    {
230                        preorder.skip_subtree();
231                        continue;
232                    }
233                    hints(event);
234                }
235                acc.into_iter().find(|hint| hasher(hint) == hash)
236            }
237
238            fn handle_event(ctx: &mut InlayHintCtx, node: WalkEvent<SyntaxNode>) -> Option<SyntaxNode> {
239                match node {
240                    WalkEvent::Enter(node) => {
241                        if let Some(node) = ast::AnyHasGenericParams::cast(node.clone()) {
242                            let params = node
243                                .generic_param_list()
244                                .map(|it| {
245                                    it.lifetime_params()
246                                        .filter_map(|it| {
247                                            it.lifetime().map(|it| format_smolstr!("{}", &it.text()[1..]))
248                                        })
249                                        .collect()
250                                })
251                                .unwrap_or_default();
252                            ctx.lifetime_stacks.push(params);
253                        }
254                        if let Some(node) = ast::ExternBlock::cast(node.clone()) {
255                            ctx.extern_block_parent = Some(node);
256                        }
257                        Some(node)
258                    }
259                    WalkEvent::Leave(n) => {
260                        if ast::AnyHasGenericParams::can_cast(n.kind()) {
261                            ctx.lifetime_stacks.pop();
262                        }
263                        if ast::ExternBlock::can_cast(n.kind()) {
264                            ctx.extern_block_parent = None;
265                        }
266                        None
267                    }
268                }
269            }
270
271            // FIXME: At some point when our hir infra is fleshed out enough we should flip this and traverse the
272            // HIR instead of the syntax tree.
273            fn hints(
274                hints: &mut Vec<InlayHint>,
275                ctx: &mut InlayHintCtx,
276                famous_defs @ FamousDefs(sema, _krate): &FamousDefs<'_, '_>,
277                config: &InlayHintsConfig,
278                file_id: EditionedFileId,
279                display_target: DisplayTarget,
280                node: SyntaxNode,
281            ) {
282                closing_brace::hints(
283                    hints,
284                    sema,
285                    config,
286                    display_target,
287                    InRealFile { file_id, value: node.clone() },
288                );
289                if let Some(any_has_generic_args) = ast::AnyHasGenericArgs::cast(node.clone()) {
290                    generic_param::hints(hints, famous_defs, config, any_has_generic_args);
291                }
292
293                match_ast! {
294                    match node {
295                        ast::Expr(expr) => {
296                            chaining::hints(hints, famous_defs, config, display_target, &expr);
297                            adjustment::hints(hints, famous_defs, config, display_target, &expr);
298                            match expr {
299                                ast::Expr::CallExpr(it) => param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it)),
300                                ast::Expr::MethodCallExpr(it) => {
301                                    param_name::hints(hints, famous_defs, config, file_id, ast::Expr::from(it))
302                                }
303                                ast::Expr::ClosureExpr(it) => {
304                                    closure_captures::hints(hints, famous_defs, config, it.clone());
305                                    closure_ret::hints(hints, famous_defs, config, display_target, it)
306                                },
307                                ast::Expr::RangeExpr(it) => range_exclusive::hints(hints, famous_defs, config, it),
308                                _ => Some(()),
309                            }
310                        },
311                        ast::Pat(it) => {
312                            binding_mode::hints(hints, famous_defs, config, &it);
313                            match it {
314                                ast::Pat::IdentPat(it) => {
315                                    bind_pat::hints(hints, famous_defs, config, display_target, &it);
316                                }
317                                ast::Pat::RangePat(it) => {
318                                    range_exclusive::hints(hints, famous_defs, config, it);
319                                }
320                                _ => {}
321                            }
322                            Some(())
323                        },
324                        ast::Item(it) => match it {
325                            ast::Item::Fn(it) => {
326                                implicit_drop::hints(hints, famous_defs, config, display_target, &it);
327                                if let Some(extern_block) = &ctx.extern_block_parent {
328                                    extern_block::fn_hints(hints, famous_defs, config, &it, extern_block);
329                                }
330                                lifetime::fn_hints(hints, ctx, famous_defs, config,  it)
331                            },
332                            ast::Item::Static(it) => {
333                                if let Some(extern_block) = &ctx.extern_block_parent {
334                                    extern_block::static_hints(hints, famous_defs, config, &it, extern_block);
335                                }
336                                implicit_static::hints(hints, famous_defs, config,  Either::Left(it))
337                            },
338                            ast::Item::Const(it) => implicit_static::hints(hints, famous_defs, config, Either::Right(it)),
339                            ast::Item::Enum(it) => discriminant::enum_hints(hints, famous_defs, config, it),
340                            ast::Item::ExternBlock(it) => extern_block::extern_block_hints(hints, famous_defs, config, it),
341                            _ => None,
342                        },
343                        // FIXME: trait object type elisions
344                        ast::Type(ty) => match ty {
345                            ast::Type::FnPtrType(ptr) => lifetime::fn_ptr_hints(hints, ctx, famous_defs, config,  ptr),
346                            ast::Type::PathType(path) => {
347                                lifetime::fn_path_hints(hints, ctx, famous_defs, config, &path);
348                                implied_dyn_trait::hints(hints, famous_defs, config, Either::Left(path));
349                                Some(())
350                            },
351                            ast::Type::DynTraitType(dyn_) => {
352                                implied_dyn_trait::hints(hints, famous_defs, config, Either::Right(dyn_));
353                                Some(())
354                            },
355                            _ => Some(()),
356                        },
357                        ast::GenericParamList(it) => bounds::hints(hints, famous_defs, config,  it),
358                        _ => Some(()),
359                    }
360                };
361            }
362        "#});
363        cx.executor().advance_clock(Duration::from_millis(100));
364        cx.executor().run_until_parked();
365
366        let actual_ranges = cx.update_editor(|editor, window, cx| {
367            let snapshot = editor.snapshot(window, cx);
368            snapshot
369                .all_text_highlight_ranges::<RainbowBracketHighlight>()
370                .iter()
371                .flat_map(|ranges| {
372                    ranges
373                        .1
374                        .iter()
375                        .map(|range| (ranges.0.color, range.to_point(&snapshot.buffer_snapshot())))
376                })
377                .collect::<Vec<_>>()
378        });
379        let last_bracket = actual_ranges
380            .iter()
381            .max_by_key(|(_, p)| p.end.row)
382            .unwrap()
383            .clone();
384
385        cx.update_editor(|editor, window, cx| {
386            let was_scrolled = editor.set_scroll_position(
387                gpui::Point::new(0.0, last_bracket.1.end.row as f64 * 2.0),
388                window,
389                cx,
390            );
391            assert!(was_scrolled.0);
392        });
393        cx.executor().advance_clock(Duration::from_millis(100));
394        cx.executor().run_until_parked();
395
396        let ranges_after_scrolling = cx.update_editor(|editor, window, cx| {
397            let snapshot = editor.snapshot(window, cx);
398            snapshot
399                .all_text_highlight_ranges::<RainbowBracketHighlight>()
400                .iter()
401                .flat_map(|ranges| {
402                    ranges
403                        .1
404                        .iter()
405                        .map(|range| (ranges.0.color, range.to_point(&snapshot.buffer_snapshot())))
406                })
407                .collect::<Vec<_>>()
408        });
409        let new_last_bracket = ranges_after_scrolling
410            .iter()
411            .max_by_key(|(_, p)| p.end.row)
412            .unwrap()
413            .clone();
414        // todo! more tests, check consistency of the colors picked also, settings toggle
415        assert_ne!(
416            last_bracket, new_last_bracket,
417            "After scrolling down, we should have highlighted more brackets"
418        );
419    }
420}