From 3389d84e0339bc7ae618df82fcd6993b1d7e5b47 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 17 Jan 2026 00:04:51 +0200 Subject: [PATCH] Improve bracket colorization in Markdown files (#47026) Closes https://github.com/zed-industries/zed/issues/46420 Release Notes: - Improved bracket colorization in Markdown files --- crates/editor/src/bracket_colorization.rs | 58 +++++++++ crates/language/src/buffer.rs | 143 +++++++++++++++++++--- 2 files changed, 183 insertions(+), 18 deletions(-) diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 0e5e3ced08480292a36b5036cc2249b1f46c16be..81d73c6725c4f543946e8394f783c9e7a30cf596 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -348,6 +348,64 @@ where ); } + #[gpui::test] + async fn test_markdown_brackets_in_multiple_hunks(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(markdown_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + let rows = 100; + let footer = "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n"; + + let simple_brackets = (0..rows).map(|_| "ˇ[]\n").collect::(); + let simple_brackets_highlights = (0..rows).map(|_| "«1[]1»\n").collect::(); + cx.set_state(&simple_brackets); + cx.update_editor(|editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + format!("{simple_brackets_highlights}\n{footer}"), + bracket_colors_markup(&mut cx), + "Simple bracket pairs should be colored" + ); + + let paired_brackets = (0..rows).map(|_| "ˇ[]()\n").collect::(); + let paired_brackets_highlights = (0..rows).map(|_| "«1[]1»«1()1»\n").collect::(); + cx.set_state(&paired_brackets); + // Wait for reparse to complete after content change + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, cx| { + // Force invalidation of bracket cache after reparse + editor.colorize_brackets(true, cx); + }); + // Scroll to beginning to fetch first chunks + cx.update_editor(|editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + // Scroll to end to fetch remaining chunks + cx.update_editor(|editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!( + format!("{paired_brackets_highlights}\n{footer}"), + bracket_colors_markup(&mut cx), + "Paired bracket pairs should be colored" + ); + } + #[gpui::test] async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) { init_test(cx, |language_settings| { diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 7aa4066ed74b9508e20ab6cbeebb872459621199..8640288d563ef9e0d577896a57a2e3c5ab2e30f3 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4444,7 +4444,7 @@ impl BufferSnapshot { continue; } - let mut all_brackets = Vec::new(); + let mut all_brackets: Vec<(BracketMatch, bool)> = Vec::new(); let mut opens = Vec::new(); let mut color_pairs = Vec::new(); @@ -4463,6 +4463,9 @@ impl BufferSnapshot { .map(|grammar| grammar.brackets_config.as_ref().unwrap()) .collect::>(); + // Group matches by open range so we can either trust grammar output + // or repair it by picking a single closest close per open. + let mut open_to_close_ranges = BTreeMap::new(); while let Some(mat) = matches.peek() { let mut open = None; let mut close = None; @@ -4488,27 +4491,131 @@ impl BufferSnapshot { continue; } - let index = all_brackets.len(); - all_brackets.push(BracketMatch { - open_range: open_range.clone(), - close_range: close_range.clone(), - newline_only: pattern.newline_only, - syntax_layer_depth, - color_index: None, - }); + open_to_close_ranges + .entry((open_range.start, open_range.end)) + .or_insert_with(BTreeMap::new) + .insert( + (close_range.start, close_range.end), + BracketMatch { + open_range: open_range.clone(), + close_range: close_range.clone(), + syntax_layer_depth, + newline_only: pattern.newline_only, + color_index: None, + }, + ); + + all_brackets.push(( + BracketMatch { + open_range, + close_range, + syntax_layer_depth, + newline_only: pattern.newline_only, + color_index: None, + }, + pattern.rainbow_exclude, + )); + } + + let has_bogus_matches = open_to_close_ranges + .iter() + .any(|(_, end_ranges)| end_ranges.len() > 1); + if has_bogus_matches { + // Grammar is producing bogus matches where one open is paired with multiple + // closes. Build a valid stack by walking through positions in order. + // For each close, we know the expected open_len from tree-sitter matches. + + // Map each close to its expected open length (for inferring opens) + let close_to_open_len: HashMap<(usize, usize), usize> = all_brackets + .iter() + .map(|(m, _)| ((m.close_range.start, m.close_range.end), m.open_range.len())) + .collect(); + + // Collect unique opens and closes within this chunk + let mut unique_opens: HashSet<(usize, usize)> = all_brackets + .iter() + .map(|(m, _)| (m.open_range.start, m.open_range.end)) + .filter(|(start, _)| chunk_range.contains(start)) + .collect(); + + let mut unique_closes: Vec<(usize, usize)> = all_brackets + .iter() + .map(|(m, _)| (m.close_range.start, m.close_range.end)) + .filter(|(start, _)| chunk_range.contains(start)) + .collect(); + unique_closes.sort(); + unique_closes.dedup(); + + // Build valid pairs by walking through closes in order + let mut unique_opens_vec: Vec<_> = unique_opens.iter().copied().collect(); + unique_opens_vec.sort(); + + let mut valid_pairs: HashSet<((usize, usize), (usize, usize))> = HashSet::default(); + let mut open_stack: Vec<(usize, usize)> = Vec::new(); + let mut open_idx = 0; + + for close in &unique_closes { + // Push all opens before this close onto stack + while open_idx < unique_opens_vec.len() + && unique_opens_vec[open_idx].0 < close.0 + { + open_stack.push(unique_opens_vec[open_idx]); + open_idx += 1; + } - // Certain languages have "brackets" that are not brackets, e.g. tags. and such - // bracket will match the entire tag with all text inside. - // For now, avoid highlighting any pair that has more than single char in each bracket. - // We need to colorize `` bracket pairs, so cannot make this check stricter. - let should_color = - !pattern.rainbow_exclude && (open_range.len() == 1 || close_range.len() == 1); - if should_color { - opens.push(open_range.clone()); - color_pairs.push((open_range, close_range, index)); + // Try to match with most recent open + if let Some(open) = open_stack.pop() { + valid_pairs.insert((open, *close)); + } else if let Some(&open_len) = close_to_open_len.get(close) { + // No open on stack - infer one based on expected open_len + if close.0 >= open_len { + let inferred = (close.0 - open_len, close.0); + unique_opens.insert(inferred); + valid_pairs.insert((inferred, *close)); + all_brackets.push(( + BracketMatch { + open_range: inferred.0..inferred.1, + close_range: close.0..close.1, + newline_only: false, + syntax_layer_depth: 0, + color_index: None, + }, + false, + )); + } + } } + + all_brackets.retain(|(m, _)| { + let open = (m.open_range.start, m.open_range.end); + let close = (m.close_range.start, m.close_range.end); + valid_pairs.contains(&(open, close)) + }); } + let mut all_brackets = all_brackets + .into_iter() + .enumerate() + .map(|(index, (bracket_match, rainbow_exclude))| { + // Certain languages have "brackets" that are not brackets, e.g. tags. and such + // bracket will match the entire tag with all text inside. + // For now, avoid highlighting any pair that has more than single char in each bracket. + // We need to colorize `` bracket pairs, so cannot make this check stricter. + let should_color = !rainbow_exclude + && (bracket_match.open_range.len() == 1 + || bracket_match.close_range.len() == 1); + if should_color { + opens.push(bracket_match.open_range.clone()); + color_pairs.push(( + bracket_match.open_range.clone(), + bracket_match.close_range.clone(), + index, + )); + } + bracket_match + }) + .collect::>(); + opens.sort_by_key(|r| (r.start, r.end)); opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); color_pairs.sort_by_key(|(_, close, _)| close.end);