Compare commits

...

18 Commits
0.1.5 ... main

Author SHA1 Message Date
Jan-Bulthuis
5bd36b5380 feat: release 0.2.0 2026-07-19 22:45:31 +02:00
Jan-Bulthuis
7c50234101 feat: shrink container size 2026-07-19 22:45:15 +02:00
Jan-Bulthuis
085ddaa0bf feat: add syntax and themes to container 2026-07-19 21:12:17 +02:00
Jan-Bulthuis
24666266d3 fix: various improvements 2026-07-19 20:59:09 +02:00
Jan-Bulthuis
9b50509910 feat: generate scopes based on applied theme 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
3711e99528 feat: apply code block background and foreground color 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
7798d556bc chore: clippy 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
b4a8084350 feat: compute scope intersection for theme pair 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
2bb0a5c099 feat: generate css from theme 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
2b0c852e99 feat: add themes derivation 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
30ca17e17f feat: read helix themes 2026-07-19 20:52:13 +02:00
Jan-Bulthuis
cac6d0ae31 feat: perform highlighting 2026-07-19 16:04:29 +02:00
Jan-Bulthuis
c1057be18e chore: clippy 2026-07-19 15:26:53 +02:00
Jan-Bulthuis
06173555f6 fix: limit loading of grammars 2026-07-19 15:25:44 +02:00
Jan-Bulthuis
308dcf2911 feat: load language grammars 2026-07-19 15:20:38 +02:00
Jan-Bulthuis
d5ecc05345 feat: setup for tree-sitter syntax highlighting 2026-07-19 15:20:38 +02:00
Jan-Bulthuis
853efc406d chore: remove directory 2026-07-19 14:01:42 +02:00
Jan-Bulthuis
035acb614f feat: add helix language configuration 2026-07-19 14:01:01 +02:00
8 changed files with 930 additions and 14 deletions

174
Cargo.lock generated
View File

