···11+[package]
22+name = "dathan"
33+version = "0.1.0"
44+edition = "2021"
55+description = "Highlight source code to HTML or Clojure/EDN Hiccup using Helix's tree-sitter grammars"
66+77+[dependencies]
88+tree-house = { version = "0.3.0", default-features = false }
99+ropey = "1.6"
1010+toml = "0.8"
1111+serde = { version = "1.0", features = ["derive"] }
1212+regex = "1"
1313+clap = { version = "4", features = ["derive"] }
1414+anyhow = "1"
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Tom Waddington
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
···11+//! Clojure/EDN Hiccup backend.
22+//!
33+//! Emits `[:pre [:code [:span {:class "keyword keyword-control"} "if"] " " ...]]`.
44+//! The `:class` value uses the same space-separated hyphenated hierarchical
55+//! classes as the HTML backend, so naming is consistent across formats. Spans
66+//! nest, so we build a stack of frames and render each on `close`.
77+88+use super::{classes, Backend};
99+1010+struct Frame {
1111+ /// `None` for the implicit root (the `[:code ...]` children).
1212+ class: Option<String>,
1313+ children: Vec<String>,
1414+}
1515+1616+pub struct EdnHiccupBackend {
1717+ stack: Vec<Frame>,
1818+}
1919+2020+impl EdnHiccupBackend {
2121+ pub fn new() -> Self {
2222+ Self {
2323+ stack: vec![Frame {
2424+ class: None,
2525+ children: Vec::new(),
2626+ }],
2727+ }
2828+ }
2929+}
3030+3131+impl Backend for EdnHiccupBackend {
3232+ fn open(&mut self, scope: &str) {
3333+ self.stack.push(Frame {
3434+ class: Some(scope.to_string()),
3535+ children: Vec::new(),
3636+ });
3737+ }
3838+3939+ fn text(&mut self, text: &str) {
4040+ // The driver coalesces runs of equal scope, but tree-sitter can still
4141+ // split text at injection/event boundaries; merge adjacent literals.
4242+ let literal = edn_string(text);
4343+ if let Some(frame) = self.stack.last_mut() {
4444+ frame.children.push(literal);
4545+ }
4646+ }
4747+4848+ fn close(&mut self) {
4949+ let frame = self.stack.pop().expect("close without matching open");
5050+ let rendered = render(&frame);
5151+ if let Some(parent) = self.stack.last_mut() {
5252+ parent.children.push(rendered);
5353+ }
5454+ }
5555+5656+ fn finish(self: Box<Self>) -> String {
5757+ let root = &self.stack[0];
5858+ let mut out = String::from("[:pre [:code");
5959+ for child in &root.children {
6060+ out.push(' ');
6161+ out.push_str(child);
6262+ }
6363+ out.push_str("]]\n");
6464+ out
6565+ }
6666+}
6767+6868+fn render(frame: &Frame) -> String {
6969+ let scope = frame.class.as_deref().unwrap_or_default();
7070+ let mut out = format!("[:span {{:class {}}}", edn_string(&classes(scope)));
7171+ for child in &frame.children {
7272+ out.push(' ');
7373+ out.push_str(child);
7474+ }
7575+ out.push(']');
7676+ out
7777+}
7878+7979+/// Render a Rust string as an EDN string literal.
8080+fn edn_string(text: &str) -> String {
8181+ let mut out = String::with_capacity(text.len() + 2);
8282+ out.push('"');
8383+ for ch in text.chars() {
8484+ match ch {
8585+ '\\' => out.push_str("\\\\"),
8686+ '"' => out.push_str("\\\""),
8787+ '\n' => out.push_str("\\n"),
8888+ '\r' => out.push_str("\\r"),
8989+ '\t' => out.push_str("\\t"),
9090+ _ => out.push(ch),
9191+ }
9292+ }
9393+ out.push('"');
9494+ out
9595+}
9696+9797+#[cfg(test)]
9898+mod tests {
9999+ use super::*;
100100+101101+ #[test]
102102+ fn nested_hiccup_and_edn_escaping() {
103103+ let mut b = Box::new(EdnHiccupBackend::new());
104104+ b.open("keyword.function");
105105+ b.text("fn");
106106+ b.close();
107107+ b.text("\t\"x\"\n");
108108+ let out = b.finish();
109109+ assert_eq!(
110110+ out,
111111+ "[:pre [:code [:span {:class \"keyword keyword-function\"} \"fn\"] \"\\t\\\"x\\\"\\n\"]]\n"
112112+ );
113113+ }
114114+}
+73
src/backend/html.rs
···11+//! HTML backend: `<pre><code>` with `<span class="...">` per scope.
22+//!
33+//! A dotted scope is emitted as space-separated hierarchical classes so CSS can
44+//! target any level: `keyword.control.conditional` becomes
55+//! `class="keyword keyword-control keyword-control-conditional"`.
66+77+use super::{classes, Backend};
88+99+pub struct HtmlBackend {
1010+ out: String,
1111+}
1212+1313+impl HtmlBackend {
1414+ pub fn new() -> Self {
1515+ let mut out = String::from("<pre><code class=\"dathan\">");
1616+ out.reserve(4096);
1717+ Self { out }
1818+ }
1919+}
2020+2121+impl Backend for HtmlBackend {
2222+ fn open(&mut self, scope: &str) {
2323+ self.out.push_str("<span class=\"");
2424+ self.out.push_str(&classes(scope));
2525+ self.out.push_str("\">");
2626+ }
2727+2828+ fn text(&mut self, text: &str) {
2929+ escape_into(text, &mut self.out);
3030+ }
3131+3232+ fn close(&mut self) {
3333+ self.out.push_str("</span>");
3434+ }
3535+3636+ fn finish(self: Box<Self>) -> String {
3737+ let mut out = self.out;
3838+ out.push_str("</code></pre>\n");
3939+ out
4040+ }
4141+}
4242+4343+fn escape_into(text: &str, out: &mut String) {
4444+ for ch in text.chars() {
4545+ match ch {
4646+ '&' => out.push_str("&"),
4747+ '<' => out.push_str("<"),
4848+ '>' => out.push_str(">"),
4949+ '"' => out.push_str("""),
5050+ _ => out.push(ch),
5151+ }
5252+ }
5353+}
5454+5555+#[cfg(test)]
5656+mod tests {
5757+ use super::*;
5858+5959+ #[test]
6060+ fn nested_spans_and_escaping() {
6161+ let mut b = Box::new(HtmlBackend::new());
6262+ b.open("keyword.control");
6363+ b.text("if x < 1 && \"q\"");
6464+ b.close();
6565+ let out = b.finish();
6666+ assert_eq!(
6767+ out,
6868+ "<pre><code class=\"dathan\">\
6969+ <span class=\"keyword keyword-control\">if x < 1 && "q"</span>\
7070+ </code></pre>\n"
7171+ );
7272+ }
7373+}
+48
src/backend/mod.rs
···11+//! Pluggable output backends.
22+33+mod hiccup;
44+mod html;
55+66+pub use hiccup::EdnHiccupBackend;
77+pub use html::HtmlBackend;
88+99+/// A streaming sink for highlight events. `open`/`close` bracket a highlighted
1010+/// span (spans nest); `text` receives a raw source slice the backend must
1111+/// escape for its format.
1212+pub trait Backend {
1313+ /// Open a span for the given dotted scope (e.g. `keyword.control`).
1414+ fn open(&mut self, scope: &str);
1515+ /// Append a raw (unescaped) source slice.
1616+ fn text(&mut self, text: &str);
1717+ /// Close the most recently opened span.
1818+ fn close(&mut self);
1919+ /// Consume the backend and produce the finished document.
2020+ fn finish(self: Box<Self>) -> String;
2121+}
2222+2323+/// Convert a dotted scope into space-separated hierarchical classes, shared by
2424+/// every backend (and matched by the CSS emitter) so naming is consistent:
2525+/// `keyword.control.conditional` ->
2626+/// `keyword keyword-control keyword-control-conditional`.
2727+pub(crate) fn classes(scope: &str) -> String {
2828+ let parts: Vec<&str> = scope.split('.').collect();
2929+ let mut classes = Vec::with_capacity(parts.len());
3030+ for i in 1..=parts.len() {
3131+ classes.push(parts[..i].join("-"));
3232+ }
3333+ classes.join(" ")
3434+}
3535+3636+#[cfg(test)]
3737+mod tests {
3838+ use super::*;
3939+4040+ #[test]
4141+ fn hierarchical_classes() {
4242+ assert_eq!(classes("keyword"), "keyword");
4343+ assert_eq!(
4444+ classes("keyword.control.conditional"),
4545+ "keyword keyword-control keyword-control-conditional"
4646+ );
4747+ }
4848+}
+29
src/grammar.rs
···11+//! Grammar loading.
22+//!
33+//! Thin wrapper over `tree_house::tree_sitter::Grammar::new`, which dlopens the
44+//! precompiled shared library, resolves the `tree_sitter_<name>` symbol, leaks
55+//! the library so the grammar outlives it, and verifies the ABI version
66+//! (13..=15) — exactly how Helix loads grammars.
77+88+use std::path::PathBuf;
99+1010+use anyhow::{anyhow, Context, Result};
1111+use tree_house::tree_sitter::Grammar;
1212+1313+use crate::runtime::{Runtime, DYLIB_EXT};
1414+1515+/// Load the grammar named `name` from the first runtime root that provides it.
1616+pub fn load(name: &str, rt: &Runtime) -> Result<Grammar> {
1717+ let mut rel = PathBuf::from("grammars");
1818+ rel.push(name);
1919+ rel.set_extension(DYLIB_EXT);
2020+2121+ let path = rt.find_file(&rel).ok_or_else(|| {
2222+ anyhow!("no compiled grammar for '{name}' (looked for grammars/{name}.{DYLIB_EXT})")
2323+ })?;
2424+2525+ // SAFETY: the file is a Helix-compiled tree-sitter grammar exporting the
2626+ // expected `tree_sitter_<name>` constructor; `Grammar::new` checks the ABI.
2727+ unsafe { Grammar::new(name, &path) }
2828+ .with_context(|| format!("loading grammar '{name}' from {}", path.display()))
2929+}
+98
src/highlight.rs
···11+//! Highlight driver.
22+//!
33+//! Drives `tree_house`'s `Highlighter`, which yields, at each event offset, the
44+//! full stack of active highlights (outermost first) for the following text
55+//! span. We diff successive stacks into nested open/close span events and feed
66+//! them to a `Backend`, emitting properly nested markup.
77+88+use std::time::Duration;
99+1010+use anyhow::{anyhow, Result};
1111+use ropey::RopeSlice;
1212+use tree_house::highlighter::{Highlight, HighlightEvent, Highlighter};
1313+use tree_house::{Language, Syntax};
1414+1515+use crate::backend::Backend;
1616+use crate::languages::Loader;
1717+1818+const PARSE_TIMEOUT: Duration = Duration::from_secs(15);
1919+2020+/// Highlight `source` as `lang`, driving `backend` with open/text/close calls.
2121+pub fn highlight(
2222+ loader: &Loader,
2323+ lang: Language,
2424+ source: &str,
2525+ backend: &mut dyn Backend,
2626+) -> Result<()> {
2727+ let rope = RopeSlice::from(source);
2828+ let len = source.len() as u32;
2929+3030+ let syntax = Syntax::new(rope, lang, PARSE_TIMEOUT, loader)
3131+ .map_err(|e| anyhow!("failed to parse source: {e:?}"))?;
3232+ let mut highlighter = Highlighter::new(&syntax, rope, loader, ..);
3333+3434+ // Mirror of the highlighter's active stack (outermost first).
3535+ let mut stack: Vec<Highlight> = Vec::new();
3636+ // Spans currently open in the backend output.
3737+ let mut open: Vec<Highlight> = Vec::new();
3838+3939+ let mut pos = 0u32;
4040+ while pos < len {
4141+ if pos == highlighter.next_event_offset() {
4242+ let (event, new_highlights) = highlighter.advance();
4343+ if event == HighlightEvent::Refresh {
4444+ stack.clear();
4545+ }
4646+ stack.extend(new_highlights);
4747+ }
4848+4949+ let start = pos;
5050+ let next = highlighter.next_event_offset();
5151+ pos = if next == u32::MAX || next > len {
5252+ len
5353+ } else {
5454+ next
5555+ };
5656+5757+ if pos <= start {
5858+ if pos >= len {
5959+ break;
6060+ }
6161+ // Zero-width region: loop to drain further events at this offset.
6262+ continue;
6363+ }
6464+6565+ sync_spans(&mut open, &stack, loader, backend);
6666+ backend.text(&source[start as usize..pos as usize]);
6767+ }
6868+6969+ while open.pop().is_some() {
7070+ backend.close();
7171+ }
7272+ Ok(())
7373+}
7474+7575+/// Reconcile the open spans with the desired stack: keep the common prefix,
7676+/// close the rest, then open the new tail.
7777+fn sync_spans(
7878+ open: &mut Vec<Highlight>,
7979+ stack: &[Highlight],
8080+ loader: &Loader,
8181+ backend: &mut dyn Backend,
8282+) {
8383+ let common = open
8484+ .iter()
8585+ .zip(stack.iter())
8686+ .take_while(|(a, b)| a == b)
8787+ .count();
8888+8989+ for _ in common..open.len() {
9090+ backend.close();
9191+ }
9292+ open.truncate(common);
9393+9494+ for &highlight in &stack[common..] {
9595+ backend.open(&loader.scope_name(highlight));
9696+ open.push(highlight);
9797+ }
9898+}
+368
src/languages.rs
···11+//! Language registry and the `tree_house::LanguageLoader` implementation.
22+//!
33+//! Parses Helix's `languages.toml` into a registry, detects a file's language,
44+//! resolves injection markers, and lazily compiles a `LanguageConfig` per
55+//! language. Compiled configs are `configure`d against a single global,
66+//! append-only list of recognized scope names so a `Highlight` index maps back
77+//! to the exact dotted capture name (e.g. `keyword.control.conditional`).
88+99+use std::cell::{OnceCell, RefCell};
1010+use std::collections::HashMap;
1111+use std::path::Path;
1212+1313+use anyhow::{Context, Result};
1414+use regex::Regex;
1515+use ropey::RopeSlice;
1616+use serde::Deserialize;
1717+use toml::Value;
1818+use tree_house::highlighter::Highlight;
1919+use tree_house::{InjectionLanguageMarker, Language, LanguageConfig, LanguageLoader};
2020+2121+use crate::grammar;
2222+use crate::queries::read_query;
2323+use crate::runtime::Runtime;
2424+2525+#[derive(Debug, Deserialize)]
2626+struct RawConfig {
2727+ #[serde(default)]
2828+ language: Vec<RawLang>,
2929+}
3030+3131+#[derive(Debug, Deserialize)]
3232+struct RawLang {
3333+ name: String,
3434+ #[serde(default)]
3535+ grammar: Option<String>,
3636+ #[serde(default, rename = "injection-regex")]
3737+ injection_regex: Option<String>,
3838+ #[serde(default, rename = "file-types")]
3939+ file_types: Vec<FileType>,
4040+ #[serde(default)]
4141+ shebangs: Vec<String>,
4242+}
4343+4444+/// A `file-types` entry: either a bare extension or a `{ glob = .. }` /
4545+/// `{ suffix = .. }` table.
4646+#[derive(Debug, Deserialize)]
4747+#[serde(untagged)]
4848+enum FileType {
4949+ Extension(String),
5050+ Special(HashMap<String, String>),
5151+}
5252+5353+/// Processed per-language data.
5454+struct LangData {
5555+ name: String,
5656+ grammar: String,
5757+ injection_regex: Option<Regex>,
5858+ file_types: Vec<FileType>,
5959+}
6060+6161+pub struct Loader {
6262+ rt: Runtime,
6363+ langs: Vec<LangData>,
6464+ configs: Vec<OnceCell<Option<LanguageConfig>>>,
6565+ by_extension: HashMap<String, Language>,
6666+ by_name: HashMap<String, Language>,
6767+ by_shebang: HashMap<String, Language>,
6868+ /// Global, append-only list of recognized scope names. The index of a name
6969+ /// is the `Highlight` value reported for captures of that name.
7070+ recognized: RefCell<Vec<String>>,
7171+}
7272+7373+impl Loader {
7474+ /// Build a registry from an already-merged `languages.toml` value.
7575+ pub fn new(rt: Runtime, config: Value) -> Result<Self> {
7676+ let raw: RawConfig = config.try_into().context("interpreting languages.toml")?;
7777+7878+ let mut langs = Vec::with_capacity(raw.language.len());
7979+ let mut by_extension = HashMap::new();
8080+ let mut by_name = HashMap::new();
8181+ let mut by_shebang = HashMap::new();
8282+8383+ for (i, l) in raw.language.into_iter().enumerate() {
8484+ let lang = Language::new(i as u32);
8585+ by_name.insert(l.name.clone(), lang);
8686+8787+ for ft in &l.file_types {
8888+ if let FileType::Extension(ext) = ft {
8989+ by_extension.insert(ext.clone(), lang);
9090+ }
9191+ }
9292+ for sb in &l.shebangs {
9393+ by_shebang.insert(sb.clone(), lang);
9494+ }
9595+9696+ let injection_regex = l.injection_regex.as_deref().and_then(|s| {
9797+ Regex::new(s)
9898+ .map_err(|e| eprintln!("dathan: bad injection-regex for '{}': {e}", l.name))
9999+ .ok()
100100+ });
101101+102102+ langs.push(LangData {
103103+ grammar: l.grammar.unwrap_or_else(|| l.name.clone()),
104104+ name: l.name,
105105+ injection_regex,
106106+ file_types: l.file_types,
107107+ });
108108+ }
109109+110110+ let configs = (0..langs.len()).map(|_| OnceCell::new()).collect();
111111+112112+ Ok(Self {
113113+ rt,
114114+ langs,
115115+ configs,
116116+ by_extension,
117117+ by_name,
118118+ by_shebang,
119119+ recognized: RefCell::new(Vec::new()),
120120+ })
121121+ }
122122+123123+ /// Resolve a `Highlight` back to its dotted scope name.
124124+ pub fn scope_name(&self, highlight: Highlight) -> String {
125125+ self.recognized
126126+ .borrow()
127127+ .get(highlight.idx())
128128+ .cloned()
129129+ .unwrap_or_default()
130130+ }
131131+132132+ /// Intern a capture name into the global recognized list (append-only) and
133133+ /// return its `Highlight` index.
134134+ fn intern(&self, name: &str) -> Highlight {
135135+ let mut rec = self.recognized.borrow_mut();
136136+ let idx = rec.iter().position(|n| n == name).unwrap_or_else(|| {
137137+ rec.push(name.to_string());
138138+ rec.len() - 1
139139+ });
140140+ Highlight::new(idx as u32)
141141+ }
142142+143143+ /// Compile grammar + queries into a configured `LanguageConfig`.
144144+ fn compile(&self, lang: Language) -> Option<LanguageConfig> {
145145+ let data = &self.langs[lang.idx()];
146146+147147+ let grammar = match grammar::load(&data.grammar, &self.rt) {
148148+ Ok(g) => g,
149149+ Err(e) => {
150150+ eprintln!("dathan: {e:#}");
151151+ return None;
152152+ }
153153+ };
154154+155155+ let highlights = read_query(&self.rt, &data.name, "highlights.scm");
156156+ let injections = read_query(&self.rt, &data.name, "injections.scm");
157157+ let locals = read_query(&self.rt, &data.name, "locals.scm");
158158+159159+ let config = match LanguageConfig::new(grammar, &highlights, &injections, &locals) {
160160+ Ok(c) => c,
161161+ Err(e) => {
162162+ eprintln!("dathan: failed to compile queries for '{}': {e}", data.name);
163163+ return None;
164164+ }
165165+ };
166166+167167+ config.configure(|name| Some(self.intern(name)));
168168+ Some(config)
169169+ }
170170+171171+ pub fn language_for_name(&self, name: &str) -> Option<Language> {
172172+ self.by_name.get(name).copied()
173173+ }
174174+175175+ /// Detect by extension, then by glob/suffix `file-types` entries.
176176+ pub fn language_for_filename(&self, path: &Path) -> Option<Language> {
177177+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
178178+ if let Some(&lang) = self.by_extension.get(ext) {
179179+ return Some(lang);
180180+ }
181181+ }
182182+ let name = path
183183+ .file_name()
184184+ .and_then(|f| f.to_str())
185185+ .unwrap_or_default();
186186+ for (i, data) in self.langs.iter().enumerate() {
187187+ for ft in &data.file_types {
188188+ if let FileType::Special(map) = ft {
189189+ let hit = map.get("glob").is_some_and(|g| glob_match(g, name))
190190+ || map
191191+ .get("suffix")
192192+ .is_some_and(|s| name.ends_with(s.as_str()));
193193+ if hit {
194194+ return Some(Language::new(i as u32));
195195+ }
196196+ }
197197+ }
198198+ }
199199+ None
200200+ }
201201+202202+ /// Resolve an injected-language token: exact name first, then the longest
203203+ /// `injection-regex` match (mirrors Helix's `language_for_match`).
204204+ pub fn language_for_match(&self, text: &str) -> Option<Language> {
205205+ if let Some(lang) = self.language_for_name(text) {
206206+ return Some(lang);
207207+ }
208208+ let mut best_len = 0;
209209+ let mut best = None;
210210+ for (i, data) in self.langs.iter().enumerate() {
211211+ if let Some(re) = &data.injection_regex {
212212+ if let Some(m) = re.find(text) {
213213+ let len = m.end() - m.start();
214214+ if len > best_len {
215215+ best_len = len;
216216+ best = Some(Language::new(i as u32));
217217+ }
218218+ }
219219+ }
220220+ }
221221+ best
222222+ }
223223+224224+ /// Resolve a language from a `#!` shebang line.
225225+ pub fn language_for_shebang(&self, line: &str) -> Option<Language> {
226226+ let rest = line.strip_prefix("#!")?.trim_start();
227227+ let mut tokens = rest.split_whitespace();
228228+ let first = tokens.next()?;
229229+ let interpreter = if first.rsplit(['/', '\\']).next() == Some("env") {
230230+ tokens.next()?
231231+ } else {
232232+ first
233233+ };
234234+ let name = interpreter.rsplit(['/', '\\']).next()?;
235235+ self.by_shebang.get(name).copied()
236236+ }
237237+}
238238+239239+impl LanguageLoader for Loader {
240240+ fn language_for_marker(&self, marker: InjectionLanguageMarker) -> Option<Language> {
241241+ match marker {
242242+ InjectionLanguageMarker::Name(name) => self.language_for_name(name),
243243+ InjectionLanguageMarker::Match(text) => self.language_for_match(&slice_to_string(text)),
244244+ InjectionLanguageMarker::Filename(text) => {
245245+ self.language_for_filename(Path::new(&slice_to_string(text)))
246246+ }
247247+ InjectionLanguageMarker::Shebang(text) => {
248248+ let token = slice_to_string(text);
249249+ self.by_shebang.get(&token).copied()
250250+ }
251251+ }
252252+ }
253253+254254+ fn get_config(&self, lang: Language) -> Option<&LanguageConfig> {
255255+ self.configs[lang.idx()]
256256+ .get_or_init(|| self.compile(lang))
257257+ .as_ref()
258258+ }
259259+}
260260+261261+fn slice_to_string(slice: RopeSlice) -> String {
262262+ String::from(slice)
263263+}
264264+265265+/// Minimal glob: supports a leading `*` wildcard (suffix match) or an exact
266266+/// filename match. Sufficient for the `file-types` globs Helix ships.
267267+fn glob_match(pattern: &str, name: &str) -> bool {
268268+ match pattern.strip_prefix('*') {
269269+ Some(suffix) => name.ends_with(suffix),
270270+ None => pattern == name,
271271+ }
272272+}
273273+274274+/// Deep-merge an `overlay` `languages.toml` onto a `base`, the way Helix merges
275275+/// the user config over the default: `[[language]]`/`[[grammar]]` arrays are
276276+/// merged by `name` (overlay entries override/extend matching base entries and
277277+/// append new ones); other keys are overridden by the overlay.
278278+pub fn merge_configs(base: &str, overlay: Option<&str>) -> Result<Value> {
279279+ let base: Value = toml::from_str(base).context("parsing base languages.toml")?;
280280+ match overlay {
281281+ None => Ok(base),
282282+ Some(overlay) => {
283283+ let overlay: Value = toml::from_str(overlay).context("parsing user languages.toml")?;
284284+ Ok(merge_values(base, overlay))
285285+ }
286286+ }
287287+}
288288+289289+fn merge_values(base: Value, overlay: Value) -> Value {
290290+ match (base, overlay) {
291291+ (Value::Table(mut base), Value::Table(overlay)) => {
292292+ for (key, ov) in overlay {
293293+ let merged = match base.remove(&key) {
294294+ Some(bv) if key == "language" || key == "grammar" => merge_named_array(bv, ov),
295295+ Some(bv) => merge_values(bv, ov),
296296+ None => ov,
297297+ };
298298+ base.insert(key, merged);
299299+ }
300300+ Value::Table(base)
301301+ }
302302+ // Scalars and arrays without a merge key: the overlay wins.
303303+ (_, overlay) => overlay,
304304+ }
305305+}
306306+307307+/// Merge two arrays of tables keyed by their `name` field.
308308+fn merge_named_array(base: Value, overlay: Value) -> Value {
309309+ match (base, overlay) {
310310+ (Value::Array(mut base), Value::Array(overlay)) => {
311311+ for item in overlay {
312312+ let pos = item.get("name").and_then(Value::as_str).and_then(|name| {
313313+ base.iter()
314314+ .position(|b| b.get("name").and_then(Value::as_str) == Some(name))
315315+ });
316316+ match pos {
317317+ Some(i) => base[i] = merge_values(base[i].clone(), item),
318318+ None => base.push(item),
319319+ }
320320+ }
321321+ Value::Array(base)
322322+ }
323323+ (_, overlay) => overlay,
324324+ }
325325+}
326326+327327+#[cfg(test)]
328328+mod tests {
329329+ use super::*;
330330+331331+ #[test]
332332+ fn glob_matching() {
333333+ assert!(glob_match("*.toml", "Cargo.toml"));
334334+ assert!(glob_match("Makefile", "Makefile"));
335335+ assert!(!glob_match("Makefile", "makefile"));
336336+ assert!(!glob_match("*.rs", "main.py"));
337337+ }
338338+339339+ #[test]
340340+ fn merge_overrides_by_name_and_appends() {
341341+ let base = r#"
342342+[[language]]
343343+name = "rust"
344344+grammar = "rust"
345345+scope = "source.rust"
346346+"#;
347347+ let user = r#"
348348+[[language]]
349349+name = "rust"
350350+grammar = "rust-custom"
351351+352352+[[language]]
353353+name = "quipu"
354354+grammar = "quipu"
355355+"#;
356356+ let merged = merge_configs(base, Some(user)).unwrap();
357357+ let langs = merged["language"].as_array().unwrap();
358358+ assert_eq!(langs.len(), 2, "override merges, new language appends");
359359+360360+ let rust = &langs[0];
361361+ // overlay wins for overlapping keys...
362362+ assert_eq!(rust["grammar"].as_str(), Some("rust-custom"));
363363+ // ...but base-only keys are preserved.
364364+ assert_eq!(rust["scope"].as_str(), Some("source.rust"));
365365+366366+ assert_eq!(langs[1]["name"].as_str(), Some("quipu"));
367367+ }
368368+}
+179
src/main.rs
···11+//! dathan — highlight source code to HTML or Clojure/EDN Hiccup using Helix's
22+//! tree-sitter grammars and queries.
33+44+mod backend;
55+mod grammar;
66+mod highlight;
77+mod languages;
88+mod queries;
99+mod runtime;
1010+mod theme;
1111+1212+use std::path::{Path, PathBuf};
1313+1414+use anyhow::{anyhow, Context, Result};
1515+use clap::{Parser, ValueEnum};
1616+1717+use backend::{Backend, EdnHiccupBackend, HtmlBackend};
1818+use languages::Loader;
1919+use runtime::{home_dir, Runtime};
2020+2121+#[derive(Clone, Copy, ValueEnum)]
2222+enum Format {
2323+ Html,
2424+ EdnHiccup,
2525+}
2626+2727+#[derive(Parser)]
2828+#[command(
2929+ name = "dathan",
3030+ about = "Highlight code to HTML or Clojure/EDN Hiccup via Helix grammars"
3131+)]
3232+struct Cli {
3333+ /// Source file to highlight (omit only with --emit-css).
3434+ file: Option<PathBuf>,
3535+3636+ /// Output format.
3737+ #[arg(long, value_enum, default_value = "edn-hiccup")]
3838+ format: Format,
3939+4040+ /// Override detected language by name (e.g. `rust`).
4141+ #[arg(long)]
4242+ lang: Option<String>,
4343+4444+ /// Extra runtime root(s); highest priority. May be repeated.
4545+ #[arg(long)]
4646+ runtime: Vec<PathBuf>,
4747+4848+ /// Path to a languages.toml (defaults to the Helix source/user config).
4949+ #[arg(long)]
5050+ languages: Option<PathBuf>,
5151+5252+ /// theme.toml to use for --emit-css.
5353+ #[arg(long)]
5454+ theme: Option<PathBuf>,
5555+5656+ /// Emit a CSS stylesheet from the theme and exit (ignores FILE).
5757+ #[arg(long)]
5858+ emit_css: bool,
5959+6060+ /// Output file (default: stdout).
6161+ #[arg(short, long)]
6262+ output: Option<PathBuf>,
6363+}
6464+6565+fn main() -> Result<()> {
6666+ let cli = Cli::parse();
6767+ let rt = Runtime::new(&cli.runtime);
6868+6969+ if cli.emit_css {
7070+ let theme_path = resolve_theme(&cli, &rt)?;
7171+ let theme_toml = std::fs::read_to_string(&theme_path)
7272+ .with_context(|| format!("reading theme {}", theme_path.display()))?;
7373+ let css = theme::to_css(&theme_toml)?;
7474+ return write_output(cli.output.as_deref(), &css);
7575+ }
7676+7777+ let file = cli
7878+ .file
7979+ .as_deref()
8080+ .ok_or_else(|| anyhow!("a FILE argument is required (or use --emit-css)"))?;
8181+ let source =
8282+ std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
8383+8484+ let merged = load_languages(&cli)?;
8585+ let loader = Loader::new(rt, merged)?;
8686+8787+ let lang = detect_language(&loader, &cli, file, &source).ok_or_else(|| {
8888+ anyhow!(
8989+ "could not determine language for {} (try --lang)",
9090+ file.display()
9191+ )
9292+ })?;
9393+9494+ let mut backend: Box<dyn Backend> = match cli.format {
9595+ Format::Html => Box::new(HtmlBackend::new()),
9696+ Format::EdnHiccup => Box::new(EdnHiccupBackend::new()),
9797+ };
9898+ highlight::highlight(&loader, lang, &source, backend.as_mut())?;
9999+ let rendered = backend.finish();
100100+101101+ write_output(cli.output.as_deref(), &rendered)
102102+}
103103+104104+fn detect_language(
105105+ loader: &Loader,
106106+ cli: &Cli,
107107+ file: &Path,
108108+ source: &str,
109109+) -> Option<tree_house::Language> {
110110+ if let Some(name) = &cli.lang {
111111+ return loader.language_for_name(name);
112112+ }
113113+ loader.language_for_filename(file).or_else(|| {
114114+ let first_line = source.lines().next().unwrap_or_default();
115115+ loader.language_for_shebang(first_line)
116116+ })
117117+}
118118+119119+/// Load the base `languages.toml` and merge the user config over it (by
120120+/// language name), mirroring how Helix layers user config on the default.
121121+fn load_languages(cli: &Cli) -> Result<toml::Value> {
122122+ let user_path = home_dir().map(|h| h.join(".config/helix/languages.toml"));
123123+124124+ let base_path = cli
125125+ .languages
126126+ .clone()
127127+ .or_else(|| {
128128+ first_existing([
129129+ home_dir().map(|h| h.join("source/helix/languages.toml")),
130130+ user_path.clone(),
131131+ ])
132132+ })
133133+ .ok_or_else(|| anyhow!("no languages.toml found; pass --languages <path>"))?;
134134+135135+ let base = std::fs::read_to_string(&base_path)
136136+ .with_context(|| format!("reading {}", base_path.display()))?;
137137+138138+ // Overlay the user config unless it is already the base we loaded.
139139+ let overlay = match user_path {
140140+ Some(path) if path.exists() && path != base_path => Some(
141141+ std::fs::read_to_string(&path)
142142+ .with_context(|| format!("reading {}", path.display()))?,
143143+ ),
144144+ _ => None,
145145+ };
146146+147147+ languages::merge_configs(&base, overlay.as_deref())
148148+}
149149+150150+fn resolve_theme(cli: &Cli, rt: &Runtime) -> Result<PathBuf> {
151151+ if let Some(path) = &cli.theme {
152152+ return Ok(path.clone());
153153+ }
154154+ let mut candidates = vec![home_dir().map(|h| h.join("source/helix/theme.toml"))];
155155+ // A theme bundled alongside any runtime root's parent.
156156+ for root in rt.roots() {
157157+ candidates.push(root.parent().map(|p| p.join("theme.toml")));
158158+ }
159159+ first_existing(candidates).ok_or_else(|| anyhow!("no theme.toml found; pass --theme <path>"))
160160+}
161161+162162+fn first_existing<I>(candidates: I) -> Option<PathBuf>
163163+where
164164+ I: IntoIterator<Item = Option<PathBuf>>,
165165+{
166166+ candidates.into_iter().flatten().find(|p| p.exists())
167167+}
168168+169169+fn write_output(output: Option<&Path>, content: &str) -> Result<()> {
170170+ match output {
171171+ Some(path) => {
172172+ std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))
173173+ }
174174+ None => {
175175+ print!("{content}");
176176+ Ok(())
177177+ }
178178+ }
179179+}
+20
src/queries.rs
···11+//! Query loading with `; inherits:` resolution.
22+//!
33+//! `tree_house::read_query` does the recursive in-place `; inherits: a,b`
44+//! expansion; we just supply a reader that pulls each language's query file
55+//! from the runtime search path.
66+77+use std::path::PathBuf;
88+99+use crate::runtime::Runtime;
1010+1111+/// Read `queries/<lang>/<filename>` with inherits expanded. Missing files
1212+/// resolve to an empty string, matching Helix.
1313+pub fn read_query(rt: &Runtime, lang: &str, filename: &str) -> String {
1414+ tree_house::read_query(lang, |language| {
1515+ let rel = PathBuf::from("queries").join(language).join(filename);
1616+ rt.find_file(&rel)
1717+ .and_then(|p| std::fs::read_to_string(p).ok())
1818+ .unwrap_or_default()
1919+ })
2020+}
+52
src/runtime.rs
···11+//! Discovery of Helix runtime directories.
22+//!
33+//! Helix ships grammars at `runtime/grammars/<lang>.{dylib,so}` and queries at
44+//! `runtime/queries/<lang>/{highlights,injections,locals}.scm`. Several runtime
55+//! trees may coexist; user config shadows the source distribution.
66+77+use std::path::{Path, PathBuf};
88+99+/// Platform shared-library extension for compiled grammars.
1010+#[cfg(target_os = "macos")]
1111+pub const DYLIB_EXT: &str = "dylib";
1212+#[cfg(all(unix, not(target_os = "macos")))]
1313+pub const DYLIB_EXT: &str = "so";
1414+#[cfg(windows)]
1515+pub const DYLIB_EXT: &str = "dll";
1616+1717+/// An ordered set of runtime roots. Earlier roots take precedence.
1818+pub struct Runtime {
1919+ roots: Vec<PathBuf>,
2020+}
2121+2222+impl Runtime {
2323+ /// Build the runtime search path. `overrides` (from `--runtime`) win, then
2424+ /// `$HELIX_RUNTIME`, then the user config runtime.
2525+ pub fn new(overrides: &[PathBuf]) -> Self {
2626+ let mut roots: Vec<PathBuf> = overrides.to_vec();
2727+2828+ if let Some(env) = std::env::var_os("HELIX_RUNTIME") {
2929+ roots.push(PathBuf::from(env));
3030+ }
3131+ if let Some(home) = home_dir() {
3232+ roots.push(home.join(".config/helix/runtime"));
3333+ }
3434+3535+ roots.retain(|p| p.is_dir());
3636+ Runtime { roots }
3737+ }
3838+3939+ /// First existing match for a path relative to a runtime root.
4040+ pub fn find_file(&self, rel: &Path) -> Option<PathBuf> {
4141+ self.roots.iter().map(|r| r.join(rel)).find(|p| p.exists())
4242+ }
4343+4444+ pub fn roots(&self) -> &[PathBuf] {
4545+ &self.roots
4646+ }
4747+}
4848+4949+/// The user's home directory, if known.
5050+pub fn home_dir() -> Option<PathBuf> {
5151+ std::env::var_os("HOME").map(PathBuf::from)
5252+}
+108
src/theme.rs
···11+//! Optional Helix `theme.toml` -> CSS stylesheet.
22+//!
33+//! Each scope key becomes a rule whose selector matches the most specific class
44+//! the HTML backend emits: `keyword.control` -> `.keyword-control`. Palette
55+//! names are resolved via the theme's `[palette]` table.
66+77+use std::collections::HashMap;
88+99+use anyhow::{Context, Result};
1010+use toml::Value;
1111+1212+/// Render a `theme.toml` string into a CSS stylesheet.
1313+pub fn to_css(theme_toml: &str) -> Result<String> {
1414+ let value: Value = toml::from_str(theme_toml).context("parsing theme.toml")?;
1515+ let table = value.as_table().context("theme.toml is not a table")?;
1616+1717+ let palette: HashMap<String, String> = table
1818+ .get("palette")
1919+ .and_then(Value::as_table)
2020+ .map(|t| {
2121+ t.iter()
2222+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2323+ .collect()
2424+ })
2525+ .unwrap_or_default();
2626+2727+ let resolve = |color: &str| -> String {
2828+ palette
2929+ .get(color)
3030+ .cloned()
3131+ .unwrap_or_else(|| color.to_string())
3232+ };
3333+3434+ let mut css = String::from("/* generated by dathan from theme.toml */\n");
3535+ let mut scopes: Vec<(&String, &Value)> = table
3636+ .iter()
3737+ .filter(|(k, _)| k.as_str() != "palette" && k.as_str() != "inherits")
3838+ .collect();
3939+ scopes.sort_by_key(|(k, _)| k.as_str());
4040+4141+ for (scope, style) in scopes {
4242+ let mut decls = Vec::new();
4343+4444+ match style {
4545+ Value::String(color) => decls.push(format!("color: {}", resolve(color))),
4646+ Value::Table(t) => {
4747+ if let Some(fg) = t.get("fg").and_then(Value::as_str) {
4848+ decls.push(format!("color: {}", resolve(fg)));
4949+ }
5050+ if let Some(bg) = t.get("bg").and_then(Value::as_str) {
5151+ decls.push(format!("background-color: {}", resolve(bg)));
5252+ }
5353+ if let Some(mods) = t.get("modifiers").and_then(Value::as_array) {
5454+ for m in mods.iter().filter_map(Value::as_str) {
5555+ match m {
5656+ "bold" => decls.push("font-weight: bold".into()),
5757+ "italic" => decls.push("font-style: italic".into()),
5858+ "underlined" => decls.push("text-decoration: underline".into()),
5959+ "crossed_out" => decls.push("text-decoration: line-through".into()),
6060+ "dim" => decls.push("opacity: 0.7".into()),
6161+ _ => {}
6262+ }
6363+ }
6464+ }
6565+ if t.get("underline").is_some() {
6666+ decls.push("text-decoration: underline".into());
6767+ }
6868+ }
6969+ _ => {}
7070+ }
7171+7272+ if decls.is_empty() {
7373+ continue;
7474+ }
7575+ css.push_str(&format!(
7676+ ".{} {{ {}; }}\n",
7777+ css_class(scope),
7878+ decls.join("; ")
7979+ ));
8080+ }
8181+8282+ Ok(css)
8383+}
8484+8585+/// `keyword.control` -> `keyword-control` (matches the HTML backend's most
8686+/// specific class).
8787+fn css_class(scope: &str) -> String {
8888+ scope.replace('.', "-")
8989+}
9090+9191+#[cfg(test)]
9292+mod tests {
9393+ use super::*;
9494+9595+ #[test]
9696+ fn palette_and_modifiers() {
9797+ let theme = r##"
9898+"keyword.control" = { fg = "blue", modifiers = ["bold"] }
9999+comment = "gray"
100100+[palette]
101101+blue = "#0000ff"
102102+gray = "#808080"
103103+"##;
104104+ let css = to_css(theme).unwrap();
105105+ assert!(css.contains(".keyword-control { color: #0000ff; font-weight: bold; }"));
106106+ assert!(css.contains(".comment { color: #808080; }"));
107107+ }
108108+}