Skip to main content

lattice_syntax/
style.rs

1//! Style category emitted by the highlighter, plus the capture-name table.
2//!
3//! `Style` and `StyledSpan` now live in `lattice-cells` (to break the
4//! `lattice-syntax → lattice-mode → lattice-runtime` dep cycle).
5//! Re-exported here so all existing `lattice_syntax::Style` / `StyledSpan`
6//! call-sites see no change.
7
8pub use lattice_cells::style::{Style, StyledSpan};
9
10/// The capture-name table consumed by `HighlightConfiguration::configure`.
11/// Order matters only as far as it's consistent with the index-to-style
12/// mapping below.
13///
14/// The `text.*` family is the nvim-treesitter convention used by
15/// `tree-sitter-md`'s bundled queries (and most other markup
16/// grammars). Extending this list with `markup.*` entries lets newer
17/// queries fall through cleanly when we adopt them.
18pub(crate) const CAPTURE_NAMES: &[&str] = &[
19    "comment.line",
20    "comment",
21    "string.escape",
22    "string",
23    "keyword.control",
24    "keyword.function",
25    "keyword.operator",
26    "keyword",
27    "type.builtin",
28    "type",
29    "number",
30    "constant.builtin",
31    "constant",
32    "function.method",
33    "function.macro",
34    "function",
35    "variable.parameter",
36    "variable.builtin",
37    "variable",
38    "operator",
39    "punctuation.bracket",
40    "punctuation.special",
41    "punctuation.delimiter",
42    "punctuation",
43    "attribute",
44    "tag",
45    "label",
46    // ---- Markup captures (markdown block + inline grammars) ----
47    "text.title.1",
48    "text.title.2",
49    "text.title.3",
50    "text.title.4",
51    "text.title.5",
52    "text.title.6",
53    "text.title",
54    "text.strong",
55    "text.emphasis",
56    "text.uri",
57    "text.reference",
58    "text.literal",
59    "none",
60];
61
62/// TK.1: resolve a capture name that names a **registered theme
63/// element** rather than a builtin category.
64///
65/// `Style::Element` exists precisely for vocabularies that are open by
66/// nature — its own doc says a plugin "can register a theme element by
67/// name but can **never** add a variant to a Rust enum". It was
68/// reachable from decorations and listing icons and not from a
69/// tree-sitter query, which is the gap this closes. Org's TODO
70/// keywords are the first caller (`org.todo.WAITING`); nothing here
71/// knows that.
72///
73/// Builtin names win. A capture called `keyword` stays
74/// [`Style::Keyword`] even if some plugin registers an element under
75/// that name, because the closed categories are the editor's own
76/// vocabulary and a plugin must not be able to redefine what `keyword`
77/// means for every language at once.
78///
79/// `theme` is `None` wherever no registry is in hand — the native
80/// `LangRegistry::standard()` path, and every test that does not care.
81/// Then this is exactly the pre-TK.1 mapping.
82pub fn name_to_style_with_theme(
83    name: &str,
84    theme: Option<&dyn lattice_theme::ThemeRegistry>,
85) -> Style {
86    let builtin = name_to_style(name);
87    if builtin != Style::Default {
88        return builtin;
89    }
90    // Only an unrecognised name reaches the registry, so this costs a
91    // lookup per unknown capture at query-COMPILE time — never per
92    // span, and never per frame.
93    let Some(theme) = theme else {
94        return Style::Default;
95    };
96    theme
97        .id(&lattice_theme::ElementName::from(name.to_string()))
98        .map(Style::Element)
99        .unwrap_or(Style::Default)
100}
101
102/// Public re-export of the capture-name → Style mapping.
103pub fn name_to_style_pub(name: &str) -> Style {
104    name_to_style(name)
105}
106
107/// Capture-name priority: position in `CAPTURE_NAMES` (lower = higher
108/// precedence on overlap). Walks the dot-prefix hierarchy.
109pub fn capture_priority(name: &str) -> u32 {
110    let mut best: Option<usize> = None;
111    let mut probe = name;
112    loop {
113        if let Some(pos) = CAPTURE_NAMES.iter().position(|n| *n == probe) {
114            best = Some(pos);
115            break;
116        }
117        match probe.rfind('.') {
118            Some(i) => probe = &probe[..i],
119            None => break,
120        }
121    }
122    best.map(|p| p as u32).unwrap_or(*ELEMENT_CAPTURE_PRIORITY)
123}
124
125/// TK.1: the overlap precedence an element-backed capture gets.
126///
127/// **This is the half that would otherwise fail silently.** The old
128/// fallback was `u32::MAX` — the *lowest* precedence — and org's query
129/// captures a whole headline `(item)` as `@text.title.N`. An
130/// `@org.todo.WAITING` capture over the keyword would therefore lose
131/// its overlap with the title and paint nothing, with every individual
132/// piece correct in isolation and the feature simply absent.
133///
134/// The value is `keyword`'s, and that is chosen rather than invented:
135/// a TODO keyword is captured as `@keyword` today, so taking the same
136/// priority keeps overlap behaviour byte-identical and changes only the
137/// colour. A change that is behaviour-preserving in every dimension
138/// except the one being changed is the one that cannot surprise.
139fn element_capture_priority() -> u32 {
140    // Resolved from the table rather than written as a literal, so
141    // reordering `CAPTURE_NAMES` cannot silently retune this.
142    CAPTURE_NAMES
143        .iter()
144        .position(|n| *n == "keyword")
145        .map(|p| p as u32)
146        .unwrap_or(u32::MAX)
147}
148
149/// Cached form of [`element_capture_priority`] for the hot walk.
150static ELEMENT_CAPTURE_PRIORITY: std::sync::LazyLock<u32> =
151    std::sync::LazyLock::new(element_capture_priority);
152
153pub(crate) fn name_to_style(name: &str) -> Style {
154    let head = name.split('.').next().unwrap_or(name);
155    match head {
156        "comment" if name.starts_with("comment.line") => Style::LineComment,
157        "comment" => Style::Comment,
158        "string" => Style::String,
159        "keyword" => Style::Keyword,
160        "type" => Style::Type,
161        "number" => Style::Number,
162        "function" => Style::Function,
163        "constant" => Style::Constant,
164        "variable" => Style::Variable,
165        "operator" => Style::Operator,
166        "punctuation" if name == "punctuation.special" => Style::Markup,
167        "punctuation" => Style::Punctuation,
168        "attribute" => Style::Attribute,
169        "tag" => Style::Type,
170        "label" => Style::Constant,
171        "text" => match name {
172            "text.title.1" => Style::Heading1,
173            "text.title.2" => Style::Heading2,
174            "text.title.3" => Style::Heading3,
175            "text.title.4" => Style::Heading4,
176            "text.title.5" => Style::Heading5,
177            "text.title.6" => Style::Heading6,
178            "text.title" => Style::Heading1,
179            "text.strong" => Style::Bold,
180            "text.emphasis" => Style::Italic,
181            "text.uri" => Style::Url,
182            "text.reference" => Style::Link,
183            "text.literal" => Style::MarkupRaw,
184            _ => Style::Default,
185        },
186        "none" => Style::Default,
187        _ => Style::Default,
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    #![allow(clippy::unwrap_used, clippy::panic)]
194    use super::*;
195
196    #[test]
197    fn known_names_map_to_distinct_styles() {
198        assert_eq!(name_to_style("keyword"), Style::Keyword);
199        assert_eq!(name_to_style("string"), Style::String);
200        assert_eq!(name_to_style("type"), Style::Type);
201        assert_eq!(name_to_style("number"), Style::Number);
202    }
203
204    #[test]
205    fn dot_prefixed_names_use_the_head() {
206        assert_eq!(name_to_style("keyword.control"), Style::Keyword);
207        assert_eq!(name_to_style("type.builtin"), Style::Type);
208        assert_eq!(name_to_style("string.escape"), Style::String);
209    }
210
211    #[test]
212    fn comment_line_specialization() {
213        assert_eq!(name_to_style("comment.line"), Style::LineComment);
214        assert_eq!(name_to_style("comment"), Style::Comment);
215    }
216
217    #[test]
218    fn unknown_names_fall_back_to_default() {
219        assert_eq!(name_to_style("unknown"), Style::Default);
220        assert_eq!(name_to_style(""), Style::Default);
221    }
222
223    #[test]
224    fn capture_names_table_is_non_empty_and_has_no_duplicates() {
225        assert!(!CAPTURE_NAMES.is_empty());
226        let mut seen = std::collections::HashSet::new();
227        for &n in CAPTURE_NAMES {
228            assert!(seen.insert(n), "duplicate capture name: {n}");
229        }
230    }
231
232    #[test]
233    fn markup_heading_levels_are_distinct() {
234        assert_eq!(name_to_style("text.title.1"), Style::Heading1);
235        assert_eq!(name_to_style("text.title.2"), Style::Heading2);
236        assert_eq!(name_to_style("text.title.6"), Style::Heading6);
237        assert_eq!(name_to_style("text.title"), Style::Heading1);
238    }
239
240    #[test]
241    fn markup_emphasis_styles_resolve() {
242        assert_eq!(name_to_style("text.strong"), Style::Bold);
243        assert_eq!(name_to_style("text.emphasis"), Style::Italic);
244        assert_eq!(name_to_style("text.uri"), Style::Url);
245        assert_eq!(name_to_style("text.reference"), Style::Link);
246        assert_eq!(name_to_style("text.literal"), Style::MarkupRaw);
247    }
248
249    #[test]
250    fn punctuation_special_maps_to_markup() {
251        assert_eq!(name_to_style("punctuation.special"), Style::Markup);
252        assert_eq!(name_to_style("punctuation"), Style::Punctuation);
253        assert_eq!(name_to_style("punctuation.bracket"), Style::Punctuation);
254    }
255
256    // ---- TK.1: a capture name may name a theme element ----
257
258    use lattice_theme::{
259        ColorRef, ElementName, ElementOwner, InMemoryThemeRegistry, Palette, StyleSpec,
260        ThemeRegistry,
261    };
262
263    fn registry_with(names: &[&str]) -> InMemoryThemeRegistry {
264        let r = InMemoryThemeRegistry::new(Palette::default());
265        for n in names {
266            r.register(
267                ElementName::from((*n).to_string()),
268                ElementOwner::Plugin("org".into()),
269                StyleSpec {
270                    fg: Some(ColorRef::Palette("red".into())),
271                    ..Default::default()
272                },
273                "test element",
274            );
275        }
276        r
277    }
278
279    #[test]
280    fn tk1_a_registered_element_name_resolves_to_that_element() {
281        let r = registry_with(&["org.todo.WAITING"]);
282        let id = r
283            .id(&ElementName::from("org.todo.WAITING".to_string()))
284            .expect("registered");
285        assert_eq!(
286            name_to_style_with_theme("org.todo.WAITING", Some(&r)),
287            Style::Element(id)
288        );
289    }
290
291    #[test]
292    fn tk1_an_unregistered_dotted_name_is_still_default() {
293        // No accidental matching: only a name the theme actually knows
294        // becomes an element.
295        let r = registry_with(&["org.todo.WAITING"]);
296        assert_eq!(
297            name_to_style_with_theme("org.todo.NOPE", Some(&r)),
298            Style::Default
299        );
300    }
301
302    /// A plugin must not be able to redefine what `keyword` means for
303    /// every language at once. The closed categories are the editor's
304    /// own vocabulary and they win.
305    #[test]
306    fn tk1_a_builtin_capture_name_is_never_shadowed_by_an_element() {
307        let r = registry_with(&["keyword", "comment", "text.title.1"]);
308        assert_eq!(
309            name_to_style_with_theme("keyword", Some(&r)),
310            Style::Keyword
311        );
312        assert_eq!(
313            name_to_style_with_theme("comment", Some(&r)),
314            Style::Comment
315        );
316        assert_eq!(
317            name_to_style_with_theme("text.title.1", Some(&r)),
318            Style::Heading1
319        );
320    }
321
322    #[test]
323    fn tk1_no_registry_is_exactly_the_pre_tk1_mapping() {
324        for n in [
325            "keyword",
326            "comment",
327            "text.title.1",
328            "org.todo.WAITING",
329            "utterly.unknown",
330        ] {
331            assert_eq!(
332                name_to_style_with_theme(n, None),
333                name_to_style(n),
334                "{n} must be unchanged without a registry"
335            );
336        }
337    }
338
339    /// **The half that would otherwise fail silently.**
340    ///
341    /// Org's query captures a whole headline `(item)` as
342    /// `@text.title.N`, and an element capture over the keyword overlaps
343    /// it. Under the old `u32::MAX` fallback the element would LOSE that
344    /// overlap and paint nothing — every piece correct in isolation, the
345    /// feature simply absent. An element capture takes `keyword`'s
346    /// priority, so it wins exactly as `@keyword` does today.
347    #[test]
348    fn tk1_an_element_capture_outranks_a_title_capture() {
349        let element = capture_priority("org.todo.WAITING");
350        let title = capture_priority("text.title.1");
351        assert!(
352            element < title,
353            "element ({element}) must outrank text.title.1 ({title}), \
354             or the keyword never paints"
355        );
356    }
357
358    #[test]
359    fn tk1_an_element_capture_has_exactly_keywords_priority() {
360        // Chosen rather than invented: TODO is `@keyword` today, so this
361        // keeps overlap behaviour byte-identical and changes only colour.
362        assert_eq!(
363            capture_priority("org.todo.WAITING"),
364            capture_priority("keyword")
365        );
366    }
367
368    #[test]
369    fn tk1_builtin_capture_priorities_are_unchanged() {
370        // The fallback moved from u32::MAX; nothing that already had a
371        // position may have shifted.
372        assert!(capture_priority("comment.line") < capture_priority("comment"));
373        assert!(capture_priority("keyword.control") < capture_priority("keyword"));
374        assert!(capture_priority("text.title.1") < capture_priority("none"));
375    }
376}