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