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.

implementation

Tom Waddington (Jun 15, 2026, 4:30 AM +0100) e6e6236f 7dd03a0e

+1764
+1
.gitignore
··· 1 1 .DS_Store 2 + target
+547
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 15 + name = "allocator-api2" 16 + version = "0.2.21" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 + 20 + [[package]] 21 + name = "anstream" 22 + version = "1.0.0" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" 25 + dependencies = [ 26 + "anstyle", 27 + "anstyle-parse", 28 + "anstyle-query", 29 + "anstyle-wincon", 30 + "colorchoice", 31 + "is_terminal_polyfill", 32 + "utf8parse", 33 + ] 34 + 35 + [[package]] 36 + name = "anstyle" 37 + version = "1.0.14" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 40 + 41 + [[package]] 42 + name = "anstyle-parse" 43 + version = "1.0.0" 44 + source = "registry+https://github.com/rust-lang/crates.io-index" 45 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 46 + dependencies = [ 47 + "utf8parse", 48 + ] 49 + 50 + [[package]] 51 + name = "anstyle-query" 52 + version = "1.1.5" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 55 + dependencies = [ 56 + "windows-sys", 57 + ] 58 + 59 + [[package]] 60 + name = "anstyle-wincon" 61 + version = "3.0.11" 62 + source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 64 + dependencies = [ 65 + "anstyle", 66 + "once_cell_polyfill", 67 + "windows-sys", 68 + ] 69 + 70 + [[package]] 71 + name = "anyhow" 72 + version = "1.0.102" 73 + source = "registry+https://github.com/rust-lang/crates.io-index" 74 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 75 + 76 + [[package]] 77 + name = "arc-swap" 78 + version = "1.9.1" 79 + source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" 81 + dependencies = [ 82 + "rustversion", 83 + ] 84 + 85 + [[package]] 86 + name = "cc" 87 + version = "1.2.64" 88 + source = "registry+https://github.com/rust-lang/crates.io-index" 89 + checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" 90 + dependencies = [ 91 + "find-msvc-tools", 92 + "shlex", 93 + ] 94 + 95 + [[package]] 96 + name = "cfg-if" 97 + version = "1.0.4" 98 + source = "registry+https://github.com/rust-lang/crates.io-index" 99 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 100 + 101 + [[package]] 102 + name = "clap" 103 + version = "4.6.1" 104 + source = "registry+https://github.com/rust-lang/crates.io-index" 105 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" 106 + dependencies = [ 107 + "clap_builder", 108 + "clap_derive", 109 + ] 110 + 111 + [[package]] 112 + name = "clap_builder" 113 + version = "4.6.0" 114 + source = "registry+https://github.com/rust-lang/crates.io-index" 115 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 116 + dependencies = [ 117 + "anstream", 118 + "anstyle", 119 + "clap_lex", 120 + "strsim", 121 + ] 122 + 123 + [[package]] 124 + name = "clap_derive" 125 + version = "4.6.1" 126 + source = "registry+https://github.com/rust-lang/crates.io-index" 127 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" 128 + dependencies = [ 129 + "heck", 130 + "proc-macro2", 131 + "quote", 132 + "syn", 133 + ] 134 + 135 + [[package]] 136 + name = "clap_lex" 137 + version = "1.1.0" 138 + source = "registry+https://github.com/rust-lang/crates.io-index" 139 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 140 + 141 + [[package]] 142 + name = "colorchoice" 143 + version = "1.0.5" 144 + source = "registry+https://github.com/rust-lang/crates.io-index" 145 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 146 + 147 + [[package]] 148 + name = "dathan" 149 + version = "0.1.0" 150 + dependencies = [ 151 + "anyhow", 152 + "clap", 153 + "regex", 154 + "ropey", 155 + "serde", 156 + "toml", 157 + "tree-house", 158 + ] 159 + 160 + [[package]] 161 + name = "equivalent" 162 + version = "1.0.2" 163 + source = "registry+https://github.com/rust-lang/crates.io-index" 164 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 165 + 166 + [[package]] 167 + name = "find-msvc-tools" 168 + version = "0.1.9" 169 + source = "registry+https://github.com/rust-lang/crates.io-index" 170 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 171 + 172 + [[package]] 173 + name = "foldhash" 174 + version = "0.1.5" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 177 + 178 + [[package]] 179 + name = "hashbrown" 180 + version = "0.15.5" 181 + source = "registry+https://github.com/rust-lang/crates.io-index" 182 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 183 + dependencies = [ 184 + "allocator-api2", 185 + "equivalent", 186 + "foldhash", 187 + ] 188 + 189 + [[package]] 190 + name = "hashbrown" 191 + version = "0.17.1" 192 + source = "registry+https://github.com/rust-lang/crates.io-index" 193 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 194 + 195 + [[package]] 196 + name = "heck" 197 + version = "0.5.0" 198 + source = "registry+https://github.com/rust-lang/crates.io-index" 199 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 200 + 201 + [[package]] 202 + name = "indexmap" 203 + version = "2.14.0" 204 + source = "registry+https://github.com/rust-lang/crates.io-index" 205 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 206 + dependencies = [ 207 + "equivalent", 208 + "hashbrown 0.17.1", 209 + ] 210 + 211 + [[package]] 212 + name = "is_terminal_polyfill" 213 + version = "1.70.2" 214 + source = "registry+https://github.com/rust-lang/crates.io-index" 215 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 216 + 217 + [[package]] 218 + name = "kstring" 219 + version = "2.0.2" 220 + source = "registry+https://github.com/rust-lang/crates.io-index" 221 + checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" 222 + dependencies = [ 223 + "static_assertions", 224 + ] 225 + 226 + [[package]] 227 + name = "libloading" 228 + version = "0.8.9" 229 + source = "registry+https://github.com/rust-lang/crates.io-index" 230 + checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" 231 + dependencies = [ 232 + "cfg-if", 233 + "windows-link", 234 + ] 235 + 236 + [[package]] 237 + name = "log" 238 + version = "0.4.32" 239 + source = "registry+https://github.com/rust-lang/crates.io-index" 240 + checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" 241 + 242 + [[package]] 243 + name = "memchr" 244 + version = "2.8.2" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" 247 + 248 + [[package]] 249 + name = "once_cell" 250 + version = "1.21.4" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 253 + 254 + [[package]] 255 + name = "once_cell_polyfill" 256 + version = "1.70.2" 257 + source = "registry+https://github.com/rust-lang/crates.io-index" 258 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 259 + 260 + [[package]] 261 + name = "proc-macro2" 262 + version = "1.0.106" 263 + source = "registry+https://github.com/rust-lang/crates.io-index" 264 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 265 + dependencies = [ 266 + "unicode-ident", 267 + ] 268 + 269 + [[package]] 270 + name = "quote" 271 + version = "1.0.45" 272 + source = "registry+https://github.com/rust-lang/crates.io-index" 273 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 274 + dependencies = [ 275 + "proc-macro2", 276 + ] 277 + 278 + [[package]] 279 + name = "regex" 280 + version = "1.12.4" 281 + source = "registry+https://github.com/rust-lang/crates.io-index" 282 + checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" 283 + dependencies = [ 284 + "aho-corasick", 285 + "memchr", 286 + "regex-automata", 287 + "regex-syntax", 288 + ] 289 + 290 + [[package]] 291 + name = "regex-automata" 292 + version = "0.4.14" 293 + source = "registry+https://github.com/rust-lang/crates.io-index" 294 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 295 + dependencies = [ 296 + "aho-corasick", 297 + "memchr", 298 + "regex-syntax", 299 + ] 300 + 301 + [[package]] 302 + name = "regex-cursor" 303 + version = "0.1.5" 304 + source = "registry+https://github.com/rust-lang/crates.io-index" 305 + checksum = "0497c781d2f982ae8284d2932aee6a877e58a4541daa5e8fadc18cc75c23a61d" 306 + dependencies = [ 307 + "log", 308 + "memchr", 309 + "regex-automata", 310 + "regex-syntax", 311 + "ropey", 312 + ] 313 + 314 + [[package]] 315 + name = "regex-syntax" 316 + version = "0.8.11" 317 + source = "registry+https://github.com/rust-lang/crates.io-index" 318 + checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 319 + 320 + [[package]] 321 + name = "ropey" 322 + version = "1.6.1" 323 + source = "registry+https://github.com/rust-lang/crates.io-index" 324 + checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" 325 + dependencies = [ 326 + "smallvec", 327 + "str_indices", 328 + ] 329 + 330 + [[package]] 331 + name = "rustversion" 332 + version = "1.0.22" 333 + source = "registry+https://github.com/rust-lang/crates.io-index" 334 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 335 + 336 + [[package]] 337 + name = "serde" 338 + version = "1.0.228" 339 + source = "registry+https://github.com/rust-lang/crates.io-index" 340 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 341 + dependencies = [ 342 + "serde_core", 343 + "serde_derive", 344 + ] 345 + 346 + [[package]] 347 + name = "serde_core" 348 + version = "1.0.228" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 351 + dependencies = [ 352 + "serde_derive", 353 + ] 354 + 355 + [[package]] 356 + name = "serde_derive" 357 + version = "1.0.228" 358 + source = "registry+https://github.com/rust-lang/crates.io-index" 359 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 360 + dependencies = [ 361 + "proc-macro2", 362 + "quote", 363 + "syn", 364 + ] 365 + 366 + [[package]] 367 + name = "serde_spanned" 368 + version = "0.6.9" 369 + source = "registry+https://github.com/rust-lang/crates.io-index" 370 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 371 + dependencies = [ 372 + "serde", 373 + ] 374 + 375 + [[package]] 376 + name = "shlex" 377 + version = "2.0.1" 378 + source = "registry+https://github.com/rust-lang/crates.io-index" 379 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 380 + 381 + [[package]] 382 + name = "slab" 383 + version = "0.4.12" 384 + source = "registry+https://github.com/rust-lang/crates.io-index" 385 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 386 + 387 + [[package]] 388 + name = "smallvec" 389 + version = "1.15.2" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" 392 + 393 + [[package]] 394 + name = "static_assertions" 395 + version = "1.1.0" 396 + source = "registry+https://github.com/rust-lang/crates.io-index" 397 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 398 + 399 + [[package]] 400 + name = "str_indices" 401 + version = "0.4.4" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" 404 + 405 + [[package]] 406 + name = "strsim" 407 + version = "0.11.1" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 410 + 411 + [[package]] 412 + name = "syn" 413 + version = "2.0.117" 414 + source = "registry+https://github.com/rust-lang/crates.io-index" 415 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 416 + dependencies = [ 417 + "proc-macro2", 418 + "quote", 419 + "unicode-ident", 420 + ] 421 + 422 + [[package]] 423 + name = "thiserror" 424 + version = "2.0.18" 425 + source = "registry+https://github.com/rust-lang/crates.io-index" 426 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 427 + dependencies = [ 428 + "thiserror-impl", 429 + ] 430 + 431 + [[package]] 432 + name = "thiserror-impl" 433 + version = "2.0.18" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 436 + dependencies = [ 437 + "proc-macro2", 438 + "quote", 439 + "syn", 440 + ] 441 + 442 + [[package]] 443 + name = "toml" 444 + version = "0.8.23" 445 + source = "registry+https://github.com/rust-lang/crates.io-index" 446 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 447 + dependencies = [ 448 + "serde", 449 + "serde_spanned", 450 + "toml_datetime", 451 + "toml_edit", 452 + ] 453 + 454 + [[package]] 455 + name = "toml_datetime" 456 + version = "0.6.11" 457 + source = "registry+https://github.com/rust-lang/crates.io-index" 458 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 459 + dependencies = [ 460 + "serde", 461 + ] 462 + 463 + [[package]] 464 + name = "toml_edit" 465 + version = "0.22.27" 466 + source = "registry+https://github.com/rust-lang/crates.io-index" 467 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 468 + dependencies = [ 469 + "indexmap", 470 + "serde", 471 + "serde_spanned", 472 + "toml_datetime", 473 + "toml_write", 474 + "winnow", 475 + ] 476 + 477 + [[package]] 478 + name = "toml_write" 479 + version = "0.1.2" 480 + source = "registry+https://github.com/rust-lang/crates.io-index" 481 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 482 + 483 + [[package]] 484 + name = "tree-house" 485 + version = "0.3.0" 486 + source = "registry+https://github.com/rust-lang/crates.io-index" 487 + checksum = "d00ea55222392f171ae004dd13b62edd09d995633abf0c13406a8df3547fb999" 488 + dependencies = [ 489 + "arc-swap", 490 + "hashbrown 0.15.5", 491 + "kstring", 492 + "once_cell", 493 + "regex", 494 + "regex-cursor", 495 + "ropey", 496 + "slab", 497 + "tree-house-bindings", 498 + ] 499 + 500 + [[package]] 501 + name = "tree-house-bindings" 502 + version = "0.2.4" 503 + source = "registry+https://github.com/rust-lang/crates.io-index" 504 + checksum = "e53f322db2c82a796392a01ae2687e304c4b3aafcc6520ff134b4ef35dafebeb" 505 + dependencies = [ 506 + "cc", 507 + "libloading", 508 + "regex-cursor", 509 + "ropey", 510 + "thiserror", 511 + ] 512 + 513 + [[package]] 514 + name = "unicode-ident" 515 + version = "1.0.24" 516 + source = "registry+https://github.com/rust-lang/crates.io-index" 517 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 518 + 519 + [[package]] 520 + name = "utf8parse" 521 + version = "0.2.2" 522 + source = "registry+https://github.com/rust-lang/crates.io-index" 523 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 524 + 525 + [[package]] 526 + name = "windows-link" 527 + version = "0.2.1" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 530 + 531 + [[package]] 532 + name = "windows-sys" 533 + version = "0.61.2" 534 + source = "registry+https://github.com/rust-lang/crates.io-index" 535 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 536 + dependencies = [ 537 + "windows-link", 538 + ] 539 + 540 + [[package]] 541 + name = "winnow" 542 + version = "0.7.15" 543 + source = "registry+https://github.com/rust-lang/crates.io-index" 544 + checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" 545 + dependencies = [ 546 + "memchr", 547 + ]
+14
Cargo.toml
··· 1 + [package] 2 + name = "dathan" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "Highlight source code to HTML or Clojure/EDN Hiccup using Helix's tree-sitter grammars" 6 + 7 + [dependencies] 8 + tree-house = { version = "0.3.0", default-features = false } 9 + ropey = "1.6" 10 + toml = "0.8" 11 + serde = { version = "1.0", features = ["derive"] } 12 + regex = "1" 13 + clap = { version = "4", features = ["derive"] } 14 + anyhow = "1"
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Tom Waddington 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+92
README.md
··· 1 + # dathan 2 + 3 + Highlight source code to HTML or Clojure/EDN Hiccup using Helix’s tree-sitter 4 + grammars and queries. 5 + 6 + It reads the precompiled grammars and `highlights.scm` / `injections.scm` / 7 + `locals.scm` queries from a Helix runtime, so it covers whatever languages that 8 + runtime provides. Grammar loading, query `inherits` resolution, injections, and 9 + locals are handled by [tree-house](https://github.com/helix-editor/tree-house), 10 + the same library Helix uses. 11 + 12 + ## Build 13 + 14 + ``` 15 + cargo build --release 16 + ``` 17 + 18 + The binary is `target/release/dathan`. 19 + 20 + ## Usage 21 + 22 + ``` 23 + dathan [OPTIONS] <FILE> 24 + ``` 25 + 26 + Options: 27 + 28 + ``` 29 + --format <html|edn-hiccup> Output format. Default: edn-hiccup. 30 + --lang <name> Force a language instead of detecting it. 31 + --runtime <path> Extra runtime root, highest priority. Repeatable. 32 + --languages <path> Base languages.toml. The user config is still merged on top. 33 + --theme <path> theme.toml for --emit-css. 34 + --emit-css Write a CSS stylesheet from the theme and exit. Ignores FILE. 35 + -o, --output <path> Output file. Default: stdout. 36 + ``` 37 + 38 + Examples: 39 + 40 + ``` 41 + dathan src/main.rs 42 + dathan --format html src/main.rs -o main.html 43 + dathan --emit-css --theme ~/source/helix/theme.toml -o theme.css 44 + ``` 45 + 46 + ## Runtime discovery 47 + 48 + Runtime roots are searched in this order, first match wins: 49 + 50 + 1. `--runtime` paths 51 + 2. `$HELIX_RUNTIME` 52 + 3. `~/.config/helix/runtime` 53 + 54 + The language registry is read from `~/source/helix/languages.toml` (or 55 + `--languages`), with `~/.config/helix/languages.toml` merged over it by language 56 + name. 57 + 58 + Language detection uses the file extension, then `file-types` globs, then a 59 + `#!` shebang line. Override with `--lang`. 60 + 61 + ## Output 62 + 63 + Both formats name spans by scope. A dotted scope becomes space-separated 64 + hierarchical classes, so `keyword.control.conditional` is rendered as 65 + `keyword keyword-control keyword-control-conditional`. 66 + 67 + HTML: 68 + 69 + ```html 70 + <pre><code class="dathan"><span class="keyword keyword-function">fn</span> ...</code></pre> 71 + ``` 72 + 73 + EDN Hiccup: 74 + 75 + ```clojure 76 + [:pre [:code [:span {:class "keyword keyword-function"} "fn"] " " ...]] 77 + ``` 78 + 79 + CSS from `--emit-css` targets the most specific class, for example 80 + `.keyword-control { color: ...; }`. Palette names in the theme are resolved. 81 + 82 + ## Tests 83 + 84 + ``` 85 + cargo test 86 + ``` 87 + 88 + ## License 89 + 90 + Copyright © 2026 Tom Waddington 91 + 92 + Distributed under the MIT License. See LICENSE file for details.
+114
src/backend/hiccup.rs
··· 1 + //! Clojure/EDN Hiccup backend. 2 + //! 3 + //! Emits `[:pre [:code [:span {:class "keyword keyword-control"} "if"] " " ...]]`. 4 + //! The `:class` value uses the same space-separated hyphenated hierarchical 5 + //! classes as the HTML backend, so naming is consistent across formats. Spans 6 + //! nest, so we build a stack of frames and render each on `close`. 7 + 8 + use super::{classes, Backend}; 9 + 10 + struct Frame { 11 + /// `None` for the implicit root (the `[:code ...]` children). 12 + class: Option<String>, 13 + children: Vec<String>, 14 + } 15 + 16 + pub struct EdnHiccupBackend { 17 + stack: Vec<Frame>, 18 + } 19 + 20 + impl EdnHiccupBackend { 21 + pub fn new() -> Self { 22 + Self { 23 + stack: vec![Frame { 24 + class: None, 25 + children: Vec::new(), 26 + }], 27 + } 28 + } 29 + } 30 + 31 + impl Backend for EdnHiccupBackend { 32 + fn open(&mut self, scope: &str) { 33 + self.stack.push(Frame { 34 + class: Some(scope.to_string()), 35 + children: Vec::new(), 36 + }); 37 + } 38 + 39 + fn text(&mut self, text: &str) { 40 + // The driver coalesces runs of equal scope, but tree-sitter can still 41 + // split text at injection/event boundaries; merge adjacent literals. 42 + let literal = edn_string(text); 43 + if let Some(frame) = self.stack.last_mut() { 44 + frame.children.push(literal); 45 + } 46 + } 47 + 48 + fn close(&mut self) { 49 + let frame = self.stack.pop().expect("close without matching open"); 50 + let rendered = render(&frame); 51 + if let Some(parent) = self.stack.last_mut() { 52 + parent.children.push(rendered); 53 + } 54 + } 55 + 56 + fn finish(self: Box<Self>) -> String { 57 + let root = &self.stack[0]; 58 + let mut out = String::from("[:pre [:code"); 59 + for child in &root.children { 60 + out.push(' '); 61 + out.push_str(child); 62 + } 63 + out.push_str("]]\n"); 64 + out 65 + } 66 + } 67 + 68 + fn render(frame: &Frame) -> String { 69 + let scope = frame.class.as_deref().unwrap_or_default(); 70 + let mut out = format!("[:span {{:class {}}}", edn_string(&classes(scope))); 71 + for child in &frame.children { 72 + out.push(' '); 73 + out.push_str(child); 74 + } 75 + out.push(']'); 76 + out 77 + } 78 + 79 + /// Render a Rust string as an EDN string literal. 80 + fn edn_string(text: &str) -> String { 81 + let mut out = String::with_capacity(text.len() + 2); 82 + out.push('"'); 83 + for ch in text.chars() { 84 + match ch { 85 + '\\' => out.push_str("\\\\"), 86 + '"' => out.push_str("\\\""), 87 + '\n' => out.push_str("\\n"), 88 + '\r' => out.push_str("\\r"), 89 + '\t' => out.push_str("\\t"), 90 + _ => out.push(ch), 91 + } 92 + } 93 + out.push('"'); 94 + out 95 + } 96 + 97 + #[cfg(test)] 98 + mod tests { 99 + use super::*; 100 + 101 + #[test] 102 + fn nested_hiccup_and_edn_escaping() { 103 + let mut b = Box::new(EdnHiccupBackend::new()); 104 + b.open("keyword.function"); 105 + b.text("fn"); 106 + b.close(); 107 + b.text("\t\"x\"\n"); 108 + let out = b.finish(); 109 + assert_eq!( 110 + out, 111 + "[:pre [:code [:span {:class \"keyword keyword-function\"} \"fn\"] \"\\t\\\"x\\\"\\n\"]]\n" 112 + ); 113 + } 114 + }
+73
src/backend/html.rs
··· 1 + //! HTML backend: `<pre><code>` with `<span class="...">` per scope. 2 + //! 3 + //! A dotted scope is emitted as space-separated hierarchical classes so CSS can 4 + //! target any level: `keyword.control.conditional` becomes 5 + //! `class="keyword keyword-control keyword-control-conditional"`. 6 + 7 + use super::{classes, Backend}; 8 + 9 + pub struct HtmlBackend { 10 + out: String, 11 + } 12 + 13 + impl HtmlBackend { 14 + pub fn new() -> Self { 15 + let mut out = String::from("<pre><code class=\"dathan\">"); 16 + out.reserve(4096); 17 + Self { out } 18 + } 19 + } 20 + 21 + impl Backend for HtmlBackend { 22 + fn open(&mut self, scope: &str) { 23 + self.out.push_str("<span class=\""); 24 + self.out.push_str(&classes(scope)); 25 + self.out.push_str("\">"); 26 + } 27 + 28 + fn text(&mut self, text: &str) { 29 + escape_into(text, &mut self.out); 30 + } 31 + 32 + fn close(&mut self) { 33 + self.out.push_str("</span>"); 34 + } 35 + 36 + fn finish(self: Box<Self>) -> String { 37 + let mut out = self.out; 38 + out.push_str("</code></pre>\n"); 39 + out 40 + } 41 + } 42 + 43 + fn escape_into(text: &str, out: &mut String) { 44 + for ch in text.chars() { 45 + match ch { 46 + '&' => out.push_str("&amp;"), 47 + '<' => out.push_str("&lt;"), 48 + '>' => out.push_str("&gt;"), 49 + '"' => out.push_str("&quot;"), 50 + _ => out.push(ch), 51 + } 52 + } 53 + } 54 + 55 + #[cfg(test)] 56 + mod tests { 57 + use super::*; 58 + 59 + #[test] 60 + fn nested_spans_and_escaping() { 61 + let mut b = Box::new(HtmlBackend::new()); 62 + b.open("keyword.control"); 63 + b.text("if x < 1 && \"q\""); 64 + b.close(); 65 + let out = b.finish(); 66 + assert_eq!( 67 + out, 68 + "<pre><code class=\"dathan\">\ 69 + <span class=\"keyword keyword-control\">if x &lt; 1 &amp;&amp; &quot;q&quot;</span>\ 70 + </code></pre>\n" 71 + ); 72 + } 73 + }
+48
src/backend/mod.rs
··· 1 + //! Pluggable output backends. 2 + 3 + mod hiccup; 4 + mod html; 5 + 6 + pub use hiccup::EdnHiccupBackend; 7 + pub use html::HtmlBackend; 8 + 9 + /// A streaming sink for highlight events. `open`/`close` bracket a highlighted 10 + /// span (spans nest); `text` receives a raw source slice the backend must 11 + /// escape for its format. 12 + pub trait Backend { 13 + /// Open a span for the given dotted scope (e.g. `keyword.control`). 14 + fn open(&mut self, scope: &str); 15 + /// Append a raw (unescaped) source slice. 16 + fn text(&mut self, text: &str); 17 + /// Close the most recently opened span. 18 + fn close(&mut self); 19 + /// Consume the backend and produce the finished document. 20 + fn finish(self: Box<Self>) -> String; 21 + } 22 + 23 + /// Convert a dotted scope into space-separated hierarchical classes, shared by 24 + /// every backend (and matched by the CSS emitter) so naming is consistent: 25 + /// `keyword.control.conditional` -> 26 + /// `keyword keyword-control keyword-control-conditional`. 27 + pub(crate) fn classes(scope: &str) -> String { 28 + let parts: Vec<&str> = scope.split('.').collect(); 29 + let mut classes = Vec::with_capacity(parts.len()); 30 + for i in 1..=parts.len() { 31 + classes.push(parts[..i].join("-")); 32 + } 33 + classes.join(" ") 34 + } 35 + 36 + #[cfg(test)] 37 + mod tests { 38 + use super::*; 39 + 40 + #[test] 41 + fn hierarchical_classes() { 42 + assert_eq!(classes("keyword"), "keyword"); 43 + assert_eq!( 44 + classes("keyword.control.conditional"), 45 + "keyword keyword-control keyword-control-conditional" 46 + ); 47 + } 48 + }
+29
src/grammar.rs
··· 1 + //! Grammar loading. 2 + //! 3 + //! Thin wrapper over `tree_house::tree_sitter::Grammar::new`, which dlopens the 4 + //! precompiled shared library, resolves the `tree_sitter_<name>` symbol, leaks 5 + //! the library so the grammar outlives it, and verifies the ABI version 6 + //! (13..=15) — exactly how Helix loads grammars. 7 + 8 + use std::path::PathBuf; 9 + 10 + use anyhow::{anyhow, Context, Result}; 11 + use tree_house::tree_sitter::Grammar; 12 + 13 + use crate::runtime::{Runtime, DYLIB_EXT}; 14 + 15 + /// Load the grammar named `name` from the first runtime root that provides it. 16 + pub fn load(name: &str, rt: &Runtime) -> Result<Grammar> { 17 + let mut rel = PathBuf::from("grammars"); 18 + rel.push(name); 19 + rel.set_extension(DYLIB_EXT); 20 + 21 + let path = rt.find_file(&rel).ok_or_else(|| { 22 + anyhow!("no compiled grammar for '{name}' (looked for grammars/{name}.{DYLIB_EXT})") 23 + })?; 24 + 25 + // SAFETY: the file is a Helix-compiled tree-sitter grammar exporting the 26 + // expected `tree_sitter_<name>` constructor; `Grammar::new` checks the ABI. 27 + unsafe { Grammar::new(name, &path) } 28 + .with_context(|| format!("loading grammar '{name}' from {}", path.display())) 29 + }
+98
src/highlight.rs
··· 1 + //! Highlight driver. 2 + //! 3 + //! Drives `tree_house`'s `Highlighter`, which yields, at each event offset, the 4 + //! full stack of active highlights (outermost first) for the following text 5 + //! span. We diff successive stacks into nested open/close span events and feed 6 + //! them to a `Backend`, emitting properly nested markup. 7 + 8 + use std::time::Duration; 9 + 10 + use anyhow::{anyhow, Result}; 11 + use ropey::RopeSlice; 12 + use tree_house::highlighter::{Highlight, HighlightEvent, Highlighter}; 13 + use tree_house::{Language, Syntax}; 14 + 15 + use crate::backend::Backend; 16 + use crate::languages::Loader; 17 + 18 + const PARSE_TIMEOUT: Duration = Duration::from_secs(15); 19 + 20 + /// Highlight `source` as `lang`, driving `backend` with open/text/close calls. 21 + pub fn highlight( 22 + loader: &Loader, 23 + lang: Language, 24 + source: &str, 25 + backend: &mut dyn Backend, 26 + ) -> Result<()> { 27 + let rope = RopeSlice::from(source); 28 + let len = source.len() as u32; 29 + 30 + let syntax = Syntax::new(rope, lang, PARSE_TIMEOUT, loader) 31 + .map_err(|e| anyhow!("failed to parse source: {e:?}"))?; 32 + let mut highlighter = Highlighter::new(&syntax, rope, loader, ..); 33 + 34 + // Mirror of the highlighter's active stack (outermost first). 35 + let mut stack: Vec<Highlight> = Vec::new(); 36 + // Spans currently open in the backend output. 37 + let mut open: Vec<Highlight> = Vec::new(); 38 + 39 + let mut pos = 0u32; 40 + while pos < len { 41 + if pos == highlighter.next_event_offset() { 42 + let (event, new_highlights) = highlighter.advance(); 43 + if event == HighlightEvent::Refresh { 44 + stack.clear(); 45 + } 46 + stack.extend(new_highlights); 47 + } 48 + 49 + let start = pos; 50 + let next = highlighter.next_event_offset(); 51 + pos = if next == u32::MAX || next > len { 52 + len 53 + } else { 54 + next 55 + }; 56 + 57 + if pos <= start { 58 + if pos >= len { 59 + break; 60 + } 61 + // Zero-width region: loop to drain further events at this offset. 62 + continue; 63 + } 64 + 65 + sync_spans(&mut open, &stack, loader, backend); 66 + backend.text(&source[start as usize..pos as usize]); 67 + } 68 + 69 + while open.pop().is_some() { 70 + backend.close(); 71 + } 72 + Ok(()) 73 + } 74 + 75 + /// Reconcile the open spans with the desired stack: keep the common prefix, 76 + /// close the rest, then open the new tail. 77 + fn sync_spans( 78 + open: &mut Vec<Highlight>, 79 + stack: &[Highlight], 80 + loader: &Loader, 81 + backend: &mut dyn Backend, 82 + ) { 83 + let common = open 84 + .iter() 85 + .zip(stack.iter()) 86 + .take_while(|(a, b)| a == b) 87 + .count(); 88 + 89 + for _ in common..open.len() { 90 + backend.close(); 91 + } 92 + open.truncate(common); 93 + 94 + for &highlight in &stack[common..] { 95 + backend.open(&loader.scope_name(highlight)); 96 + open.push(highlight); 97 + } 98 + }
+368
src/languages.rs
··· 1 + //! Language registry and the `tree_house::LanguageLoader` implementation. 2 + //! 3 + //! Parses Helix's `languages.toml` into a registry, detects a file's language, 4 + //! resolves injection markers, and lazily compiles a `LanguageConfig` per 5 + //! language. Compiled configs are `configure`d against a single global, 6 + //! append-only list of recognized scope names so a `Highlight` index maps back 7 + //! to the exact dotted capture name (e.g. `keyword.control.conditional`). 8 + 9 + use std::cell::{OnceCell, RefCell}; 10 + use std::collections::HashMap; 11 + use std::path::Path; 12 + 13 + use anyhow::{Context, Result}; 14 + use regex::Regex; 15 + use ropey::RopeSlice; 16 + use serde::Deserialize; 17 + use toml::Value; 18 + use tree_house::highlighter::Highlight; 19 + use tree_house::{InjectionLanguageMarker, Language, LanguageConfig, LanguageLoader}; 20 + 21 + use crate::grammar; 22 + use crate::queries::read_query; 23 + use crate::runtime::Runtime; 24 + 25 + #[derive(Debug, Deserialize)] 26 + struct RawConfig { 27 + #[serde(default)] 28 + language: Vec<RawLang>, 29 + } 30 + 31 + #[derive(Debug, Deserialize)] 32 + struct RawLang { 33 + name: String, 34 + #[serde(default)] 35 + grammar: Option<String>, 36 + #[serde(default, rename = "injection-regex")] 37 + injection_regex: Option<String>, 38 + #[serde(default, rename = "file-types")] 39 + file_types: Vec<FileType>, 40 + #[serde(default)] 41 + shebangs: Vec<String>, 42 + } 43 + 44 + /// A `file-types` entry: either a bare extension or a `{ glob = .. }` / 45 + /// `{ suffix = .. }` table. 46 + #[derive(Debug, Deserialize)] 47 + #[serde(untagged)] 48 + enum FileType { 49 + Extension(String), 50 + Special(HashMap<String, String>), 51 + } 52 + 53 + /// Processed per-language data. 54 + struct LangData { 55 + name: String, 56 + grammar: String, 57 + injection_regex: Option<Regex>, 58 + file_types: Vec<FileType>, 59 + } 60 + 61 + pub struct Loader { 62 + rt: Runtime, 63 + langs: Vec<LangData>, 64 + configs: Vec<OnceCell<Option<LanguageConfig>>>, 65 + by_extension: HashMap<String, Language>, 66 + by_name: HashMap<String, Language>, 67 + by_shebang: HashMap<String, Language>, 68 + /// Global, append-only list of recognized scope names. The index of a name 69 + /// is the `Highlight` value reported for captures of that name. 70 + recognized: RefCell<Vec<String>>, 71 + } 72 + 73 + impl Loader { 74 + /// Build a registry from an already-merged `languages.toml` value. 75 + pub fn new(rt: Runtime, config: Value) -> Result<Self> { 76 + let raw: RawConfig = config.try_into().context("interpreting languages.toml")?; 77 + 78 + let mut langs = Vec::with_capacity(raw.language.len()); 79 + let mut by_extension = HashMap::new(); 80 + let mut by_name = HashMap::new(); 81 + let mut by_shebang = HashMap::new(); 82 + 83 + for (i, l) in raw.language.into_iter().enumerate() { 84 + let lang = Language::new(i as u32); 85 + by_name.insert(l.name.clone(), lang); 86 + 87 + for ft in &l.file_types { 88 + if let FileType::Extension(ext) = ft { 89 + by_extension.insert(ext.clone(), lang); 90 + } 91 + } 92 + for sb in &l.shebangs { 93 + by_shebang.insert(sb.clone(), lang); 94 + } 95 + 96 + let injection_regex = l.injection_regex.as_deref().and_then(|s| { 97 + Regex::new(s) 98 + .map_err(|e| eprintln!("dathan: bad injection-regex for '{}': {e}", l.name)) 99 + .ok() 100 + }); 101 + 102 + langs.push(LangData { 103 + grammar: l.grammar.unwrap_or_else(|| l.name.clone()), 104 + name: l.name, 105 + injection_regex, 106 + file_types: l.file_types, 107 + }); 108 + } 109 + 110 + let configs = (0..langs.len()).map(|_| OnceCell::new()).collect(); 111 + 112 + Ok(Self { 113 + rt, 114 + langs, 115 + configs, 116 + by_extension, 117 + by_name, 118 + by_shebang, 119 + recognized: RefCell::new(Vec::new()), 120 + }) 121 + } 122 + 123 + /// Resolve a `Highlight` back to its dotted scope name. 124 + pub fn scope_name(&self, highlight: Highlight) -> String { 125 + self.recognized 126 + .borrow() 127 + .get(highlight.idx()) 128 + .cloned() 129 + .unwrap_or_default() 130 + } 131 + 132 + /// Intern a capture name into the global recognized list (append-only) and 133 + /// return its `Highlight` index. 134 + fn intern(&self, name: &str) -> Highlight { 135 + let mut rec = self.recognized.borrow_mut(); 136 + let idx = rec.iter().position(|n| n == name).unwrap_or_else(|| { 137 + rec.push(name.to_string()); 138 + rec.len() - 1 139 + }); 140 + Highlight::new(idx as u32) 141 + } 142 + 143 + /// Compile grammar + queries into a configured `LanguageConfig`. 144 + fn compile(&self, lang: Language) -> Option<LanguageConfig> { 145 + let data = &self.langs[lang.idx()]; 146 + 147 + let grammar = match grammar::load(&data.grammar, &self.rt) { 148 + Ok(g) => g, 149 + Err(e) => { 150 + eprintln!("dathan: {e:#}"); 151 + return None; 152 + } 153 + }; 154 + 155 + let highlights = read_query(&self.rt, &data.name, "highlights.scm"); 156 + let injections = read_query(&self.rt, &data.name, "injections.scm"); 157 + let locals = read_query(&self.rt, &data.name, "locals.scm"); 158 + 159 + let config = match LanguageConfig::new(grammar, &highlights, &injections, &locals) { 160 + Ok(c) => c, 161 + Err(e) => { 162 + eprintln!("dathan: failed to compile queries for '{}': {e}", data.name); 163 + return None; 164 + } 165 + }; 166 + 167 + config.configure(|name| Some(self.intern(name))); 168 + Some(config) 169 + } 170 + 171 + pub fn language_for_name(&self, name: &str) -> Option<Language> { 172 + self.by_name.get(name).copied() 173 + } 174 + 175 + /// Detect by extension, then by glob/suffix `file-types` entries. 176 + pub fn language_for_filename(&self, path: &Path) -> Option<Language> { 177 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 178 + if let Some(&lang) = self.by_extension.get(ext) { 179 + return Some(lang); 180 + } 181 + } 182 + let name = path 183 + .file_name() 184 + .and_then(|f| f.to_str()) 185 + .unwrap_or_default(); 186 + for (i, data) in self.langs.iter().enumerate() { 187 + for ft in &data.file_types { 188 + if let FileType::Special(map) = ft { 189 + let hit = map.get("glob").is_some_and(|g| glob_match(g, name)) 190 + || map 191 + .get("suffix") 192 + .is_some_and(|s| name.ends_with(s.as_str())); 193 + if hit { 194 + return Some(Language::new(i as u32)); 195 + } 196 + } 197 + } 198 + } 199 + None 200 + } 201 + 202 + /// Resolve an injected-language token: exact name first, then the longest 203 + /// `injection-regex` match (mirrors Helix's `language_for_match`). 204 + pub fn language_for_match(&self, text: &str) -> Option<Language> { 205 + if let Some(lang) = self.language_for_name(text) { 206 + return Some(lang); 207 + } 208 + let mut best_len = 0; 209 + let mut best = None; 210 + for (i, data) in self.langs.iter().enumerate() { 211 + if let Some(re) = &data.injection_regex { 212 + if let Some(m) = re.find(text) { 213 + let len = m.end() - m.start(); 214 + if len > best_len { 215 + best_len = len; 216 + best = Some(Language::new(i as u32)); 217 + } 218 + } 219 + } 220 + } 221 + best 222 + } 223 + 224 + /// Resolve a language from a `#!` shebang line. 225 + pub fn language_for_shebang(&self, line: &str) -> Option<Language> { 226 + let rest = line.strip_prefix("#!")?.trim_start(); 227 + let mut tokens = rest.split_whitespace(); 228 + let first = tokens.next()?; 229 + let interpreter = if first.rsplit(['/', '\\']).next() == Some("env") { 230 + tokens.next()? 231 + } else { 232 + first 233 + }; 234 + let name = interpreter.rsplit(['/', '\\']).next()?; 235 + self.by_shebang.get(name).copied() 236 + } 237 + } 238 + 239 + impl LanguageLoader for Loader { 240 + fn language_for_marker(&self, marker: InjectionLanguageMarker) -> Option<Language> { 241 + match marker { 242 + InjectionLanguageMarker::Name(name) => self.language_for_name(name), 243 + InjectionLanguageMarker::Match(text) => self.language_for_match(&slice_to_string(text)), 244 + InjectionLanguageMarker::Filename(text) => { 245 + self.language_for_filename(Path::new(&slice_to_string(text))) 246 + } 247 + InjectionLanguageMarker::Shebang(text) => { 248 + let token = slice_to_string(text); 249 + self.by_shebang.get(&token).copied() 250 + } 251 + } 252 + } 253 + 254 + fn get_config(&self, lang: Language) -> Option<&LanguageConfig> { 255 + self.configs[lang.idx()] 256 + .get_or_init(|| self.compile(lang)) 257 + .as_ref() 258 + } 259 + } 260 + 261 + fn slice_to_string(slice: RopeSlice) -> String { 262 + String::from(slice) 263 + } 264 + 265 + /// Minimal glob: supports a leading `*` wildcard (suffix match) or an exact 266 + /// filename match. Sufficient for the `file-types` globs Helix ships. 267 + fn glob_match(pattern: &str, name: &str) -> bool { 268 + match pattern.strip_prefix('*') { 269 + Some(suffix) => name.ends_with(suffix), 270 + None => pattern == name, 271 + } 272 + } 273 + 274 + /// Deep-merge an `overlay` `languages.toml` onto a `base`, the way Helix merges 275 + /// the user config over the default: `[[language]]`/`[[grammar]]` arrays are 276 + /// merged by `name` (overlay entries override/extend matching base entries and 277 + /// append new ones); other keys are overridden by the overlay. 278 + pub fn merge_configs(base: &str, overlay: Option<&str>) -> Result<Value> { 279 + let base: Value = toml::from_str(base).context("parsing base languages.toml")?; 280 + match overlay { 281 + None => Ok(base), 282 + Some(overlay) => { 283 + let overlay: Value = toml::from_str(overlay).context("parsing user languages.toml")?; 284 + Ok(merge_values(base, overlay)) 285 + } 286 + } 287 + } 288 + 289 + fn merge_values(base: Value, overlay: Value) -> Value { 290 + match (base, overlay) { 291 + (Value::Table(mut base), Value::Table(overlay)) => { 292 + for (key, ov) in overlay { 293 + let merged = match base.remove(&key) { 294 + Some(bv) if key == "language" || key == "grammar" => merge_named_array(bv, ov), 295 + Some(bv) => merge_values(bv, ov), 296 + None => ov, 297 + }; 298 + base.insert(key, merged); 299 + } 300 + Value::Table(base) 301 + } 302 + // Scalars and arrays without a merge key: the overlay wins. 303 + (_, overlay) => overlay, 304 + } 305 + } 306 + 307 + /// Merge two arrays of tables keyed by their `name` field. 308 + fn merge_named_array(base: Value, overlay: Value) -> Value { 309 + match (base, overlay) { 310 + (Value::Array(mut base), Value::Array(overlay)) => { 311 + for item in overlay { 312 + let pos = item.get("name").and_then(Value::as_str).and_then(|name| { 313 + base.iter() 314 + .position(|b| b.get("name").and_then(Value::as_str) == Some(name)) 315 + }); 316 + match pos { 317 + Some(i) => base[i] = merge_values(base[i].clone(), item), 318 + None => base.push(item), 319 + } 320 + } 321 + Value::Array(base) 322 + } 323 + (_, overlay) => overlay, 324 + } 325 + } 326 + 327 + #[cfg(test)] 328 + mod tests { 329 + use super::*; 330 + 331 + #[test] 332 + fn glob_matching() { 333 + assert!(glob_match("*.toml", "Cargo.toml")); 334 + assert!(glob_match("Makefile", "Makefile")); 335 + assert!(!glob_match("Makefile", "makefile")); 336 + assert!(!glob_match("*.rs", "main.py")); 337 + } 338 + 339 + #[test] 340 + fn merge_overrides_by_name_and_appends() { 341 + let base = r#" 342 + [[language]] 343 + name = "rust" 344 + grammar = "rust" 345 + scope = "source.rust" 346 + "#; 347 + let user = r#" 348 + [[language]] 349 + name = "rust" 350 + grammar = "rust-custom" 351 + 352 + [[language]] 353 + name = "quipu" 354 + grammar = "quipu" 355 + "#; 356 + let merged = merge_configs(base, Some(user)).unwrap(); 357 + let langs = merged["language"].as_array().unwrap(); 358 + assert_eq!(langs.len(), 2, "override merges, new language appends"); 359 + 360 + let rust = &langs[0]; 361 + // overlay wins for overlapping keys... 362 + assert_eq!(rust["grammar"].as_str(), Some("rust-custom")); 363 + // ...but base-only keys are preserved. 364 + assert_eq!(rust["scope"].as_str(), Some("source.rust")); 365 + 366 + assert_eq!(langs[1]["name"].as_str(), Some("quipu")); 367 + } 368 + }
+179
src/main.rs
··· 1 + //! dathan — highlight source code to HTML or Clojure/EDN Hiccup using Helix's 2 + //! tree-sitter grammars and queries. 3 + 4 + mod backend; 5 + mod grammar; 6 + mod highlight; 7 + mod languages; 8 + mod queries; 9 + mod runtime; 10 + mod theme; 11 + 12 + use std::path::{Path, PathBuf}; 13 + 14 + use anyhow::{anyhow, Context, Result}; 15 + use clap::{Parser, ValueEnum}; 16 + 17 + use backend::{Backend, EdnHiccupBackend, HtmlBackend}; 18 + use languages::Loader; 19 + use runtime::{home_dir, Runtime}; 20 + 21 + #[derive(Clone, Copy, ValueEnum)] 22 + enum Format { 23 + Html, 24 + EdnHiccup, 25 + } 26 + 27 + #[derive(Parser)] 28 + #[command( 29 + name = "dathan", 30 + about = "Highlight code to HTML or Clojure/EDN Hiccup via Helix grammars" 31 + )] 32 + struct Cli { 33 + /// Source file to highlight (omit only with --emit-css). 34 + file: Option<PathBuf>, 35 + 36 + /// Output format. 37 + #[arg(long, value_enum, default_value = "edn-hiccup")] 38 + format: Format, 39 + 40 + /// Override detected language by name (e.g. `rust`). 41 + #[arg(long)] 42 + lang: Option<String>, 43 + 44 + /// Extra runtime root(s); highest priority. May be repeated. 45 + #[arg(long)] 46 + runtime: Vec<PathBuf>, 47 + 48 + /// Path to a languages.toml (defaults to the Helix source/user config). 49 + #[arg(long)] 50 + languages: Option<PathBuf>, 51 + 52 + /// theme.toml to use for --emit-css. 53 + #[arg(long)] 54 + theme: Option<PathBuf>, 55 + 56 + /// Emit a CSS stylesheet from the theme and exit (ignores FILE). 57 + #[arg(long)] 58 + emit_css: bool, 59 + 60 + /// Output file (default: stdout). 61 + #[arg(short, long)] 62 + output: Option<PathBuf>, 63 + } 64 + 65 + fn main() -> Result<()> { 66 + let cli = Cli::parse(); 67 + let rt = Runtime::new(&cli.runtime); 68 + 69 + if cli.emit_css { 70 + let theme_path = resolve_theme(&cli, &rt)?; 71 + let theme_toml = std::fs::read_to_string(&theme_path) 72 + .with_context(|| format!("reading theme {}", theme_path.display()))?; 73 + let css = theme::to_css(&theme_toml)?; 74 + return write_output(cli.output.as_deref(), &css); 75 + } 76 + 77 + let file = cli 78 + .file 79 + .as_deref() 80 + .ok_or_else(|| anyhow!("a FILE argument is required (or use --emit-css)"))?; 81 + let source = 82 + std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?; 83 + 84 + let merged = load_languages(&cli)?; 85 + let loader = Loader::new(rt, merged)?; 86 + 87 + let lang = detect_language(&loader, &cli, file, &source).ok_or_else(|| { 88 + anyhow!( 89 + "could not determine language for {} (try --lang)", 90 + file.display() 91 + ) 92 + })?; 93 + 94 + let mut backend: Box<dyn Backend> = match cli.format { 95 + Format::Html => Box::new(HtmlBackend::new()), 96 + Format::EdnHiccup => Box::new(EdnHiccupBackend::new()), 97 + }; 98 + highlight::highlight(&loader, lang, &source, backend.as_mut())?; 99 + let rendered = backend.finish(); 100 + 101 + write_output(cli.output.as_deref(), &rendered) 102 + } 103 + 104 + fn detect_language( 105 + loader: &Loader, 106 + cli: &Cli, 107 + file: &Path, 108 + source: &str, 109 + ) -> Option<tree_house::Language> { 110 + if let Some(name) = &cli.lang { 111 + return loader.language_for_name(name); 112 + } 113 + loader.language_for_filename(file).or_else(|| { 114 + let first_line = source.lines().next().unwrap_or_default(); 115 + loader.language_for_shebang(first_line) 116 + }) 117 + } 118 + 119 + /// Load the base `languages.toml` and merge the user config over it (by 120 + /// language name), mirroring how Helix layers user config on the default. 121 + fn load_languages(cli: &Cli) -> Result<toml::Value> { 122 + let user_path = home_dir().map(|h| h.join(".config/helix/languages.toml")); 123 + 124 + let base_path = cli 125 + .languages 126 + .clone() 127 + .or_else(|| { 128 + first_existing([ 129 + home_dir().map(|h| h.join("source/helix/languages.toml")), 130 + user_path.clone(), 131 + ]) 132 + }) 133 + .ok_or_else(|| anyhow!("no languages.toml found; pass --languages <path>"))?; 134 + 135 + let base = std::fs::read_to_string(&base_path) 136 + .with_context(|| format!("reading {}", base_path.display()))?; 137 + 138 + // Overlay the user config unless it is already the base we loaded. 139 + let overlay = match user_path { 140 + Some(path) if path.exists() && path != base_path => Some( 141 + std::fs::read_to_string(&path) 142 + .with_context(|| format!("reading {}", path.display()))?, 143 + ), 144 + _ => None, 145 + }; 146 + 147 + languages::merge_configs(&base, overlay.as_deref()) 148 + } 149 + 150 + fn resolve_theme(cli: &Cli, rt: &Runtime) -> Result<PathBuf> { 151 + if let Some(path) = &cli.theme { 152 + return Ok(path.clone()); 153 + } 154 + let mut candidates = vec![home_dir().map(|h| h.join("source/helix/theme.toml"))]; 155 + // A theme bundled alongside any runtime root's parent. 156 + for root in rt.roots() { 157 + candidates.push(root.parent().map(|p| p.join("theme.toml"))); 158 + } 159 + first_existing(candidates).ok_or_else(|| anyhow!("no theme.toml found; pass --theme <path>")) 160 + } 161 + 162 + fn first_existing<I>(candidates: I) -> Option<PathBuf> 163 + where 164 + I: IntoIterator<Item = Option<PathBuf>>, 165 + { 166 + candidates.into_iter().flatten().find(|p| p.exists()) 167 + } 168 + 169 + fn write_output(output: Option<&Path>, content: &str) -> Result<()> { 170 + match output { 171 + Some(path) => { 172 + std::fs::write(path, content).with_context(|| format!("writing {}", path.display())) 173 + } 174 + None => { 175 + print!("{content}"); 176 + Ok(()) 177 + } 178 + } 179 + }
+20
src/queries.rs
··· 1 + //! Query loading with `; inherits:` resolution. 2 + //! 3 + //! `tree_house::read_query` does the recursive in-place `; inherits: a,b` 4 + //! expansion; we just supply a reader that pulls each language's query file 5 + //! from the runtime search path. 6 + 7 + use std::path::PathBuf; 8 + 9 + use crate::runtime::Runtime; 10 + 11 + /// Read `queries/<lang>/<filename>` with inherits expanded. Missing files 12 + /// resolve to an empty string, matching Helix. 13 + pub fn read_query(rt: &Runtime, lang: &str, filename: &str) -> String { 14 + tree_house::read_query(lang, |language| { 15 + let rel = PathBuf::from("queries").join(language).join(filename); 16 + rt.find_file(&rel) 17 + .and_then(|p| std::fs::read_to_string(p).ok()) 18 + .unwrap_or_default() 19 + }) 20 + }
+52
src/runtime.rs
··· 1 + //! Discovery of Helix runtime directories. 2 + //! 3 + //! Helix ships grammars at `runtime/grammars/<lang>.{dylib,so}` and queries at 4 + //! `runtime/queries/<lang>/{highlights,injections,locals}.scm`. Several runtime 5 + //! trees may coexist; user config shadows the source distribution. 6 + 7 + use std::path::{Path, PathBuf}; 8 + 9 + /// Platform shared-library extension for compiled grammars. 10 + #[cfg(target_os = "macos")] 11 + pub const DYLIB_EXT: &str = "dylib"; 12 + #[cfg(all(unix, not(target_os = "macos")))] 13 + pub const DYLIB_EXT: &str = "so"; 14 + #[cfg(windows)] 15 + pub const DYLIB_EXT: &str = "dll"; 16 + 17 + /// An ordered set of runtime roots. Earlier roots take precedence. 18 + pub struct Runtime { 19 + roots: Vec<PathBuf>, 20 + } 21 + 22 + impl Runtime { 23 + /// Build the runtime search path. `overrides` (from `--runtime`) win, then 24 + /// `$HELIX_RUNTIME`, then the user config runtime. 25 + pub fn new(overrides: &[PathBuf]) -> Self { 26 + let mut roots: Vec<PathBuf> = overrides.to_vec(); 27 + 28 + if let Some(env) = std::env::var_os("HELIX_RUNTIME") { 29 + roots.push(PathBuf::from(env)); 30 + } 31 + if let Some(home) = home_dir() { 32 + roots.push(home.join(".config/helix/runtime")); 33 + } 34 + 35 + roots.retain(|p| p.is_dir()); 36 + Runtime { roots } 37 + } 38 + 39 + /// First existing match for a path relative to a runtime root. 40 + pub fn find_file(&self, rel: &Path) -> Option<PathBuf> { 41 + self.roots.iter().map(|r| r.join(rel)).find(|p| p.exists()) 42 + } 43 + 44 + pub fn roots(&self) -> &[PathBuf] { 45 + &self.roots 46 + } 47 + } 48 + 49 + /// The user's home directory, if known. 50 + pub fn home_dir() -> Option<PathBuf> { 51 + std::env::var_os("HOME").map(PathBuf::from) 52 + }
+108
src/theme.rs
··· 1 + //! Optional Helix `theme.toml` -> CSS stylesheet. 2 + //! 3 + //! Each scope key becomes a rule whose selector matches the most specific class 4 + //! the HTML backend emits: `keyword.control` -> `.keyword-control`. Palette 5 + //! names are resolved via the theme's `[palette]` table. 6 + 7 + use std::collections::HashMap; 8 + 9 + use anyhow::{Context, Result}; 10 + use toml::Value; 11 + 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")?; 16 + 17 + let palette: HashMap<String, String> = table 18 + .get("palette") 19 + .and_then(Value::as_table) 20 + .map(|t| { 21 + t.iter() 22 + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) 23 + .collect() 24 + }) 25 + .unwrap_or_default(); 26 + 27 + let resolve = |color: &str| -> String { 28 + palette 29 + .get(color) 30 + .cloned() 31 + .unwrap_or_else(|| color.to_string()) 32 + }; 33 + 34 + let mut css = String::from("/* generated by dathan from theme.toml */\n"); 35 + let mut scopes: Vec<(&String, &Value)> = table 36 + .iter() 37 + .filter(|(k, _)| k.as_str() != "palette" && k.as_str() != "inherits") 38 + .collect(); 39 + scopes.sort_by_key(|(k, _)| k.as_str()); 40 + 41 + for (scope, style) in scopes { 42 + let mut decls = Vec::new(); 43 + 44 + match style { 45 + Value::String(color) => decls.push(format!("color: {}", resolve(color))), 46 + Value::Table(t) => { 47 + if let Some(fg) = t.get("fg").and_then(Value::as_str) { 48 + decls.push(format!("color: {}", resolve(fg))); 49 + } 50 + if let Some(bg) = t.get("bg").and_then(Value::as_str) { 51 + decls.push(format!("background-color: {}", resolve(bg))); 52 + } 53 + if let Some(mods) = t.get("modifiers").and_then(Value::as_array) { 54 + for m in mods.iter().filter_map(Value::as_str) { 55 + match m { 56 + "bold" => decls.push("font-weight: bold".into()), 57 + "italic" => decls.push("font-style: italic".into()), 58 + "underlined" => decls.push("text-decoration: underline".into()), 59 + "crossed_out" => decls.push("text-decoration: line-through".into()), 60 + "dim" => decls.push("opacity: 0.7".into()), 61 + _ => {} 62 + } 63 + } 64 + } 65 + if t.get("underline").is_some() { 66 + decls.push("text-decoration: underline".into()); 67 + } 68 + } 69 + _ => {} 70 + } 71 + 72 + if decls.is_empty() { 73 + continue; 74 + } 75 + css.push_str(&format!( 76 + ".{} {{ {}; }}\n", 77 + css_class(scope), 78 + decls.join("; ") 79 + )); 80 + } 81 + 82 + Ok(css) 83 + } 84 + 85 + /// `keyword.control` -> `keyword-control` (matches the HTML backend's most 86 + /// specific class). 87 + fn css_class(scope: &str) -> String { 88 + scope.replace('.', "-") 89 + } 90 + 91 + #[cfg(test)] 92 + mod tests { 93 + use super::*; 94 + 95 + #[test] 96 + fn palette_and_modifiers() { 97 + let theme = r##" 98 + "keyword.control" = { fg = "blue", modifiers = ["bold"] } 99 + comment = "gray" 100 + [palette] 101 + blue = "#0000ff" 102 + gray = "#808080" 103 + "##; 104 + let css = to_css(theme).unwrap(); 105 + assert!(css.contains(".keyword-control { color: #0000ff; font-weight: bold; }")); 106 + assert!(css.contains(".comment { color: #808080; }")); 107 + } 108 + }