@ -32,6 +32,12 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@ -100,6 +106,15 @@ dependencies = [
"object", "object",
] ]
[[package]]
name = "arc-swap"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@ -585,6 +600,12 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@ -733,6 +754,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@ -842,8 +869,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"foldhash", "foldhash 0.2.0",
"hashbrown", "hashbrown 0.17.1",
"log", "log",
"peniko", "peniko",
"png", "png",
@ -872,13 +899,24 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [ dependencies = [
"foldhash", "foldhash 0.2.0",
] ]
[[package]] [[package]]
@ -1341,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
"rayon", "rayon",
"serde", "serde",
"serde_core", "serde_core",
@ -1379,24 +1417,39 @@ dependencies = [
[[package]] [[package]]
name = "karnaugh" name = "karnaugh"
version = "0.1.5" version = "0.2.0"
dependencies = [ dependencies = [
"axum", "axum",
"clap", "clap",
"flate2", "flate2",
"itertools", "itertools",
"ropey",
"tar", "tar",
"time", "time",
"tokio", "tokio",
"toml",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tree-house",
"tree-house-bindings",
"typst", "typst",
"typst-html", "typst-html",
"typst-kit", "typst-kit",
"typst-library",
"typst-utils",
"ureq", "ureq",
] ]
[[package]]
name = "kstring"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e"
dependencies = [
"static_assertions",
]
[[package]] [[package]]
name = "kurbo" name = "kurbo"
version = "0.13.1" version = "0.13.1"
@ -1421,6 +1474,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@ -1822,6 +1885,16 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]] [[package]]
name = "proc-macro-hack" name = "proc-macro-hack"
version = "0.5.20+deprecated" version = "0.5.20+deprecated"
@ -1974,6 +2047,19 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "regex-cursor"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0497c781d2f982ae8284d2932aee6a877e58a4541daa5e8fadc18cc75c23a61d"
dependencies = [
"log",
"memchr",
"regex-automata",
"regex-syntax",
"ropey",
]
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.11" version = "0.8.11"
@ -2000,6 +2086,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e"
[[package]]
name = "ropey"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5"
dependencies = [
"smallvec",
"str_indices",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.21.1" version = "0.21.1"
@ -2073,6 +2169,12 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "rustybuzz" name = "rustybuzz"
version = "0.20.1" version = "0.20.1"
@ -2308,6 +2410,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "str_indices"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
[[package]] [[package]]
name = "strict-num" name = "strict-num"
version = "0.1.1" version = "0.1.1"
@ -2715,6 +2829,38 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "tree-house"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333476442882205ab249a8ced263ae53db3d0797c727cc3424836a6fb221d7b1"
dependencies = [
"arc-swap",
"hashbrown 0.15.5",
"kstring",
"once_cell",
"pretty_assertions",
"regex",
"regex-cursor",
"ropey",
"slab",
"tree-house-bindings",
"unicode-width",
]
[[package]]
name = "tree-house-bindings"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f5d0eed0db98578618e598b8f6f413f7c172bade1f12f8242d71c38dd5deb0f"
dependencies = [
"cc",
"libloading",
"regex-cursor",
"ropey",
"thiserror",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@ -3155,6 +3301,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@ -3282,7 +3434,7 @@ dependencies = [
"bytemuck", "bytemuck",
"fearless_simd", "fearless_simd",
"guillotiere", "guillotiere",
"hashbrown", "hashbrown 0.17.1",
"log", "log",
"peniko", "peniko",
"png", "png",
@ -3299,7 +3451,7 @@ dependencies = [
"bytemuck", "bytemuck",
"fearless_simd", "fearless_simd",
"guillotiere", "guillotiere",
"hashbrown", "hashbrown 0.17.1",
"log", "log",
"peniko", "peniko",
"png", "png",
@ -3315,7 +3467,7 @@ checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"glifo", "glifo",
"hashbrown", "hashbrown 0.17.1",
"png", "png",
"vello_common 0.0.8", "vello_common 0.0.8",
] ]
@ -3546,6 +3698,12 @@ dependencies = [
"linked-hash-map", "linked-hash-map",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "karnaugh" name = "karnaugh"
version = "0.1.5" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@ -23,3 +23,9 @@ typst-kit = "0.15.1"
typst = "0.15.1" typst = "0.15.1"
ureq = "3.3.0" ureq = "3.3.0"
clap = { version = "4.6.2", features = ["derive"] } clap = { version = "4.6.2", features = ["derive"] }
typst-library = "0.15.1"
typst-utils = "0.15.1"
tree-house = "0.4.0"
tree-house-bindings = { version = "0.3.2", features = ["ropey"] }
ropey = "1.6.1"
toml = "0.8.23"

43
flake.lock generated
View File

@ -18,6 +18,27 @@
"type": "github" "type": "github"
} }
}, },
"helix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1784219418,
"narHash": "sha256-yfNBfV6HWAQU/xZXwrFoedsSYsDmCDIlTHRxAZI6teY=",
"owner": "helix-editor",
"repo": "helix",
"rev": "f6f3eb1fe4a73933466f30175843059a4381a985",
"type": "github"
},
"original": {
"owner": "helix-editor",
"repo": "helix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784120854, "lastModified": 1784120854,
@ -37,9 +58,31 @@
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"helix": "helix",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
}, },
"rust-overlay": {
"inputs": {
"nixpkgs": [
"helix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1775358767,
"narHash": "sha256-f2eC+WIfhjevCPQILuV08i/kmKZzYZpUvkom/33VxCA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "20fd44bc663daa53a2575e01293e24e681d62244",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": { "systems": {
"locked": { "locked": {
"lastModified": 1681028828, "lastModified": 1681028828,

View File

@ -2,6 +2,8 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
helix.url = "github:helix-editor/helix";
helix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
@ -9,6 +11,7 @@
self, self,
nixpkgs, nixpkgs,
flake-utils, flake-utils,
helix,
... ...
}: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
@ -19,10 +22,54 @@
}; };
in in
rec { rec {
packages.syntax =
let
grammars = pkgs.callPackage "${helix}/grammars.nix" { };
in
pkgs.stdenv.mkDerivation {
name = "syntax";
nativeBuildInputs = [ pkgs.patchelf ];
phases = [
"buildPhase"
"installPhase"
];
buildPhase = ''
mkdir -p build
cp -r ${helix}/runtime/queries/* ./build/
chmod u+w ./build -R
for so in ${grammars}/*.so; do
name=$(basename "$so" .so)
if [ -d "./build/$name" ]; then
cp "$so" "./build/$name/$name.so"
chmod u+w "./build/$name/$name.so"
patchelf --shrink-rpath "./build/$name/$name.so"
fi
done
'';
installPhase = ''
mkdir -p $out
cp -r ./build/* $out/
'';
};
packages.themes = pkgs.stdenv.mkDerivation {
name = "themes";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out
cp ${helix}/runtime/themes/*.toml $out/
'';
};
packages.karnaugh = pkgs.rustPlatform.buildRustPackage (final: { packages.karnaugh = pkgs.rustPlatform.buildRustPackage (final: {
pname = "karnaugh"; pname = "karnaugh";
version = "0.1.5"; version = "0.2.0";
src = self; src = pkgs.lib.fileset.toSource {
root = ./.;
fileset = pkgs.lib.fileset.unions [
./Cargo.toml
./Cargo.lock
./src
];
};
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
}); });
packages.container = pkgs.dockerTools.buildImage { packages.container = pkgs.dockerTools.buildImage {
@ -36,7 +83,13 @@
}; };
config = { config = {
Entrypoint = [ "/bin/karnaugh" ]; Entrypoint = [
"/bin/karnaugh"
"--syntax-root"
"${packages.syntax}"
"--themes-root"
"${packages.themes}"
];
}; };
}; };
packages.default = packages.karnaugh; packages.default = packages.karnaugh;

View File

@ -25,6 +25,8 @@ use tracing_subscriber::FmtSubscriber;
use crate::typst::TypstContext; use crate::typst::TypstContext;
mod syntax;
mod themes;
mod typst; mod typst;
#[derive(Clone)] #[derive(Clone)]
@ -59,6 +61,14 @@ struct AppConfig {
#[arg(short = 'd', long, value_name = "DIR", default_value = "/tmp/karnaugh")] #[arg(short = 'd', long, value_name = "DIR", default_value = "/tmp/karnaugh")]
packages_root: PathBuf, packages_root: PathBuf,
/// The directory where the syntax files are stored.
#[arg(short = 'g', long, value_name = "DIR", default_value = None)]
syntax_root: Option<PathBuf>,
/// The directory where Helix theme `.toml` files are stored.
#[arg(short = 't', long, value_name = "DIR", default_value = None)]
themes_root: Option<PathBuf>,
/// The log level to use /// The log level to use
#[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)] #[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)]
log_level: Level, log_level: Level,
@ -94,6 +104,9 @@ async fn main() {
.with_max_level(state.config.log_level) .with_max_level(state.config.log_level)
.init(); .init();
syntax::init(&state.config);
themes::init(&state.config);
// TODO: Replace with config option // TODO: Replace with config option
let mut favicon = state.config.get_full_assets_path(); let mut favicon = state.config.get_full_assets_path();
favicon.push(PathBuf::from("favicon.svg")); favicon.push(PathBuf::from("favicon.svg"));
@ -117,7 +130,7 @@ async fn main() {
info!("Serving Karnaugh on socket {}", socket); info!("Serving Karnaugh on socket {}", socket);
let listener = tokio::net::TcpListener::bind(socket) let listener = tokio::net::TcpListener::bind(socket)
.await .await
.expect(&format!("Failed to bind to socket {}", socket)); .unwrap_or_else(|_| panic!("Failed to bind to socket {}", socket));
axum::serve(listener, app) axum::serve(listener, app)
.await .await
.expect("Failed to serve site"); .expect("Failed to serve site");

293
src/syntax.rs Normal file
View File

@ -0,0 +1,293 @@
use std::{
borrow::Cow,
collections::{BTreeSet, HashMap},
error::Error,
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock, RwLock},
time::Duration,
};
use ropey::Rope;
use tracing::{info, warn};
use tree_house::{
InjectionLanguageMarker, Language, LanguageConfig, LanguageLoader, Syntax,
highlighter::{Highlight, HighlightEvent, Highlighter},
read_query,
};
use tree_house_bindings::Grammar;
use typst::{
foundations::{Content, Func, NativeElement, NativeFunc, func},
text::TextElem,
};
use typst_html::{HtmlAttr, HtmlElem, attr, tag};
use crate::AppConfig;
/// Where grammars are loaded from, captured at `init`.
static SYNTAX_ROOT: OnceLock<PathBuf> = OnceLock::new();
/// LanguageRegistries keyed by ScopeSet
static REGISTRIES: OnceLock<RwLock<HashMap<BTreeSet<String>, Arc<LanguageRegistry>>>> =
OnceLock::new();
/// Get the index of the longest prefix of `scope` present in `scopes`.
fn highlight_for_scope(scope: &str, scopes: &[String]) -> Option<Highlight> {
let mut candidate = scope;
loop {
if let Some(i) = scopes.iter().position(|n| n == candidate) {
return Some(Highlight::new(i as u32));
}
match candidate.rfind('.') {
Some(dot) => candidate = &candidate[..dot],
None => return None,
}
}
}
struct LanguageRegistry {
syntax_root: PathBuf,
/// The scope vocabulary.
scopes: Vec<String>,
/// Name to Language mapping, Option to allow storing None for failed
/// configuration and preventing reloading.
by_name: RwLock<HashMap<String, Option<Language>>>,
configs: RwLock<Vec<&'static LanguageConfig>>,
}
impl LanguageRegistry {
fn new(syntax_root: PathBuf, scopes: Vec<String>) -> Self {
Self {
syntax_root,
scopes,
by_name: RwLock::new(HashMap::new()),
configs: RwLock::new(Vec::new()),
}
}
/// The `Language` for `name`, loading and configuring its grammar on first
/// request. Returns `None` if the grammar is missing or fails to load.
fn language(&self, name: &str) -> Option<Language> {
if let Some(cached) = self.by_name.read().unwrap().get(name) {
return *cached;
}
let loaded = self.load(name);
let mut by_name = self.by_name.write().unwrap();
if let Some(cached) = by_name.get(name) {
return *cached;
}
let language = loaded.map(|config| {
let mut configs = self.configs.write().unwrap();
configs.push(config);
Language::new((configs.len() - 1) as u32)
});
by_name.insert(name.to_owned(), language);
language
}
/// Loads and configures the grammar named `name`, leaking the config to a
/// `'static` borrow. `None` if the `.so` is absent or parsing fails.
fn load(&self, name: &str) -> Option<&'static LanguageConfig> {
let lang_dir = self.syntax_root.join(name);
if !lang_dir.join(format!("{name}.so")).exists() {
warn!("No grammar found for language {name}");
return None;
}
match load_language(name, &lang_dir, &self.syntax_root) {
Ok(config) => {
config.configure(|scope| highlight_for_scope(scope, &self.scopes));
Some(Box::leak(Box::new(config)))
}
Err(e) => {
warn!("Failed to load grammar {name}: {e}");
None
}
}
}
}
/// Fetch or build the registry configured for `scopes`.
fn registry_for(scopes: BTreeSet<String>) -> Option<Arc<LanguageRegistry>> {
let registries = REGISTRIES.get()?;
// Fast path: already built.
if let Some(registry) = registries.read().unwrap().get(&scopes) {
return Some(registry.clone());
}
let syntax_root = SYNTAX_ROOT.get()?;
let ordered = scopes.iter().cloned().collect();
let registry = Arc::new(LanguageRegistry::new(syntax_root.clone(), ordered));
Some(
registries
.write()
.unwrap()
.entry(scopes)
.or_insert(registry)
.clone(),
)
}
fn load_language(
name: &str,
lang_dir: &Path,
syntax_root: &Path,
) -> Result<LanguageConfig, Box<dyn Error>> {
let grammar = unsafe { Grammar::new(name, &lang_dir.join(format!("{name}.so")))? };
let read = |kind: &str| {
read_query(name, |lang| {
fs::read_to_string(syntax_root.join(lang).join(format!("{kind}.scm")))
.unwrap_or_default()
})
};
let config = LanguageConfig::new(
grammar,
&read("highlights"),
&read("injections"),
&read("locals"),
)?;
Ok(config)
}
impl LanguageLoader for LanguageRegistry {
fn language_for_marker(&self, marker: InjectionLanguageMarker<'_>) -> Option<Language> {
match marker {
InjectionLanguageMarker::Name(name) => self.language(name),
InjectionLanguageMarker::Match(text) => {
let name: Cow<str> = text.into();
self.language(name.as_ref())
}
_ => None,
}
}
fn get_config(&self, lang: Language) -> Option<&LanguageConfig> {
self.configs.read().unwrap().get(lang.idx()).copied()
}
}
pub fn init(config: &AppConfig) {
match &config.syntax_root {
Some(syntax_root) => {
info!("Loading syntax files from {}", syntax_root.display());
SYNTAX_ROOT.get_or_init(|| syntax_root.clone());
REGISTRIES.get_or_init(|| RwLock::new(HashMap::new()));
}
None => {
warn!(
"No syntax root has been configured, improved tree-sitter highlighting will not be possible."
)
}
}
}
pub fn highlight_func() -> Func {
highlight::func()
}
#[func]
fn highlight(
text: String,
#[named]
#[default(false)]
block: bool,
#[named]
#[default(None)]
lang: Option<String>,
#[named]
#[default(None)]
light: Option<String>,
#[named]
#[default(None)]
dark: Option<String>,
) -> Content {
let highlighted = highlight_code(&text, lang.as_deref(), light.as_deref(), dark.as_deref())
.unwrap_or_else(|| TextElem::packed(text));
if block {
HtmlElem::new(tag::pre)
.with_body(Some(highlighted))
.with_attr(HtmlAttr::constant("class"), "code-block")
.pack()
} else {
HtmlElem::new(tag::code)
.with_body(Some(highlighted))
.with_attr(HtmlAttr::constant("class"), "code-inline")
.pack()
}
}
/// Highlights `source` as `lang`.
fn highlight_code(
source: &str,
lang: Option<&str>,
light: Option<&str>,
dark: Option<&str>,
) -> Option<Content> {
let (lang, light, dark) = (lang?, light?, dark?);
let Some(scopes) = crate::themes::shared_scopes(light, dark) else {
warn!("Unknown theme in pair (light={light}, dark={dark})");
return None;
};
let registry = registry_for(scopes)?;
let language = registry.language(lang)?;
let rope = Rope::from_str(source);
let slice = rope.slice(..);
let loader = registry.as_ref();
let syntax = Syntax::new(slice, language, Duration::from_secs(3), loader).ok()?;
let mut hl = Highlighter::new(&syntax, slice, loader, 0..source.len() as u32);
let src = source.as_bytes();
let src_len = source.len() as u32;
let mut pos: u32 = 0;
let mut classes = String::new();
let mut nodes = Vec::<Content>::new();
loop {
let next = hl.next_event_offset().min(src_len);
if next > pos {
let text = std::str::from_utf8(&src[pos as usize..next as usize]).ok()?;
let node = TextElem::packed(text);
nodes.push(if classes.is_empty() {
node
} else {
HtmlElem::new(tag::span)
.with_attr(attr::class, classes.clone())
.with_body(Some(node))
.pack()
});
pos = next;
}
if pos >= src_len {
break;
}
let (event, highlights) = hl.advance();
let base = match event {
// Replace the active highlights
HighlightEvent::Refresh => String::new(),
// Extend the active highlights
HighlightEvent::Push => classes.clone(),
};
classes = highlights.fold(base, |mut acc, h| {
let name = registry.scopes[h.idx()].replace('.', "-");
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str("hl-");
acc.push_str(&name);
acc
});
}
Some(Content::sequence(nodes))
}

336
src/themes.rs Normal file
View File

@ -0,0 +1,336 @@
use std::{
collections::{BTreeSet, HashMap, HashSet},
fs,
path::Path,
sync::OnceLock,
};
use toml::{Table, Value};
use tracing::{info, warn};
use typst::foundations::{Func, NativeFunc, func};
use crate::AppConfig;
static REGISTRY: OnceLock<ThemeRegistry> = OnceLock::new();
#[derive(Debug)]
struct ThemeRegistry {
themes: HashMap<String, Theme>,
}
impl ThemeRegistry {
fn new(theme_root: &Path) -> Self {
let mut helix_themes = HashMap::new();
for entry in fs::read_dir(theme_root).into_iter().flatten().flatten() {
let name = entry
.path()
.file_prefix()
.unwrap()
.to_string_lossy()
.into_owned();
if let Ok(text) = fs::read_to_string(entry.path())
&& let Ok(toml) = toml::from_str::<Table>(&text)
{
helix_themes.insert(name, toml);
}
}
// resolve inheritance
while helix_themes
.values()
.any(|table| table.contains_key("inherits"))
{
let old_themes = helix_themes.clone();
old_themes
.keys()
.filter(|name| old_themes.get(*name).unwrap().contains_key("inherits"))
.for_each(|name| {
let parent_name = old_themes
.get(name)
.unwrap()
.get("inherits")
.unwrap()
.as_str()
.unwrap();
let parent = old_themes.get(parent_name).unwrap();
if !parent.contains_key("inherits") {
let table = helix_themes.get_mut(name).unwrap();
parent.into_iter().for_each(|(k, v)| {
if k == "palette" {
let parent_palette = v.as_table();
let child_palette = table
.entry(k.clone())
.or_insert_with(|| Value::Table(Table::new()))
.as_table_mut();
if let (Some(parent_palette), Some(child_palette)) =
(parent_palette, child_palette)
{
for (pk, pv) in parent_palette {
child_palette
.entry(pk.clone())
.or_insert_with(|| pv.clone());
}
}
} else if !table.contains_key(k) {
table.insert(k.clone(), v.clone());
}
});
table.remove("inherits");
}
});
}
let themes = helix_themes
.into_iter()
.flat_map(|(name, table)| match Theme::from_toml(table) {
Some(theme) => Some((name, theme)),
None => {
warn!("Theme {name} is missing an `ui.background`, skipping it");
None
}
})
.collect();
Self { themes }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Color(String);
impl Color {
fn from_str(color: &str) -> Option<Self> {
if color.starts_with('#') {
Some(Self(color.into()))
} else {
None
}
}
fn from_palette(color: &str, palette: &HashMap<String, Color>) -> Option<Self> {
if color.starts_with('#') {
Self::from_str(color)
} else if let Some(color) = palette.get(color) {
Some(color.clone())
} else {
// Neither a hex literal nor a palette entry; assume a named CSS
// colour and pass it through verbatim.
warn!("Unresolved colour {color}, passing it through as a literal");
Some(Self(color.into()))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Style {
fg: Option<Color>,
bold: bool,
italic: bool,
underlined: bool,
}
impl Style {
fn from_toml(style: Value, palette: &HashMap<String, Color>) -> Option<Self> {
let resolve = |s: &str| Color::from_palette(s, palette);
match style {
Value::String(s) => Some(Self {
fg: resolve(&s),
bold: false,
italic: false,
underlined: false,
}),
Value::Table(table) => {
let fg = table.get("fg").and_then(Value::as_str).and_then(resolve);
let modifiers: HashSet<&str> = table
.get("modifiers")
.and_then(Value::as_array)
.into_iter()
.flatten()
.flat_map(Value::as_str)
.collect();
Some(Self {
fg,
bold: modifiers.contains("bold"),
italic: modifiers.contains("italic"),
underlined: modifiers.contains("underlined"),
})
}
_ => None,
}
}
}
#[derive(Debug)]
struct Theme {
foreground: Option<Color>,
background: Option<Color>,
styles: HashMap<String, Style>,
}
static IGNORED_SCOPES: &[&str] = &["warning", "error", "info", "hint"];
impl Theme {
fn from_toml(theme: Table) -> Option<Self> {
let palette = theme
.get("palette")
.and_then(Value::as_table)
.cloned()
.map(|table| {
table
.into_iter()
.flat_map(|(k, v)| v.as_str().and_then(Color::from_str).map(|v| (k, v)))
.collect::<HashMap<String, Color>>()
})
.unwrap_or_default();
let resolve = |c: &str| Color::from_palette(c, &palette);
let ui_background = theme.get("ui.background").and_then(Value::as_table)?;
let foreground = ui_background
.get("fg")
.and_then(Value::as_str)
.and_then(resolve);
let background = ui_background
.get("bg")
.and_then(Value::as_str)
.and_then(resolve);
let styles = theme
.into_iter()
.filter(|(k, _)| {
!k.starts_with("ui.")
&& !k.starts_with("diagnostic.")
&& !IGNORED_SCOPES.contains(&k.as_str())
})
.flat_map(|(k, v)| Style::from_toml(v, &palette).map(|v| (k, v)))
.collect();
Some(Self {
foreground,
background,
styles,
})
}
}
pub fn init(config: &AppConfig) {
match &config.themes_root {
Some(themes_root) => {
info!("Loading themes from {}", themes_root.display());
REGISTRY.get_or_init(|| ThemeRegistry::new(themes_root));
}
None => {
warn!("No themes root has been configured, theme-based colouring will not be possible.")
}
}
}
/// The intersection of scopes between two themes.
pub fn shared_scopes(light: &str, dark: &str) -> Option<BTreeSet<String>> {
let registry = REGISTRY.get()?;
let light = registry.themes.get(light)?;
let dark = registry.themes.get(dark)?;
Some(
light
.styles
.keys()
.filter(|k| dark.styles.contains_key(*k))
.cloned()
.collect(),
)
}
pub fn theme_css_func() -> Func {
theme_css::func()
}
fn scope_to_selector(scope: &str) -> String {
format!(".hl-{}", scope.replace('.', "-"))
}
fn style_declarations(style: &Style) -> String {
let mut parts = Vec::new();
if let Some(Color(hex)) = &style.fg {
parts.push(format!("color:{hex}"));
}
parts.push(format!(
"font-weight:{}",
if style.bold { "bold" } else { "normal" }
));
parts.push(format!(
"font-style:{}",
if style.italic { "italic" } else { "normal" }
));
parts.push(format!(
"text-decoration:{}",
if style.underlined {
"underline"
} else {
"none"
}
));
parts.join(";")
}
fn generate_css(scopes: &[&str], theme: &Theme) -> String {
let mut rules = Vec::new();
// The theme's editor foreground/background colour the code block itself.
let mut block_style = Vec::new();
if let Some(Color(hex)) = &theme.foreground {
block_style.push(format!("color:{hex}"));
}
if let Some(Color(hex)) = &theme.background {
block_style.push(format!("background:{hex}"));
}
if !block_style.is_empty() {
rules.push(format!(".code-block{{{}}}", block_style.join(";")));
}
let mut groups: HashMap<&Style, Vec<&str>> = HashMap::new();
for &scope in scopes {
if let Some(style) = theme.styles.get(scope) {
groups.entry(style).or_default().push(scope);
}
}
let mut group_rules: Vec<String> = groups
.iter()
.map(|(style, scopes)| {
let selectors = scopes
.iter()
.map(|s| scope_to_selector(s))
.collect::<Vec<_>>()
.join(",");
format!("{}{{{}}}", selectors, style_declarations(style))
})
.collect();
// Sort so the emitted stylesheet is deterministic across runs.
group_rules.sort();
rules.extend(group_rules);
rules.join("\n")
}
#[func]
fn theme_css(light: String, dark: String) -> String {
let Some(registry) = REGISTRY.get() else {
return String::new();
};
let Some(light_theme) = registry.themes.get(&light) else {
warn!("Unknown light theme: {light}");
return String::new();
};
let Some(dark_theme) = registry.themes.get(&dark) else {
warn!("Unknown dark theme: {dark}");
return String::new();
};
let shared = shared_scopes(&light, &dark).unwrap_or_default();
let shared: Vec<&str> = shared.iter().map(String::as_str).collect();
let light_css = generate_css(&shared, light_theme);
let dark_css = generate_css(&shared, dark_theme);
format!("{light_css}\n@media (prefers-color-scheme:dark){{{dark_css}}}")
}

View File

@ -5,7 +5,7 @@ use tracing::info;
use typst::{ use typst::{
Library, LibraryExt, World, Library, LibraryExt, World,
diag::{FileError, FileResult, PackageError, PackageResult, SourceDiagnostic}, diag::{FileError, FileResult, PackageError, PackageResult, SourceDiagnostic},
foundations::{Bytes, Datetime, Duration}, foundations::{Bytes, Datetime, Dict, Duration, Value},
syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot, package::PackageSpec}, syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot, package::PackageSpec},
text::{Font, FontBook}, text::{Font, FontBook},
utils::LazyHash, utils::LazyHash,
@ -23,7 +23,21 @@ pub struct TypstContext {
impl TypstContext { impl TypstContext {
pub fn new(config: AppConfig) -> Self { pub fn new(config: AppConfig) -> Self {
let mut inputs = Dict::new();
if config.syntax_root.is_some() {
inputs.insert(
"highlight".into(),
Value::Func(crate::syntax::highlight_func()),
);
}
if config.themes_root.is_some() {
inputs.insert(
"theme_css".into(),
Value::Func(crate::themes::theme_css_func()),
);
}
let library = Library::builder() let library = Library::builder()
.with_inputs(inputs)
.with_features([typst::Feature::Html].into_iter().collect()) .with_features([typst::Feature::Html].into_iter().collect())
.build(); .build();
let library = LazyHash::new(library); let library = LazyHash::new(library);