Render code to ANSI, HTML or Hiccup, syntax highlighted the same way Helix does it github.com/waddie/dathan/releases
html hiccup helix-editor css syntax-highlighting
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: handle theme inheritance

Tom Waddington (Jun 15, 2026, 2:46 PM +0100) 490f9c0a 4dc1d689

+421 -14
+1 -1
Cargo.lock
··· 146 146 147 147 [[package]] 148 148 name = "dathan" 149 - version = "0.1.1" 149 + version = "0.2.0" 150 150 dependencies = [ 151 151 "anyhow", 152 152 "clap",
+1 -1
Cargo.toml
··· 1 1 [package] 2 2 name = "dathan" 3 - version = "0.1.1" 3 + version = "0.2.0" 4 4 edition = "2021" 5 5 description = "Highlight source code to HTML or Clojure/EDN Hiccup using Helix's tree-sitter grammars" 6 6
+56
assets/base16_default.toml
··· 1 + # Author: NNB <nnbnh@protonmail.com> 2 + 3 + "ui.menu" = { fg = "black", bg = "white" } 4 + "ui.menu.selected" = { modifiers = ["reversed"] } 5 + "ui.linenr" = { fg = "gray", bg = "black" } 6 + "ui.popup" = { modifiers = ["reversed"] } 7 + "ui.linenr.selected" = { fg = "white", bg = "black", modifiers = ["bold"] } 8 + "ui.selection" = { fg = "black", bg = "blue" } 9 + "ui.selection.primary" = { fg = "white", bg = "blue" } 10 + "ui.text.inactive" = { fg = "gray" } 11 + "comment" = { fg = "gray" } 12 + "ui.statusline" = { fg = "black", bg = "white" } 13 + "ui.statusline.inactive" = { fg = "gray", bg = "white" } 14 + "ui.cursor" = { fg = "white", modifiers = ["reversed"] } 15 + "variable" = "red" 16 + "constant.numeric" = "yellow" 17 + "constant" = "yellow" 18 + "attributes" = "yellow" 19 + "type" = "yellow" 20 + "ui.cursor.match" = { fg = "yellow", modifiers = ["underlined"] } 21 + "string" = "green" 22 + "variable.other.member" = "green" 23 + "constant.character.escape" = "cyan" 24 + "function" = "blue" 25 + "constructor" = "blue" 26 + "special" = "blue" 27 + "keyword" = "magenta" 28 + "label" = "magenta" 29 + "namespace" = "magenta" 30 + "ui.help" = { fg = "white", bg = "black" } 31 + "ui.statusline.insert" = { fg = "black", bg = "green" } 32 + "ui.statusline.select" = { fg = "black", bg = "blue" } 33 + "ui.virtual" = { fg = "gray", modifiers = ["italic"] } 34 + "ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] } 35 + "ui.virtual.ruler" = { bg = "black" } 36 + 37 + "markup.heading" = "blue" 38 + "markup.list" = "red" 39 + "markup.bold" = { fg = "yellow", modifiers = ["bold"] } 40 + "markup.italic" = { fg = "magenta", modifiers = ["italic"] } 41 + "markup.link.url" = { fg = "yellow", modifiers = ["underlined"] } 42 + "markup.link.text" = "red" 43 + "markup.quote" = "cyan" 44 + "markup.raw" = "green" 45 + 46 + "diff.plus" = "green" 47 + "diff.delta" = "yellow" 48 + "diff.minus" = "red" 49 + 50 + "diagnostic" = { modifiers = ["underlined"] } 51 + "ui.gutter" = { bg = "black" } 52 + "info" = "blue" 53 + "hint" = "gray" 54 + "debug" = "gray" 55 + "warning" = "yellow" 56 + "error" = "red"
+114
assets/default.toml
··· 1 + attribute = "lilac" 2 + keyword = "almond" 3 + "keyword.directive" = "lilac" # -- preprocessor comments (#if in C) 4 + namespace = "lilac" 5 + punctuation = "lavender" 6 + "punctuation.delimiter" = "lavender" 7 + operator = "lilac" 8 + special = "honey" 9 + "variable.other.member" = "white" 10 + variable = "lavender" 11 + # variable = "almond" # TODO: metavariables only 12 + # "variable.parameter" = { fg = "lavender", modifiers = ["underlined"] } 13 + "variable.parameter" = { fg = "lavender" } 14 + "variable.builtin" = "mint" 15 + type = "white" 16 + "type.builtin" = "white" # TODO: distinguish? 17 + constructor = "lilac" 18 + function = "white" 19 + "function.macro" = "lilac" 20 + "function.builtin" = "white" 21 + tag = "almond" 22 + comment = "sirocco" 23 + constant = "white" 24 + "constant.builtin" = "white" 25 + string = "silver" 26 + "constant.numeric" = "chamois" 27 + "constant.character.escape" = "honey" 28 + # used for lifetimes 29 + label = "honey" 30 + tabstop = { modifiers = ["italic"], bg = "bossanova" } 31 + 32 + "markup.heading" = "lilac" 33 + "markup.bold" = { modifiers = ["bold"] } 34 + "markup.italic" = { modifiers = ["italic"] } 35 + "markup.strikethrough" = { modifiers = ["crossed_out"] } 36 + "markup.link.url" = { fg = "silver", modifiers = ["underlined"] } 37 + "markup.link.text" = "almond" 38 + "markup.raw" = "almond" 39 + 40 + "diff.plus" = "#35bf86" 41 + "diff.minus" = "#f22c86" 42 + "diff.delta" = "#6f44f0" 43 + 44 + # TODO: differentiate doc comment 45 + # concat (ERROR) @error.syntax and "MISSING ;" selectors for errors 46 + 47 + "ui.background" = { bg = "midnight" } 48 + "ui.background.separator" = { fg = "comet" } 49 + "ui.linenr" = { fg = "comet" } 50 + "ui.linenr.selected" = { fg = "lilac" } 51 + "ui.statusline" = { fg = "lilac", bg = "revolver" } 52 + "ui.statusline.inactive" = { fg = "lavender", bg = "revolver" } 53 + "ui.popup" = { bg = "revolver" } 54 + "ui.window" = { fg = "bossanova" } 55 + "ui.help" = { bg = "#7958DC", fg = "#171452" } 56 + "ui.text" = { fg = "lavender" } 57 + "ui.text.focus" = { fg = "white" } 58 + "ui.text.inactive" = "sirocco" 59 + "ui.text.directory" = { fg = "lilac" } 60 + "ui.virtual" = { fg = "comet" } 61 + "ui.virtual.ruler" = { bg = "bossanova" } 62 + "ui.virtual.jump-label" = { fg = "apricot", modifiers = ["bold"] } 63 + 64 + "ui.virtual.indent-guide" = { fg = "comet" } 65 + 66 + "ui.selection" = { bg = "#540099" } 67 + "ui.selection.primary" = { bg = "#540099" } 68 + # TODO: namespace ui.cursor as ui.selection.cursor? 69 + "ui.cursor.select" = { bg = "delta" } 70 + "ui.cursor.insert" = { bg = "white" } 71 + "ui.cursor.primary.select" = { bg = "delta" } 72 + "ui.cursor.primary.insert" = { bg = "white" } 73 + "ui.cursor.match" = { fg = "#212121", bg = "#6C6999" } 74 + "ui.cursor" = { modifiers = ["reversed"] } 75 + "ui.cursorline.primary" = { bg = "bossanova" } 76 + "ui.highlight" = { bg = "bossanova" } 77 + "ui.highlight.frameline" = { bg = "#634450" } 78 + "ui.debug" = { fg = "#634450" } 79 + "ui.debug.breakpoint" = { fg = "apricot" } 80 + "ui.menu" = { fg = "lavender", bg = "revolver" } 81 + "ui.menu.selected" = { fg = "revolver", bg = "white" } 82 + "ui.menu.scroll" = { fg = "lavender", bg = "comet" } 83 + 84 + "diagnostic.hint" = { underline = { color = "silver", style = "curl" } } 85 + "diagnostic.info" = { underline = { color = "delta", style = "curl" } } 86 + "diagnostic.warning" = { underline = { color = "lightning", style = "curl" } } 87 + "diagnostic.error" = { underline = { color = "apricot", style = "curl" } } 88 + "diagnostic.unnecessary" = { modifiers = ["dim"] } 89 + "diagnostic.deprecated" = { modifiers = ["crossed_out"] } 90 + 91 + warning = "lightning" 92 + error = "apricot" 93 + info = "delta" 94 + hint = "silver" 95 + 96 + [palette] 97 + white = "#ffffff" 98 + lilac = "#dbbfef" 99 + lavender = "#a4a0e8" 100 + comet = "#5a5977" 101 + bossanova = "#452859" 102 + midnight = "#3b224c" 103 + revolver = "#281733" 104 + 105 + silver = "#cccccc" 106 + sirocco = "#697C81" 107 + mint = "#9ff28f" 108 + almond = "#eccdba" 109 + chamois = "#E8DCA0" 110 + honey = "#efba5d" 111 + 112 + apricot = "#f47868" 113 + lightning = "#ffcd1c" 114 + delta = "#6F44F0"
+21 -3
src/main.rs
··· 69 69 70 70 if cli.emit_css { 71 71 let theme_path = resolve_theme(&cli, &rt)?; 72 - let theme_toml = std::fs::read_to_string(&theme_path) 73 - .with_context(|| format!("reading theme {}", theme_path.display()))?; 74 - let css = theme::to_css(&theme_toml)?; 72 + let theme_dirs = theme_dirs(&theme_path, &rt); 73 + let css = theme::load_css(&theme_path, &theme_dirs)?; 75 74 return write_output(cli.output.as_deref(), &css); 76 75 } 77 76 ··· 167 166 candidates.push(root.parent().map(|p| p.join("theme.toml"))); 168 167 } 169 168 first_existing(candidates).ok_or_else(|| anyhow!("no theme.toml found; pass --theme <path>")) 169 + } 170 + 171 + /// Directories to search for parent themes referenced via `inherits`, in 172 + /// priority order: the selected theme's own directory, then each runtime root's 173 + /// `themes/` subdir, then the user config themes dir (mirroring Helix). 174 + fn theme_dirs(theme_path: &Path, rt: &Runtime) -> Vec<PathBuf> { 175 + let mut dirs: Vec<PathBuf> = Vec::new(); 176 + if let Some(parent) = theme_path.parent() { 177 + dirs.push(parent.to_path_buf()); 178 + } 179 + for root in rt.roots() { 180 + dirs.push(root.join("themes")); 181 + } 182 + if let Some(home) = home_dir() { 183 + dirs.push(home.join(".config/helix/themes")); 184 + } 185 + dirs.retain(|p| p.is_dir()); 186 + dirs.dedup(); 187 + dirs 170 188 } 171 189 172 190 fn first_existing<I>(candidates: I) -> Option<PathBuf>
+228 -9
src/theme.rs
··· 3 3 //! Each scope key becomes a rule whose selector matches the most specific class 4 4 //! the HTML backend emits: `keyword.control` -> `.keyword-control`. Palette 5 5 //! names are resolved via the theme's `[palette]` table. 6 + //! 7 + //! Themes may inherit from a parent via `inherits = "<name>"`. The parent is 8 + //! located by name in the theme search directories (or, for `default` / 9 + //! `base16_default`, from data bundled with dathan) and merged following Helix's 10 + //! rules: palette entries override per-key, while a scope defined in the child 11 + //! fully replaces the parent's. 6 12 7 13 use std::collections::HashMap; 14 + use std::collections::HashSet; 15 + use std::path::{Path, PathBuf}; 8 16 9 - use anyhow::{Context, Result}; 17 + use anyhow::{anyhow, Context, Result}; 18 + use toml::value::Table; 10 19 use toml::Value; 11 20 12 - /// Render a `theme.toml` string into a CSS stylesheet. 13 - pub fn to_css(theme_toml: &str) -> Result<String> { 14 - let value: Value = toml::from_str(theme_toml).context("parsing theme.toml")?; 15 - let table = value.as_table().context("theme.toml is not a table")?; 21 + /// Helix's built-in `default` theme, bundled so themes that `inherits = "default"` 22 + /// resolve without a runtime file. 23 + const DEFAULT_THEME: &str = include_str!("../assets/default.toml"); 24 + /// Helix's built-in `base16_default` theme. 25 + const BASE16_DEFAULT_THEME: &str = include_str!("../assets/base16_default.toml"); 16 26 27 + /// Load a theme from `path`, resolving `inherits` against `theme_dirs`, and 28 + /// render the merged result to CSS. 29 + pub fn load_css(path: &Path, theme_dirs: &[PathBuf]) -> Result<String> { 30 + let mut visited = HashSet::new(); 31 + visited.insert(path.to_path_buf()); 32 + let toml = std::fs::read_to_string(path) 33 + .with_context(|| format!("reading theme {}", path.display()))?; 34 + let value: Value = 35 + toml::from_str(&toml).with_context(|| format!("parsing theme {}", path.display()))?; 36 + let merged = resolve(value, theme_dirs, &mut visited)?; 37 + let table = merged.as_table().context("theme is not a table")?.clone(); 38 + Ok(render(&table)) 39 + } 40 + 41 + /// Recursively merge `theme` with its parent (if it declares `inherits`). 42 + fn resolve(theme: Value, theme_dirs: &[PathBuf], visited: &mut HashSet<PathBuf>) -> Result<Value> { 43 + let Some(parent_name) = theme.get("inherits") else { 44 + return Ok(theme); 45 + }; 46 + let parent_name = parent_name 47 + .as_str() 48 + .ok_or_else(|| anyhow!("expected 'inherits' to be a string"))? 49 + .to_string(); 50 + 51 + let parent = match parent_name.as_str() { 52 + "default" => toml::from_str(DEFAULT_THEME).context("parsing bundled default theme")?, 53 + "base16_default" => { 54 + toml::from_str(BASE16_DEFAULT_THEME).context("parsing bundled base16_default theme")? 55 + } 56 + _ => { 57 + let parent_path = find_theme(&parent_name, theme_dirs, visited)?; 58 + visited.insert(parent_path.clone()); 59 + let toml = std::fs::read_to_string(&parent_path) 60 + .with_context(|| format!("reading parent theme {}", parent_path.display()))?; 61 + let value: Value = toml::from_str(&toml) 62 + .with_context(|| format!("parsing parent theme {}", parent_path.display()))?; 63 + resolve(value, theme_dirs, visited)? 64 + } 65 + }; 66 + 67 + Ok(merge_themes(parent, theme)) 68 + } 69 + 70 + /// Locate `<name>.toml` in the search directories, skipping already-visited 71 + /// paths so inheritance cycles surface as an error rather than looping. 72 + fn find_theme(name: &str, theme_dirs: &[PathBuf], visited: &HashSet<PathBuf>) -> Result<PathBuf> { 73 + let filename = format!("{name}.toml"); 74 + let mut cycle = false; 75 + theme_dirs 76 + .iter() 77 + .find_map(|dir| { 78 + let path = dir.join(&filename); 79 + if !path.exists() { 80 + None 81 + } else if visited.contains(&path) { 82 + cycle = true; 83 + None 84 + } else { 85 + Some(path) 86 + } 87 + }) 88 + .ok_or_else(|| { 89 + if cycle { 90 + anyhow!("cycle found inheriting theme: {name}") 91 + } else { 92 + anyhow!("parent theme not found: {name}") 93 + } 94 + }) 95 + } 96 + 97 + /// Merge a child theme into its parent, following Helix's strategy: palette 98 + /// entries merge per-key (depth 2), the rest merges at depth 1 so a scope in the 99 + /// child fully replaces the parent's. 100 + fn merge_themes(parent: Value, child: Value) -> Value { 101 + let parent_palette = parent.get("palette"); 102 + let child_palette = child.get("palette"); 103 + 104 + let palette_values = match (parent_palette, child_palette) { 105 + (Some(p), Some(c)) => merge_toml_values(p.clone(), c.clone(), 2), 106 + (Some(p), None) => p.clone(), 107 + (None, Some(c)) => c.clone(), 108 + (None, None) => Value::Table(Table::new()), 109 + }; 110 + 111 + let mut palette = Table::new(); 112 + palette.insert(String::from("palette"), palette_values); 113 + 114 + let theme = merge_toml_values(parent, child, 1); 115 + merge_toml_values(theme, Value::Table(palette), 1) 116 + } 117 + 118 + /// Recursively merge two TOML values, with `right` taking precedence. Ported 119 + /// from Helix's `helix_loader::merge_toml_values`. 120 + fn merge_toml_values(left: Value, right: Value, merge_depth: usize) -> Value { 121 + fn get_name(v: &Value) -> Option<&str> { 122 + v.get("name").and_then(Value::as_str) 123 + } 124 + 125 + match (left, right) { 126 + (Value::Array(mut left_items), Value::Array(right_items)) => { 127 + if merge_depth > 0 { 128 + left_items.reserve(right_items.len()); 129 + for rvalue in right_items { 130 + let lvalue = get_name(&rvalue) 131 + .and_then(|rname| { 132 + left_items.iter().position(|v| get_name(v) == Some(rname)) 133 + }) 134 + .map(|lpos| left_items.remove(lpos)); 135 + let mvalue = match lvalue { 136 + Some(lvalue) => merge_toml_values(lvalue, rvalue, merge_depth - 1), 137 + None => rvalue, 138 + }; 139 + left_items.push(mvalue); 140 + } 141 + Value::Array(left_items) 142 + } else { 143 + Value::Array(right_items) 144 + } 145 + } 146 + (Value::Table(mut left_map), Value::Table(right_map)) => { 147 + if merge_depth > 0 { 148 + for (rname, rvalue) in right_map { 149 + match left_map.remove(&rname) { 150 + Some(lvalue) => { 151 + let merged_value = merge_toml_values(lvalue, rvalue, merge_depth - 1); 152 + left_map.insert(rname, merged_value); 153 + } 154 + None => { 155 + left_map.insert(rname, rvalue); 156 + } 157 + } 158 + } 159 + Value::Table(left_map) 160 + } else { 161 + Value::Table(right_map) 162 + } 163 + } 164 + (_, value) => value, 165 + } 166 + } 167 + 168 + /// Render a merged theme table to a CSS stylesheet. 169 + fn render(table: &Table) -> String { 17 170 let palette: HashMap<String, String> = table 18 171 .get("palette") 19 172 .and_then(Value::as_table) ··· 79 232 )); 80 233 } 81 234 82 - Ok(css) 235 + css 83 236 } 84 237 85 238 /// `keyword.control` -> `keyword-control` (matches the HTML backend's most ··· 92 245 mod tests { 93 246 use super::*; 94 247 248 + fn parse(s: &str) -> Value { 249 + toml::from_str(s).unwrap() 250 + } 251 + 95 252 #[test] 96 253 fn palette_and_modifiers() { 97 - let theme = r##" 254 + let theme = parse( 255 + r##" 98 256 "keyword.control" = { fg = "blue", modifiers = ["bold"] } 99 257 comment = "gray" 100 258 [palette] 101 259 blue = "#0000ff" 102 260 gray = "#808080" 103 - "##; 104 - let css = to_css(theme).unwrap(); 261 + "##, 262 + ); 263 + let css = render(theme.as_table().unwrap()); 105 264 assert!(css.contains(".keyword-control { color: #0000ff; font-weight: bold; }")); 106 265 assert!(css.contains(".comment { color: #808080; }")); 266 + } 267 + 268 + #[test] 269 + fn palette_overrides_per_key() { 270 + let parent = parse( 271 + r##" 272 + keyword = { fg = "blue" } 273 + comment = { fg = "gray" } 274 + [palette] 275 + blue = "#0000ff" 276 + gray = "#808080" 277 + "##, 278 + ); 279 + let child = parse( 280 + r##" 281 + inherits = "parent" 282 + [palette] 283 + blue = "#1111ff" 284 + "##, 285 + ); 286 + let merged = merge_themes(parent, child); 287 + let css = render(merged.as_table().unwrap()); 288 + // child overrode `blue`... 289 + assert!(css.contains(".keyword { color: #1111ff; }")); 290 + // ...but the parent's `gray` is retained. 291 + assert!(css.contains(".comment { color: #808080; }")); 292 + } 293 + 294 + #[test] 295 + fn child_scope_replaces_parent_and_parent_only_kept() { 296 + let parent = parse( 297 + r##" 298 + keyword = { fg = "#000000", modifiers = ["bold"] } 299 + comment = { fg = "#808080" } 300 + "##, 301 + ); 302 + let child = parse( 303 + r##" 304 + inherits = "parent" 305 + keyword = { fg = "#ff0000" } 306 + "##, 307 + ); 308 + let merged = merge_themes(parent, child); 309 + let css = render(merged.as_table().unwrap()); 310 + // child's keyword fully replaces parent's (no leftover bold). 311 + assert!(css.contains(".keyword { color: #ff0000; }")); 312 + assert!(!css.contains("font-weight: bold")); 313 + // parent-only scope retained. 314 + assert!(css.contains(".comment { color: #808080; }")); 315 + } 316 + 317 + #[test] 318 + fn cycle_is_detected() { 319 + let dir = std::env::temp_dir().join(format!("dathan-cycle-{}", std::process::id())); 320 + std::fs::create_dir_all(&dir).unwrap(); 321 + std::fs::write(dir.join("a.toml"), "inherits = \"b\"\n").unwrap(); 322 + std::fs::write(dir.join("b.toml"), "inherits = \"a\"\n").unwrap(); 323 + let err = load_css(&dir.join("a.toml"), std::slice::from_ref(&dir)).unwrap_err(); 324 + assert!(err.to_string().contains("cycle"), "{err}"); 325 + std::fs::remove_dir_all(&dir).ok(); 107 326 } 108 327 }