Skip to content

Commit 130363b

Browse files
committed
highlighter: Maintain highlight ordering when captures emit out-of-order
This fixes a case that pops up with some specific CSS highlights which causes the `active_highlights` stack to become out of order. The odd highlight is this: "#" @punctuation ((color_value) "#") @string.special (color_value) @string.special The `#` node is fought over by the first two patterns. Requiring that the pattern matches `#` in the second pattern causes tree-sitter to finish its capture after the first pattern, so we capture the child node `{Node # 9..10}` first and then `{Node color_value 9..13}` - reversed of the normal ordering. In this case we need to maintain the ordering of `active_highlights` by `Vec::insert`ing into the correct position.
1 parent 3a85528 commit 130363b

File tree

3 files changed

+78
-18
lines changed

3 files changed

+78
-18
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
--color: #fff;
2+
// ┡━━━━━┛╿ ╿┡━┛╰─ punctuation.delimiter
3+
// │ │ │╰─ string.special
4+
// │ │ ╰─ string.special punctuation
5+
// │ ╰─ punctuation.delimiter
6+
// ╰─ variable

highlighter/src/highlighter.rs

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::borrow::Cow;
2+
use std::cmp;
23
use std::fmt;
34
use std::mem::replace;
45
use std::num::NonZeroU32;
@@ -386,21 +387,45 @@ impl<'a, 'tree: 'a, Loader: LanguageLoader> Highlighter<'a, 'tree, Loader> {
386387
config.highlight_query.highlight_indices.load()[node.capture.idx()]
387388
};
388389

390+
let highlight = highlight.map(|highlight| HighlightedNode {
391+
end: range.end,
392+
highlight,
393+
});
394+
389395
// If multiple patterns match this exact node, prefer the last one which matched.
390396
// This matches the precedence of Neovim, Zed, and tree-sitter-cli.
391-
if !*first_highlight
392-
&& self
397+
if !*first_highlight {
398+
// NOTE: `!*first_highlight` implies that the start positions are the same.
399+
let insert_position = self
393400
.active_highlights
394-
.last()
395-
.is_some_and(|prev_node| prev_node.end == range.end)
396-
{
397-
self.active_highlights.pop();
398-
}
399-
if let Some(highlight) = highlight {
400-
self.active_highlights.push(HighlightedNode {
401-
end: range.end,
402-
highlight,
403-
});
401+
.iter()
402+
.rposition(|h| h.end <= range.end);
403+
if let Some(idx) = insert_position {
404+
match self.active_highlights[idx].end.cmp(&range.end) {
405+
// If there is a prior highlight for this start..end range, replace it.
406+
cmp::Ordering::Equal => {
407+
if let Some(highlight) = highlight {
408+
self.active_highlights[idx] = highlight;
409+
} else {
410+
self.active_highlights.remove(idx);
411+
}
412+
}
413+
// Captures are emitted in the order that they are finished. Insert any
414+
// highlights which start at the same position into the active highlights so
415+
// that the ordering invariant remains satisfied.
416+
cmp::Ordering::Less => {
417+
if let Some(highlight) = highlight {
418+
self.active_highlights.insert(idx, highlight)
419+
}
420+
}
421+
// By definition of our `rposition` predicate:
422+
cmp::Ordering::Greater => unreachable!(),
423+
}
424+
} else {
425+
self.active_highlights.extend(highlight);
426+
}
427+
} else if let Some(highlight) = highlight {
428+
self.active_highlights.push(highlight);
404429
*first_highlight = false;
405430
}
406431

@@ -420,7 +445,7 @@ impl<'a, 'tree: 'a, Loader: LanguageLoader> Highlighter<'a, 'tree, Loader> {
420445
.map(|layer| layer.parent_highlights)
421446
.unwrap_or_default();
422447

423-
self.active_highlights[layer_start..].is_sorted_by_key(|h| std::cmp::Reverse(h.end))
448+
self.active_highlights[layer_start..].is_sorted_by_key(|h| cmp::Reverse(h.end))
424449
},
425450
"unsorted highlights on layer {:?}: {:?}\nall active highlights must be sorted by `end` descending",
426451
self.current_layer,

highlighter/src/tests.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,6 @@ impl TestLanguageLoader {
123123
self.lang_config[lang.idx()] = OnceCell::new();
124124
}
125125

126-
// TODO: remove on first use.
127-
#[allow(dead_code)]
128126
fn overwrite_highlights(&mut self, lang: &str, content: String) {
129127
let lang = self.get(lang);
130128
self.overwrites[lang.idx()].highlights = Some(content);
@@ -144,8 +142,6 @@ impl TestLanguageLoader {
144142
self.lang_config[lang.idx()] = OnceCell::new();
145143
}
146144

147-
// TODO: remove on first use.
148-
#[allow(dead_code)]
149145
fn shadow_highlights(&mut self, lang: &str, content: &str) {
150146
let lang = self.get(lang);
151147
let skidder_config = skidder_config();
@@ -195,9 +191,10 @@ fn lang_for_path(path: &Path, loader: &TestLanguageLoader) -> Language {
195191
{
196192
"rs" => loader.get("rust"),
197193
"html" => loader.get("html"),
194+
"css" => loader.get("css"),
198195
"erl" => loader.get("erlang"),
199196
"md" => loader.get("markdown"),
200-
extension => unreachable!("unknown file type .{extension}"),
197+
extension => panic!("unknown file type .{extension}"),
201198
}
202199
}
203200

@@ -509,3 +506,35 @@ fn markdown_bold_highlight() {
509506
// into one span.
510507
highlight_fixture(&loader, "highlighter/markdown_bold.md");
511508
}
509+
510+
#[test]
511+
fn css_parent_child_highlight_precedence() {
512+
let mut loader = TestLanguageLoader::new();
513+
// NOTE: the pattern being tested here `((color_value) "#") @string.special` is odd and should
514+
// be rewritten to `(color_value "#" @string.special)` - that was probably the original intent
515+
// of the pattern. We overwrite the highlights to take the parts we need for this case so that
516+
// if/when we fix that pattern in the future it does not break this test case.
517+
loader.overwrite_highlights(
518+
"css",
519+
r##"
520+
((property_name) @variable
521+
(#match? @variable "^--"))
522+
523+
"#" @punctuation
524+
((color_value) "#") @string.special
525+
(color_value) @string.special
526+
527+
[";" ":"] @punctuation.delimiter
528+
"##
529+
.to_string(),
530+
);
531+
532+
// In this case two patterns fight over the `#` character and both should actually highlight
533+
// it. Because of the odd way that the pattern `((color_value) "#") @string.special` is
534+
// written, the QueryIter yields the captures in the opposite order it would normally:
535+
// first the child pattern `{Node # 9..10}` and then `{Node color_value 9..13}`.
536+
//
537+
// This case checks the invariant that "`active_highlights` ends are sorted descending" is
538+
// preserved.
539+
highlight_fixture(&loader, "highlighter/parent_child_highlight_precedence.css");
540+
}

0 commit comments

Comments
 (0)