arborium_theme/
highlights.rs

1//! Highlight category definitions - single source of truth.
2//!
3//! This module defines all highlight categories used for syntax highlighting.
4//! It maps the large vocabulary of capture names from various sources (nvim-treesitter,
5//! helix, etc.) to a small set of theme slots.
6//!
7//! # Architecture
8//!
9//! The highlighting system has three layers:
10//!
11//! 1. **Capture names** - The broad vocabulary used in highlight queries
12//!    (e.g., `@keyword.function`, `@include`, `@conditional`, `@repeat`)
13//!
14//! 2. **Theme slots** - A fixed set of ~15-20 color slots that themes define
15//!    (e.g., `keyword`, `function`, `string`, `comment`, `type`)
16//!
17//! 3. **HTML tags** - Short tags for rendering (e.g., `<a-k>`, `<a-f>`, `<a-s>`)
18//!
19//! Multiple capture names map to the same theme slot. For example:
20//! - `include`, `keyword.import`, `keyword.require` → all use the `keyword` slot
21//! - `conditional`, `keyword.conditional`, `repeat` → all use the `keyword` slot
22//!
23//! Adjacent spans that map to the same slot are coalesced into a single HTML element.
24
25/// The theme slots - the fixed set of color categories that themes define.
26/// This is the final destination for all capture names.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum ThemeSlot {
29    Keyword,
30    Function,
31    String,
32    Comment,
33    Type,
34    Variable,
35    Constant,
36    Number,
37    Operator,
38    Punctuation,
39    Property,
40    Attribute,
41    Tag,
42    Macro,
43    Label,
44    Namespace,
45    Constructor,
46    /// Markup: headings, titles
47    Title,
48    /// Markup: bold text
49    Strong,
50    /// Markup: italic text
51    Emphasis,
52    /// Markup: links/URLs
53    Link,
54    /// Markup: raw/literal/code blocks
55    Literal,
56    /// Markup: strikethrough
57    Strikethrough,
58    /// Diff additions
59    DiffAdd,
60    /// Diff deletions
61    DiffDelete,
62    /// Embedded content
63    Embedded,
64    /// Errors
65    Error,
66    /// No styling (invisible captures like spell, nospell)
67    None,
68}
69
70impl ThemeSlot {
71    /// Get the HTML tag suffix for this slot.
72    /// Returns None for slots that produce no styling.
73    pub fn tag(self) -> Option<&'static str> {
74        match self {
75            ThemeSlot::Keyword => Some("k"),
76            ThemeSlot::Function => Some("f"),
77            ThemeSlot::String => Some("s"),
78            ThemeSlot::Comment => Some("c"),
79            ThemeSlot::Type => Some("t"),
80            ThemeSlot::Variable => Some("v"),
81            ThemeSlot::Constant => Some("co"),
82            ThemeSlot::Number => Some("n"),
83            ThemeSlot::Operator => Some("o"),
84            ThemeSlot::Punctuation => Some("p"),
85            ThemeSlot::Property => Some("pr"),
86            ThemeSlot::Attribute => Some("at"),
87            ThemeSlot::Tag => Some("tg"),
88            ThemeSlot::Macro => Some("m"),
89            ThemeSlot::Label => Some("l"),
90            ThemeSlot::Namespace => Some("ns"),
91            ThemeSlot::Constructor => Some("cr"),
92            /// Markup: headings, titles
93            ThemeSlot::Title => Some("tt"),
94            /// Markup: bold text
95            ThemeSlot::Strong => Some("st"),
96            /// Markup: italic text
97            ThemeSlot::Emphasis => Some("em"),
98            /// Markup: links/URLs
99            ThemeSlot::Link => Some("tu"),
100            /// Markup: raw/literal/code blocks
101            ThemeSlot::Literal => Some("tl"),
102            /// Markup: strikethrough
103            ThemeSlot::Strikethrough => Some("tx"),
104            /// Diff additions
105            ThemeSlot::DiffAdd => Some("da"),
106            /// Diff deletions
107            ThemeSlot::DiffDelete => Some("dd"),
108            /// Embedded content
109            ThemeSlot::Embedded => Some("eb"),
110            /// Errors
111            ThemeSlot::Error => Some("er"),
112            /// No styling (invisible captures like spell, nospell)
113            ThemeSlot::None => None,
114        }
115    }
116}
117
118/// Map a theme slot to a canonical highlight index.
119///
120/// This is useful for ANSI rendering, where we want to
121/// look up a single representative style for each slot.
122pub fn slot_to_highlight_index(slot: ThemeSlot) -> Option<usize> {
123    match slot {
124        ThemeSlot::Keyword => HIGHLIGHTS.iter().position(|h| h.name == "keyword"),
125        ThemeSlot::Function => HIGHLIGHTS.iter().position(|h| h.name == "function"),
126        ThemeSlot::String => HIGHLIGHTS.iter().position(|h| h.name == "string"),
127        ThemeSlot::Comment => HIGHLIGHTS.iter().position(|h| h.name == "comment"),
128        ThemeSlot::Type => HIGHLIGHTS.iter().position(|h| h.name == "type"),
129        ThemeSlot::Variable => HIGHLIGHTS.iter().position(|h| h.name == "variable"),
130        ThemeSlot::Constant => HIGHLIGHTS.iter().position(|h| h.name == "constant"),
131        ThemeSlot::Number => HIGHLIGHTS.iter().position(|h| h.name == "number"),
132        ThemeSlot::Operator => HIGHLIGHTS.iter().position(|h| h.name == "operator"),
133        ThemeSlot::Punctuation => HIGHLIGHTS.iter().position(|h| h.name == "punctuation"),
134        ThemeSlot::Property => HIGHLIGHTS.iter().position(|h| h.name == "property"),
135        ThemeSlot::Attribute => HIGHLIGHTS.iter().position(|h| h.name == "attribute"),
136        ThemeSlot::Tag => HIGHLIGHTS.iter().position(|h| h.name == "tag"),
137        ThemeSlot::Macro => HIGHLIGHTS.iter().position(|h| h.name == "macro"),
138        ThemeSlot::Label => HIGHLIGHTS.iter().position(|h| h.name == "label"),
139        ThemeSlot::Namespace => HIGHLIGHTS.iter().position(|h| h.name == "namespace"),
140        ThemeSlot::Constructor => HIGHLIGHTS.iter().position(|h| h.name == "constructor"),
141        ThemeSlot::Title => HIGHLIGHTS
142            .iter()
143            .position(|h| h.name == "text.title" || h.name == "markup.heading"),
144        ThemeSlot::Strong => HIGHLIGHTS
145            .iter()
146            .position(|h| h.name == "text.strong" || h.name == "markup.bold"),
147        ThemeSlot::Emphasis => HIGHLIGHTS
148            .iter()
149            .position(|h| h.name == "text.emphasis" || h.name == "markup.italic"),
150        ThemeSlot::Link => HIGHLIGHTS
151            .iter()
152            .position(|h| h.name == "text.uri" || h.name == "text.reference"),
153        ThemeSlot::Literal => HIGHLIGHTS.iter().position(|h| h.name == "text.literal"),
154        ThemeSlot::Strikethrough => HIGHLIGHTS
155            .iter()
156            .position(|h| h.name == "text.strikethrough"),
157        ThemeSlot::DiffAdd => HIGHLIGHTS.iter().position(|h| h.name == "diff.addition"),
158        ThemeSlot::DiffDelete => HIGHLIGHTS.iter().position(|h| h.name == "diff.deletion"),
159        ThemeSlot::Embedded => HIGHLIGHTS.iter().position(|h| h.name == "embedded"),
160        ThemeSlot::Error => HIGHLIGHTS.iter().position(|h| h.name == "error"),
161        ThemeSlot::None => None,
162    }
163}
164
165/// Map any capture name to its theme slot.
166///
167/// This handles the full vocabulary of capture names from various sources:
168/// - Standard tree-sitter names (keyword, function, string, etc.)
169/// - nvim-treesitter names (include, conditional, repeat, storageclass, etc.)
170/// - Helix names
171/// - Sub-categories (keyword.function, keyword.import, etc.)
172///
173/// All are mapped to a fixed set of ~20 theme slots.
174pub fn capture_to_slot(capture: &str) -> ThemeSlot {
175    // First, strip any leading @ (some queries include it)
176    let capture = capture.strip_prefix('@').unwrap_or(capture);
177
178    match capture {
179        // Keywords - base and all variants
180        "keyword" | "keyword.conditional" | "keyword.coroutine" | "keyword.debug"
181        | "keyword.exception" | "keyword.function" | "keyword.import" | "keyword.operator"
182        | "keyword.repeat" | "keyword.return" | "keyword.type" | "keyword.modifier"
183        | "keyword.directive" | "keyword.storage" | "keyword.control"
184        | "keyword.control.conditional" | "keyword.control.repeat" | "keyword.control.import"
185        | "keyword.control.return" | "keyword.control.exception"
186        // nvim-treesitter legacy names that are really keywords
187        | "include" | "conditional" | "repeat" | "exception" | "storageclass" | "preproc"
188        | "define" | "structure" => ThemeSlot::Keyword,
189
190        // Functions
191        "function" | "function.builtin" | "function.method" | "function.definition"
192        | "function.call" | "function.special" | "method" | "method.call" => ThemeSlot::Function,
193
194        // Strings
195        "string" | "string.special" | "string.special.symbol" | "string.special.path"
196        | "string.special.url" | "string.escape" | "string.regexp" | "string.regex"
197        | "character" | "character.special" | "escape" => ThemeSlot::String,
198
199        // Comments
200        "comment" | "comment.documentation" | "comment.line" | "comment.block"
201        | "comment.error" | "comment.warning" | "comment.note" | "comment.todo" => {
202            ThemeSlot::Comment
203        }
204
205        // Types
206        "type" | "type.builtin" | "type.qualifier" | "type.definition" | "type.enum"
207        | "type.enum.variant" | "type.parameter" => ThemeSlot::Type,
208
209        // Variables
210        "variable" | "variable.builtin" | "variable.parameter" | "variable.member"
211        | "variable.other" | "variable.other.member" | "parameter" | "field" => {
212            ThemeSlot::Variable
213        }
214
215        // Constants
216        "constant" | "constant.builtin" | "constant.builtin.boolean" | "boolean" => {
217            ThemeSlot::Constant
218        }
219
220        // Numbers
221        "number" | "constant.numeric" | "float" | "number.float" => ThemeSlot::Number,
222
223        // Operators
224        "operator" => ThemeSlot::Operator,
225
226        // Punctuation
227        "punctuation" | "punctuation.bracket" | "punctuation.delimiter" | "punctuation.special" => {
228            ThemeSlot::Punctuation
229        }
230
231        // Properties
232        "property" | "property.builtin" => ThemeSlot::Property,
233
234        // Attributes
235        "attribute" | "attribute.builtin" => ThemeSlot::Attribute,
236
237        // Tags (HTML/XML)
238        "tag" | "tag.delimiter" | "tag.error" | "tag.attribute" | "tag.builtin" => ThemeSlot::Tag,
239
240        // Macros
241        "macro" | "function.macro" | "preproc.macro" => ThemeSlot::Macro,
242
243        // Labels
244        "label" => ThemeSlot::Label,
245
246        // Namespaces/Modules
247        "namespace" | "module" => ThemeSlot::Namespace,
248
249        // Constructors
250        "constructor" | "constructor.builtin" => ThemeSlot::Constructor,
251
252        // Markup - titles/headings
253        "text.title" | "markup.heading" | "markup.heading.1" | "markup.heading.2"
254        | "markup.heading.3" | "markup.heading.4" | "markup.heading.5" | "markup.heading.6" => {
255            ThemeSlot::Title
256        }
257
258        // Markup - bold
259        "text.strong" | "markup.bold" => ThemeSlot::Strong,
260
261        // Markup - italic
262        "text.emphasis" | "markup.italic" => ThemeSlot::Emphasis,
263
264        // Markup - links
265        "text.uri" | "text.reference" | "markup.link" | "markup.link.url" | "markup.link.text"
266        | "markup.link.label" => ThemeSlot::Link,
267
268        // Markup - literal/raw/code
269        "text.literal" | "markup.raw" | "markup.raw.block" | "markup.raw.inline"
270        | "markup.inline" => ThemeSlot::Literal,
271
272        // Markup - strikethrough
273        "text.strikethrough" | "markup.strikethrough" => ThemeSlot::Strikethrough,
274
275        // Markup - lists (treat as punctuation)
276        "markup.list" | "markup.list.checked" | "markup.list.unchecked"
277        | "markup.list.numbered" | "markup.list.unnumbered" | "markup.quote" => {
278            ThemeSlot::Punctuation
279        }
280
281        // Diff
282        "diff.addition" | "diff.plus" | "diff.delta" => ThemeSlot::DiffAdd,
283        "diff.deletion" | "diff.minus" => ThemeSlot::DiffDelete,
284
285        // Embedded
286        "embedded" => ThemeSlot::Embedded,
287
288        // Error
289        "error" => ThemeSlot::Error,
290
291        // No styling
292        "none" | "nospell" | "spell" | "text" | "markup" => ThemeSlot::None,
293
294        // Fallback: try to match by prefix
295        other => {
296            if other.starts_with("keyword") {
297                ThemeSlot::Keyword
298            } else if other.starts_with("function") || other.starts_with("method") {
299                ThemeSlot::Function
300            } else if other.starts_with("string") || other.starts_with("character") {
301                ThemeSlot::String
302            } else if other.starts_with("comment") {
303                ThemeSlot::Comment
304            } else if other.starts_with("type") {
305                ThemeSlot::Type
306            } else if other.starts_with("variable") || other.starts_with("parameter") {
307                ThemeSlot::Variable
308            } else if other.starts_with("constant") {
309                ThemeSlot::Constant
310            } else if other.starts_with("punctuation") {
311                ThemeSlot::Punctuation
312            } else if other.starts_with("tag") {
313                ThemeSlot::Tag
314            } else if other.starts_with("markup.heading") || other.starts_with("text.title") {
315                ThemeSlot::Title
316            } else if other.starts_with("markup") || other.starts_with("text") {
317                // Generic markup/text - no styling
318                ThemeSlot::None
319            } else {
320                // Unknown capture - no styling
321                ThemeSlot::None
322            }
323        }
324    }
325}
326
327/// A highlight category definition.
328///
329/// NOTE: This is the legacy structure used for tree-sitter highlight configuration.
330/// For mapping captures to theme slots, use [`capture_to_slot`] instead.
331pub struct HighlightDef {
332    /// The canonical name (e.g., "keyword.function")
333    pub name: &'static str,
334    /// Short tag suffix for HTML elements (e.g., "kf" -> `<a-kf>`)
335    /// Empty string means no styling should be applied.
336    pub tag: &'static str,
337    /// Parent category tag for CSS inheritance (e.g., "k" for keyword.*)
338    /// Empty string means no parent.
339    pub parent_tag: &'static str,
340    /// Alternative names from nvim-treesitter/helix/other editors that map to this category
341    pub aliases: &'static [&'static str],
342}
343
344/// All highlight categories, in order.
345/// The index in this array is the highlight index used throughout the codebase.
346pub const HIGHLIGHTS: &[HighlightDef] = &[
347    // Core categories
348    HighlightDef {
349        name: "attribute",
350        tag: "at",
351        parent_tag: "",
352        aliases: &[],
353    },
354    HighlightDef {
355        name: "constant",
356        tag: "co",
357        parent_tag: "",
358        aliases: &[],
359    },
360    HighlightDef {
361        name: "constant.builtin",
362        tag: "cb",
363        parent_tag: "co",
364        aliases: &["constant.builtin.boolean"],
365    },
366    HighlightDef {
367        name: "constructor",
368        tag: "cr",
369        parent_tag: "",
370        aliases: &[],
371    },
372    HighlightDef {
373        name: "function.builtin",
374        tag: "fb",
375        parent_tag: "f",
376        aliases: &[],
377    },
378    HighlightDef {
379        name: "function",
380        tag: "f",
381        parent_tag: "",
382        aliases: &[],
383    },
384    HighlightDef {
385        name: "function.method",
386        tag: "fm",
387        parent_tag: "f",
388        aliases: &[],
389    },
390    HighlightDef {
391        name: "keyword",
392        tag: "k",
393        parent_tag: "",
394        aliases: &[],
395    },
396    HighlightDef {
397        name: "keyword.conditional",
398        tag: "kc",
399        parent_tag: "k",
400        aliases: &[],
401    },
402    HighlightDef {
403        name: "keyword.coroutine",
404        tag: "ko",
405        parent_tag: "k",
406        aliases: &[],
407    },
408    HighlightDef {
409        name: "keyword.debug",
410        tag: "kd",
411        parent_tag: "k",
412        aliases: &[],
413    },
414    HighlightDef {
415        name: "keyword.exception",
416        tag: "ke",
417        parent_tag: "k",
418        aliases: &[],
419    },
420    HighlightDef {
421        name: "keyword.function",
422        tag: "kf",
423        parent_tag: "k",
424        aliases: &[],
425    },
426    HighlightDef {
427        name: "keyword.import",
428        tag: "ki",
429        parent_tag: "k",
430        aliases: &[],
431    },
432    HighlightDef {
433        name: "keyword.operator",
434        tag: "kp",
435        parent_tag: "k",
436        aliases: &[],
437    },
438    HighlightDef {
439        name: "keyword.repeat",
440        tag: "kr",
441        parent_tag: "k",
442        aliases: &[],
443    },
444    HighlightDef {
445        name: "keyword.return",
446        tag: "kt",
447        parent_tag: "k",
448        aliases: &[],
449    },
450    HighlightDef {
451        name: "keyword.type",
452        tag: "ky",
453        parent_tag: "k",
454        aliases: &[],
455    },
456    HighlightDef {
457        name: "operator",
458        tag: "o",
459        parent_tag: "",
460        aliases: &[],
461    },
462    HighlightDef {
463        name: "property",
464        tag: "pr",
465        parent_tag: "",
466        aliases: &[],
467    },
468    HighlightDef {
469        name: "punctuation",
470        tag: "p",
471        parent_tag: "",
472        aliases: &[],
473    },
474    HighlightDef {
475        name: "punctuation.bracket",
476        tag: "pb",
477        parent_tag: "p",
478        aliases: &[],
479    },
480    HighlightDef {
481        name: "punctuation.delimiter",
482        tag: "pd",
483        parent_tag: "p",
484        aliases: &[],
485    },
486    HighlightDef {
487        name: "punctuation.special",
488        tag: "ps",
489        parent_tag: "p",
490        aliases: &[],
491    },
492    HighlightDef {
493        name: "string",
494        tag: "s",
495        parent_tag: "",
496        aliases: &[],
497    },
498    HighlightDef {
499        name: "string.special",
500        tag: "ss",
501        parent_tag: "s",
502        aliases: &["string.special.symbol", "string.special.path"],
503    },
504    HighlightDef {
505        name: "tag",
506        tag: "tg",
507        parent_tag: "",
508        aliases: &[],
509    },
510    HighlightDef {
511        name: "tag.delimiter",
512        tag: "td",
513        parent_tag: "tg",
514        aliases: &[],
515    },
516    HighlightDef {
517        name: "tag.error",
518        tag: "te",
519        parent_tag: "tg",
520        aliases: &[],
521    },
522    HighlightDef {
523        name: "type",
524        tag: "t",
525        parent_tag: "",
526        aliases: &[],
527    },
528    HighlightDef {
529        name: "type.builtin",
530        tag: "tb",
531        parent_tag: "t",
532        aliases: &[],
533    },
534    HighlightDef {
535        name: "type.qualifier",
536        tag: "tq",
537        parent_tag: "t",
538        aliases: &[],
539    },
540    HighlightDef {
541        name: "variable",
542        tag: "v",
543        parent_tag: "",
544        aliases: &[],
545    },
546    HighlightDef {
547        name: "variable.builtin",
548        tag: "vb",
549        parent_tag: "v",
550        aliases: &[],
551    },
552    HighlightDef {
553        name: "variable.parameter",
554        tag: "vp",
555        parent_tag: "v",
556        aliases: &["parameter"],
557    },
558    HighlightDef {
559        name: "comment",
560        tag: "c",
561        parent_tag: "",
562        aliases: &[],
563    },
564    HighlightDef {
565        name: "comment.documentation",
566        tag: "cd",
567        parent_tag: "c",
568        aliases: &[],
569    },
570    HighlightDef {
571        name: "macro",
572        tag: "m",
573        parent_tag: "",
574        aliases: &[],
575    },
576    HighlightDef {
577        name: "label",
578        tag: "l",
579        parent_tag: "",
580        aliases: &[],
581    },
582    HighlightDef {
583        name: "diff.addition",
584        tag: "da",
585        parent_tag: "",
586        aliases: &["diff.plus", "diff.delta"],
587    },
588    HighlightDef {
589        name: "diff.deletion",
590        tag: "dd",
591        parent_tag: "",
592        aliases: &["diff.minus"],
593    },
594    HighlightDef {
595        name: "number",
596        tag: "n",
597        parent_tag: "",
598        aliases: &["constant.numeric"],
599    },
600    HighlightDef {
601        name: "text.literal",
602        tag: "tl",
603        parent_tag: "",
604        aliases: &["markup.raw"],
605    },
606    HighlightDef {
607        name: "text.emphasis",
608        tag: "em",
609        parent_tag: "",
610        aliases: &["markup.italic"],
611    },
612    HighlightDef {
613        name: "text.strong",
614        tag: "st",
615        parent_tag: "",
616        aliases: &["markup.bold"],
617    },
618    HighlightDef {
619        name: "text.uri",
620        tag: "tu",
621        parent_tag: "",
622        aliases: &["markup.link.url"],
623    },
624    HighlightDef {
625        name: "text.reference",
626        tag: "tr",
627        parent_tag: "",
628        aliases: &["markup.link.text"],
629    },
630    HighlightDef {
631        name: "string.escape",
632        tag: "se",
633        parent_tag: "s",
634        aliases: &["escape"],
635    },
636    HighlightDef {
637        name: "text.title",
638        tag: "tt",
639        parent_tag: "",
640        aliases: &["markup.heading"],
641    },
642    HighlightDef {
643        name: "text.strikethrough",
644        tag: "tx",
645        parent_tag: "",
646        aliases: &["markup.strikethrough"],
647    },
648    HighlightDef {
649        name: "spell",
650        tag: "sp",
651        parent_tag: "",
652        aliases: &[],
653    },
654    HighlightDef {
655        name: "embedded",
656        tag: "eb",
657        parent_tag: "",
658        aliases: &[],
659    },
660    HighlightDef {
661        name: "error",
662        tag: "er",
663        parent_tag: "",
664        aliases: &[],
665    },
666    HighlightDef {
667        name: "namespace",
668        tag: "ns",
669        parent_tag: "",
670        aliases: &["module"],
671    },
672    // Legacy/alternative names used by some grammars
673    HighlightDef {
674        name: "include",
675        tag: "in",
676        parent_tag: "k",
677        aliases: &[],
678    },
679    HighlightDef {
680        name: "storageclass",
681        tag: "sc",
682        parent_tag: "k",
683        aliases: &[],
684    },
685    HighlightDef {
686        name: "repeat",
687        tag: "rp",
688        parent_tag: "k",
689        aliases: &[],
690    },
691    HighlightDef {
692        name: "conditional",
693        tag: "cn",
694        parent_tag: "k",
695        aliases: &[],
696    },
697    HighlightDef {
698        name: "exception",
699        tag: "ex",
700        parent_tag: "k",
701        aliases: &[],
702    },
703    HighlightDef {
704        name: "preproc",
705        tag: "pp",
706        parent_tag: "k",
707        aliases: &[],
708    },
709    HighlightDef {
710        name: "none",
711        tag: "",
712        parent_tag: "",
713        aliases: &[],
714    }, // No styling
715    HighlightDef {
716        name: "character",
717        tag: "ch",
718        parent_tag: "s",
719        aliases: &[],
720    },
721    HighlightDef {
722        name: "character.special",
723        tag: "cs",
724        parent_tag: "s",
725        aliases: &[],
726    },
727    HighlightDef {
728        name: "variable.member",
729        tag: "vm",
730        parent_tag: "v",
731        aliases: &[],
732    },
733    HighlightDef {
734        name: "function.definition",
735        tag: "fd",
736        parent_tag: "f",
737        aliases: &[],
738    },
739    HighlightDef {
740        name: "type.definition",
741        tag: "tf",
742        parent_tag: "t",
743        aliases: &[],
744    },
745    HighlightDef {
746        name: "function.call",
747        tag: "fc",
748        parent_tag: "f",
749        aliases: &[],
750    },
751    HighlightDef {
752        name: "keyword.modifier",
753        tag: "km",
754        parent_tag: "k",
755        aliases: &[],
756    },
757    HighlightDef {
758        name: "keyword.directive",
759        tag: "dr",
760        parent_tag: "k",
761        aliases: &[],
762    },
763    HighlightDef {
764        name: "string.regexp",
765        tag: "rx",
766        parent_tag: "s",
767        aliases: &["string.regex"],
768    },
769    HighlightDef {
770        name: "nospell",
771        tag: "",
772        parent_tag: "",
773        aliases: &[],
774    }, // No styling
775    HighlightDef {
776        name: "float",
777        tag: "n",
778        parent_tag: "",
779        aliases: &[],
780    }, // Same as number
781    HighlightDef {
782        name: "boolean",
783        tag: "cb",
784        parent_tag: "",
785        aliases: &[],
786    }, // Same as constant.builtin
787];
788
789/// Get the highlight names array for tree-sitter configuration.
790pub const fn names() -> [&'static str; HIGHLIGHTS.len()] {
791    let mut names = [""; HIGHLIGHTS.len()];
792    let mut i = 0;
793    while i < HIGHLIGHTS.len() {
794        names[i] = HIGHLIGHTS[i].name;
795        i += 1;
796    }
797    names
798}
799
800/// Total number of highlight categories.
801pub const COUNT: usize = HIGHLIGHTS.len();
802
803/// Get the HTML tag suffix for a highlight index.
804/// Returns None for indices that should produce no styling (like "none" or "nospell").
805#[inline]
806pub fn tag(index: usize) -> Option<&'static str> {
807    HIGHLIGHTS
808        .get(index)
809        .map(|h| h.tag)
810        .filter(|t| !t.is_empty())
811}
812
813/// Get the prefixed HTML tag (e.g., "a-kf") for a highlight index.
814#[inline]
815pub fn prefixed_tag(index: usize) -> Option<String> {
816    tag(index).map(|t| format!("a-{t}"))
817}
818
819/// Get the parent tag for inheritance, if any.
820#[inline]
821pub fn parent_tag(index: usize) -> Option<&'static str> {
822    HIGHLIGHTS
823        .get(index)
824        .map(|h| h.parent_tag)
825        .filter(|t| !t.is_empty())
826}
827
828/// Generate CSS inheritance rules for sub-categories.
829/// Returns rules like "a-kc, a-kf, a-ki { color: inherit; }" grouped by parent.
830pub fn css_inheritance_rules() -> String {
831    use std::collections::HashMap;
832    use std::fmt::Write;
833
834    // Group children by parent
835    let mut parent_children: HashMap<&str, Vec<&str>> = HashMap::new();
836    for def in HIGHLIGHTS {
837        if !def.parent_tag.is_empty() && !def.tag.is_empty() {
838            parent_children
839                .entry(def.parent_tag)
840                .or_default()
841                .push(def.tag);
842        }
843    }
844
845    let mut css = String::new();
846    for (_parent, children) in parent_children {
847        if children.is_empty() {
848            continue;
849        }
850        // Create selector list: a-kc, a-kf, a-ki, ...
851        let selectors: Vec<String> = children.iter().map(|c| format!("a-{c}")).collect();
852        writeln!(css, "{} {{ color: inherit; }}", selectors.join(", ")).unwrap();
853    }
854    css
855}
856
857/// Get the HTML tag for a capture name directly.
858///
859/// This is the main function to use when rendering HTML from captures.
860/// It maps any capture name to its theme slot and returns the tag.
861///
862/// Returns None for captures that should produce no styling.
863///
864/// # Example
865/// ```
866/// use arborium_theme::highlights::tag_for_capture;
867///
868/// // All these map to the keyword slot ("k")
869/// assert_eq!(tag_for_capture("keyword"), Some("k"));
870/// assert_eq!(tag_for_capture("keyword.function"), Some("k"));
871/// assert_eq!(tag_for_capture("include"), Some("k"));
872/// assert_eq!(tag_for_capture("conditional"), Some("k"));
873///
874/// // No styling for these
875/// assert_eq!(tag_for_capture("spell"), None);
876/// assert_eq!(tag_for_capture("nospell"), None);
877/// ```
878pub fn tag_for_capture(capture: &str) -> Option<&'static str> {
879    capture_to_slot(capture).tag()
880}
881
882/// The complete list of capture names that arborium recognizes.
883///
884/// This list is used to configure tree-sitter's highlight query processor.
885/// It includes all standard names plus common alternatives from nvim-treesitter,
886/// helix, and other editors.
887pub const CAPTURE_NAMES: &[&str] = &[
888    // Keywords
889    "keyword",
890    "keyword.conditional",
891    "keyword.coroutine",
892    "keyword.debug",
893    "keyword.exception",
894    "keyword.function",
895    "keyword.import",
896    "keyword.operator",
897    "keyword.repeat",
898    "keyword.return",
899    "keyword.type",
900    "keyword.modifier",
901    "keyword.directive",
902    "keyword.storage",
903    "keyword.control",
904    "keyword.control.conditional",
905    "keyword.control.repeat",
906    "keyword.control.import",
907    "keyword.control.return",
908    "keyword.control.exception",
909    // nvim-treesitter legacy keyword names
910    "include",
911    "conditional",
912    "repeat",
913    "exception",
914    "storageclass",
915    "preproc",
916    "define",
917    "structure",
918    // Functions
919    "function",
920    "function.builtin",
921    "function.method",
922    "function.definition",
923    "function.call",
924    "function.macro",
925    "function.special",
926    "method",
927    "method.call",
928    // Strings
929    "string",
930    "string.special",
931    "string.special.symbol",
932    "string.special.path",
933    "string.special.url",
934    "string.escape",
935    "string.regexp",
936    "string.regex",
937    "character",
938    "character.special",
939    "escape",
940    // Comments
941    "comment",
942    "comment.documentation",
943    "comment.line",
944    "comment.block",
945    "comment.error",
946    "comment.warning",
947    "comment.note",
948    "comment.todo",
949    // Types
950    "type",
951    "type.builtin",
952    "type.qualifier",
953    "type.definition",
954    "type.enum",
955    "type.enum.variant",
956    "type.parameter",
957    // Variables
958    "variable",
959    "variable.builtin",
960    "variable.parameter",
961    "variable.member",
962    "variable.other",
963    "variable.other.member",
964    "parameter",
965    "field",
966    // Constants
967    "constant",
968    "constant.builtin",
969    "constant.builtin.boolean",
970    "constant.numeric",
971    "boolean",
972    // Numbers
973    "number",
974    "float",
975    "number.float",
976    // Operators
977    "operator",
978    // Punctuation
979    "punctuation",
980    "punctuation.bracket",
981    "punctuation.delimiter",
982    "punctuation.special",
983    // Properties
984    "property",
985    "property.builtin",
986    // Attributes
987    "attribute",
988    "attribute.builtin",
989    // Tags
990    "tag",
991    "tag.delimiter",
992    "tag.error",
993    "tag.attribute",
994    "tag.builtin",
995    // Macros
996    "macro",
997    // Labels
998    "label",
999    // Namespaces
1000    "namespace",
1001    "module",
1002    // Constructors
1003    "constructor",
1004    "constructor.builtin",
1005    // Markup - titles
1006    "text.title",
1007    "markup.heading",
1008    "markup.heading.1",
1009    "markup.heading.2",
1010    "markup.heading.3",
1011    "markup.heading.4",
1012    "markup.heading.5",
1013    "markup.heading.6",
1014    // Markup - emphasis
1015    "text.strong",
1016    "markup.bold",
1017    "text.emphasis",
1018    "markup.italic",
1019    // Markup - links
1020    "text.uri",
1021    "text.reference",
1022    "markup.link",
1023    "markup.link.url",
1024    "markup.link.text",
1025    "markup.link.label",
1026    // Markup - code/raw
1027    "text.literal",
1028    "markup.raw",
1029    "markup.raw.block",
1030    "markup.raw.inline",
1031    "markup.inline",
1032    // Markup - strikethrough
1033    "text.strikethrough",
1034    "markup.strikethrough",
1035    // Markup - lists
1036    "markup.list",
1037    "markup.list.checked",
1038    "markup.list.unchecked",
1039    "markup.list.numbered",
1040    "markup.list.unnumbered",
1041    "markup.quote",
1042    // Markup - generic
1043    "text",
1044    "markup",
1045    // Diff
1046    "diff.addition",
1047    "diff.plus",
1048    "diff.delta",
1049    "diff.deletion",
1050    "diff.minus",
1051    // Special
1052    "embedded",
1053    "error",
1054    "none",
1055    "nospell",
1056    "spell",
1057];
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062
1063    #[test]
1064    fn test_names_count() {
1065        assert_eq!(names().len(), COUNT);
1066    }
1067
1068    #[test]
1069    fn test_none_produces_no_tag() {
1070        // Find the "none" index
1071        let none_idx = HIGHLIGHTS.iter().position(|h| h.name == "none").unwrap();
1072        assert_eq!(tag(none_idx), None);
1073    }
1074
1075    #[test]
1076    fn test_keyword_tag() {
1077        let kw_idx = HIGHLIGHTS.iter().position(|h| h.name == "keyword").unwrap();
1078        assert_eq!(tag(kw_idx), Some("k"));
1079        assert_eq!(prefixed_tag(kw_idx), Some("a-k".to_string()));
1080    }
1081
1082    #[test]
1083    fn test_inheritance() {
1084        let kc_idx = HIGHLIGHTS
1085            .iter()
1086            .position(|h| h.name == "keyword.conditional")
1087            .unwrap();
1088        assert_eq!(parent_tag(kc_idx), Some("k"));
1089    }
1090
1091    #[test]
1092    fn test_capture_to_slot_keywords() {
1093        // All keyword variants map to Keyword slot
1094        assert_eq!(capture_to_slot("keyword"), ThemeSlot::Keyword);
1095        assert_eq!(capture_to_slot("keyword.function"), ThemeSlot::Keyword);
1096        assert_eq!(capture_to_slot("keyword.import"), ThemeSlot::Keyword);
1097        assert_eq!(capture_to_slot("include"), ThemeSlot::Keyword);
1098        assert_eq!(capture_to_slot("conditional"), ThemeSlot::Keyword);
1099        assert_eq!(capture_to_slot("repeat"), ThemeSlot::Keyword);
1100        assert_eq!(capture_to_slot("storageclass"), ThemeSlot::Keyword);
1101    }
1102
1103    #[test]
1104    fn test_capture_to_slot_functions() {
1105        assert_eq!(capture_to_slot("function"), ThemeSlot::Function);
1106        assert_eq!(capture_to_slot("function.builtin"), ThemeSlot::Function);
1107        assert_eq!(capture_to_slot("function.method"), ThemeSlot::Function);
1108        assert_eq!(capture_to_slot("method"), ThemeSlot::Function);
1109    }
1110
1111    #[test]
1112    fn test_capture_to_slot_markup() {
1113        assert_eq!(capture_to_slot("markup.heading"), ThemeSlot::Title);
1114        assert_eq!(capture_to_slot("markup.heading.1"), ThemeSlot::Title);
1115        assert_eq!(capture_to_slot("text.title"), ThemeSlot::Title);
1116        assert_eq!(capture_to_slot("markup.bold"), ThemeSlot::Strong);
1117        assert_eq!(capture_to_slot("markup.italic"), ThemeSlot::Emphasis);
1118    }
1119
1120    #[test]
1121    fn test_capture_to_slot_none() {
1122        assert_eq!(capture_to_slot("none"), ThemeSlot::None);
1123        assert_eq!(capture_to_slot("spell"), ThemeSlot::None);
1124        assert_eq!(capture_to_slot("nospell"), ThemeSlot::None);
1125    }
1126
1127    #[test]
1128    fn test_tag_for_capture() {
1129        // Keywords all get "k"
1130        assert_eq!(tag_for_capture("keyword"), Some("k"));
1131        assert_eq!(tag_for_capture("keyword.function"), Some("k"));
1132        assert_eq!(tag_for_capture("include"), Some("k"));
1133        assert_eq!(tag_for_capture("conditional"), Some("k"));
1134
1135        // Functions get "f"
1136        assert_eq!(tag_for_capture("function"), Some("f"));
1137        assert_eq!(tag_for_capture("function.builtin"), Some("f"));
1138
1139        // Comments get "c"
1140        assert_eq!(tag_for_capture("comment"), Some("c"));
1141        assert_eq!(tag_for_capture("comment.documentation"), Some("c"));
1142
1143        // No tag for special captures
1144        assert_eq!(tag_for_capture("spell"), None);
1145        assert_eq!(tag_for_capture("none"), None);
1146    }
1147
1148    #[test]
1149    fn test_theme_slot_tag() {
1150        assert_eq!(ThemeSlot::Keyword.tag(), Some("k"));
1151        assert_eq!(ThemeSlot::Function.tag(), Some("f"));
1152        assert_eq!(ThemeSlot::String.tag(), Some("s"));
1153        assert_eq!(ThemeSlot::Comment.tag(), Some("c"));
1154        assert_eq!(ThemeSlot::None.tag(), None);
1155    }
1156
1157    #[test]
1158    fn test_capture_names_all_map_to_slot() {
1159        // Every name in CAPTURE_NAMES should produce a valid mapping
1160        for name in CAPTURE_NAMES {
1161            let slot = capture_to_slot(name);
1162            // Just verify it doesn't panic and produces some slot
1163            let _ = slot.tag();
1164        }
1165    }
1166}