11 Commits
7 changed files with 200 additions and 135 deletions

No files matched your search

Generated
+2 -1
View File
@@ -1417,7 +1417,7 @@ dependencies = [
[[package]] [[package]]
name = "karnaugh" name = "karnaugh"
version = "0.1.5" version = "0.2.0"
dependencies = [ dependencies = [
"axum", "axum",
"clap", "clap",
@@ -1427,6 +1427,7 @@ dependencies = [
"tar", "tar",
"time", "time",
"tokio", "tokio",
"toml",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
+2 -1
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]
@@ -28,3 +28,4 @@ typst-utils = "0.15.1"
tree-house = "0.4.0" tree-house = "0.4.0"
tree-house-bindings = { version = "0.3.2", features = ["ropey"] } tree-house-bindings = { version = "0.3.2", features = ["ropey"] }
ropey = "1.6.1" ropey = "1.6.1"
toml = "0.8.23"
Generated
+4 -18
View File
@@ -20,7 +20,9 @@
}, },
"helix": { "helix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs", "nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
}, },
"locked": { "locked": {
@@ -38,22 +40,6 @@
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1784120854, "lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=", "narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
@@ -73,7 +59,7 @@
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"helix": "helix", "helix": "helix",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs"
} }
}, },
"rust-overlay": { "rust-overlay": {
+26 -8
View File
@@ -3,6 +3,7 @@
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.url = "github:helix-editor/helix";
helix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
@@ -27,23 +28,27 @@
in in
pkgs.stdenv.mkDerivation { pkgs.stdenv.mkDerivation {
name = "syntax"; name = "syntax";
nativeBuildInputs = [ pkgs.patchelf ];
phases = [ phases = [
"buildPhase" "buildPhase"
"installPhase" "installPhase"
]; ];
buildPhase = '' buildPhase = ''
cp -r ${helix}/runtime/queries/* ./ mkdir -p build
chmod u+w ./* -R cp -r ${helix}/runtime/queries/* ./build/
chmod u+w ./build -R
for so in ${grammars}/*.so; do for so in ${grammars}/*.so; do
name=$(basename "$so" .so) name=$(basename "$so" .so)
if [ -d "./$name" ]; then if [ -d "./build/$name" ]; then
cp "$so" "./$name/$name.so" cp "$so" "./build/$name/$name.so"
chmod u+w "./build/$name/$name.so"
patchelf --shrink-rpath "./build/$name/$name.so"
fi fi
done done
''; '';
installPhase = '' installPhase = ''
mkdir -p $out mkdir -p $out
cp -r ./* $out/ cp -r ./build/* $out/
''; '';
}; };
packages.themes = pkgs.stdenv.mkDerivation { packages.themes = pkgs.stdenv.mkDerivation {
@@ -56,8 +61,15 @@
}; };
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 {
@@ -71,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;
-5
View File
@@ -69,11 +69,6 @@ struct AppConfig {
#[arg(short = 't', long, value_name = "DIR", default_value = None)] #[arg(short = 't', long, value_name = "DIR", default_value = None)]
themes_root: Option<PathBuf>, themes_root: Option<PathBuf>,
/// Grammars to load, separated by commas.
/// Useful to avoid the memory required to load all languages.
#[arg(short = 'L', long = "lang", value_name = "NAME", value_delimiter = ',')]
languages: Vec<String>,
/// 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,
+112 -84
View File
@@ -1,10 +1,10 @@
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::{HashMap, HashSet}, collections::{BTreeSet, HashMap},
error::Error, error::Error,
fs, fs,
path::Path, path::{Path, PathBuf},
sync::OnceLock, sync::{Arc, OnceLock, RwLock},
time::Duration, time::Duration,
}; };
@@ -24,48 +24,18 @@ use typst_html::{HtmlAttr, HtmlElem, attr, tag};
use crate::AppConfig; use crate::AppConfig;
static REGISTRY: OnceLock<LanguageRegistry> = OnceLock::new(); /// Where grammars are loaded from, captured at `init`.
static SYNTAX_ROOT: OnceLock<PathBuf> = OnceLock::new();
/// Class names used in highlighted code. /// LanguageRegistries keyed by ScopeSet
const HIGHLIGHT_NAMES: &[&str] = &[ static REGISTRIES: OnceLock<RwLock<HashMap<BTreeSet<String>, Arc<LanguageRegistry>>>> =
"attribute", OnceLock::new();
"comment",
"comment.block",
"comment.line",
"constant",
"constant.builtin",
"constructor",
"function",
"function.builtin",
"function.macro",
"function.method",
"keyword",
"keyword.control",
"keyword.operator",
"label",
"namespace",
"number",
"operator",
"property",
"punctuation",
"punctuation.bracket",
"punctuation.delimiter",
"special",
"string",
"string.special",
"tag",
"type",
"type.builtin",
"variable",
"variable.builtin",
"variable.parameter",
];
/// Get the longest matching highlight name for a given scope. /// Get the index of the longest prefix of `scope` present in `scopes`.
fn highlight_for_scope(scope: &str) -> Option<Highlight> { fn highlight_for_scope(scope: &str, scopes: &[String]) -> Option<Highlight> {
let mut candidate = scope; let mut candidate = scope;
loop { loop {
if let Some(i) = HIGHLIGHT_NAMES.iter().position(|&n| n == candidate) { if let Some(i) = scopes.iter().position(|n| n == candidate) {
return Some(Highlight::new(i as u32)); return Some(Highlight::new(i as u32));
} }
match candidate.rfind('.') { match candidate.rfind('.') {
@@ -76,49 +46,89 @@ fn highlight_for_scope(scope: &str) -> Option<Highlight> {
} }
struct LanguageRegistry { struct LanguageRegistry {
by_name: HashMap<String, Language>, syntax_root: PathBuf,
configs: Vec<LanguageConfig>, /// 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 { impl LanguageRegistry {
/// Loads grammars from `syntax_root`. When `languages` is non-empty, only fn new(syntax_root: PathBuf, scopes: Vec<String>) -> Self {
/// grammars with those names are loaded; otherwise every grammar found is. Self {
fn new(syntax_root: &Path, languages: &[String]) -> Self { syntax_root,
let filter: Option<HashSet<&str>> = scopes,
(!languages.is_empty()).then(|| languages.iter().map(String::as_str).collect()); by_name: RwLock::new(HashMap::new()),
configs: RwLock::new(Vec::new()),
let mut by_name = HashMap::new();
let mut configs = Vec::new();
for entry in fs::read_dir(syntax_root).into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if filter.as_ref().is_some_and(|f| !f.contains(name.as_str())) {
continue;
} }
let so_path = entry.path().join(format!("{name}.so"));
if !so_path.exists() {
continue;
} }
match load_language(&name, &entry.path(), syntax_root) { /// The `Language` for `name`, loading and configuring its grammar on first
Ok(config) => { /// request. Returns `None` if the grammar is missing or fails to load.
by_name.insert(name, Language::new(configs.len() as u32)); 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); configs.push(config);
} Language::new((configs.len() - 1) as u32)
Err(e) => warn!("Failed to load grammar {name}: {e}"), });
} by_name.insert(name.to_owned(), language);
language
} }
// Warn about explicitly requested grammars that were not found. /// Loads and configures the grammar named `name`, leaking the config to a
for lang in languages { /// `'static` borrow. `None` if the `.so` is absent or parsing fails.
if !by_name.contains_key(lang) { fn load(&self, name: &str) -> Option<&'static LanguageConfig> {
warn!("Requested grammar {lang} was not found in the syntax root"); 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());
} }
info!("Loaded {} syntax highlighting grammars", configs.len()); let syntax_root = SYNTAX_ROOT.get()?;
Self { by_name, configs } 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( fn load_language(
@@ -141,7 +151,6 @@ fn load_language(
&read("injections"), &read("injections"),
&read("locals"), &read("locals"),
)?; )?;
config.configure(highlight_for_scope);
Ok(config) Ok(config)
} }
@@ -149,17 +158,17 @@ fn load_language(
impl LanguageLoader for LanguageRegistry { impl LanguageLoader for LanguageRegistry {
fn language_for_marker(&self, marker: InjectionLanguageMarker<'_>) -> Option<Language> { fn language_for_marker(&self, marker: InjectionLanguageMarker<'_>) -> Option<Language> {
match marker { match marker {
InjectionLanguageMarker::Name(name) => self.by_name.get(name).copied(), InjectionLanguageMarker::Name(name) => self.language(name),
InjectionLanguageMarker::Match(text) => { InjectionLanguageMarker::Match(text) => {
let name: Cow<str> = text.into(); let name: Cow<str> = text.into();
self.by_name.get(name.as_ref()).copied() self.language(name.as_ref())
} }
_ => None, _ => None,
} }
} }
fn get_config(&self, lang: Language) -> Option<&LanguageConfig> { fn get_config(&self, lang: Language) -> Option<&LanguageConfig> {
self.configs.get(lang.idx()) self.configs.read().unwrap().get(lang.idx()).copied()
} }
} }
@@ -167,7 +176,8 @@ pub fn init(config: &AppConfig) {
match &config.syntax_root { match &config.syntax_root {
Some(syntax_root) => { Some(syntax_root) => {
info!("Loading syntax files from {}", syntax_root.display()); info!("Loading syntax files from {}", syntax_root.display());
REGISTRY.get_or_init(|| LanguageRegistry::new(syntax_root, &config.languages)); SYNTAX_ROOT.get_or_init(|| syntax_root.clone());
REGISTRIES.get_or_init(|| RwLock::new(HashMap::new()));
} }
None => { None => {
warn!( warn!(
@@ -190,8 +200,15 @@ fn highlight(
#[named] #[named]
#[default(None)] #[default(None)]
lang: Option<String>, lang: Option<String>,
#[named]
#[default(None)]
light: Option<String>,
#[named]
#[default(None)]
dark: Option<String>,
) -> Content { ) -> Content {
let highlighted = highlight_code(&text, lang.as_deref()).unwrap_or(TextElem::packed(text)); let highlighted = highlight_code(&text, lang.as_deref(), light.as_deref(), dark.as_deref())
.unwrap_or_else(|| TextElem::packed(text));
if block { if block {
HtmlElem::new(tag::pre) HtmlElem::new(tag::pre)
@@ -207,14 +224,25 @@ fn highlight(
} }
/// Highlights `source` as `lang`. /// Highlights `source` as `lang`.
fn highlight_code(source: &str, lang: Option<&str>) -> Option<Content> { fn highlight_code(
let registry = REGISTRY.get()?; source: &str,
let language = *registry.by_name.get(lang?)?; 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 rope = Rope::from_str(source);
let slice = rope.slice(..); let slice = rope.slice(..);
let syntax = Syntax::new(slice, language, Duration::from_millis(500), registry).ok()?; let loader = registry.as_ref();
let mut hl = Highlighter::new(&syntax, slice, registry, 0..source.len() as u32); 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 = source.as_bytes();
let src_len = source.len() as u32; let src_len = source.len() as u32;
@@ -251,7 +279,7 @@ fn highlight_code(source: &str, lang: Option<&str>) -> Option<Content> {
HighlightEvent::Push => classes.clone(), HighlightEvent::Push => classes.clone(),
}; };
classes = highlights.fold(base, |mut acc, h| { classes = highlights.fold(base, |mut acc, h| {
let name = HIGHLIGHT_NAMES[h.idx()].replace('.', "-"); let name = registry.scopes[h.idx()].replace('.', "-");
if !acc.is_empty() { if !acc.is_empty() {
acc.push(' '); acc.push(' ');
} }
+54 -18
View File
@@ -1,6 +1,5 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{BTreeSet, HashMap, HashSet},
error::Error,
fs, fs,
path::Path, path::Path,
sync::OnceLock, sync::OnceLock,
@@ -8,10 +7,7 @@ use std::{
use toml::{Table, Value}; use toml::{Table, Value};
use tracing::{info, warn}; use tracing::{info, warn};
use typst::{ use typst::foundations::{Func, NativeFunc, func};
foundations::{Content, Func, NativeElement, NativeFunc, func},
text::TextElem,
};
use crate::AppConfig; use crate::AppConfig;
@@ -86,7 +82,13 @@ impl ThemeRegistry {
let themes = helix_themes let themes = helix_themes
.into_iter() .into_iter()
.flat_map(|(name, table)| Theme::from_toml(table).map(|t| (name, t))) .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(); .collect();
Self { themes } Self { themes }
@@ -108,8 +110,13 @@ impl Color {
fn from_palette(color: &str, palette: &HashMap<String, Color>) -> Option<Self> { fn from_palette(color: &str, palette: &HashMap<String, Color>) -> Option<Self> {
if color.starts_with('#') { if color.starts_with('#') {
Self::from_str(color) Self::from_str(color)
} else if let Some(color) = palette.get(color) {
Some(color.clone())
} else { } else {
palette.get(color).cloned() // 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()))
} }
} }
} }
@@ -218,6 +225,21 @@ pub fn init(config: &AppConfig) {
} }
} }
/// 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 { pub fn theme_css_func() -> Func {
theme_css::func() theme_css::func()
} }
@@ -251,13 +273,28 @@ fn style_declarations(style: &Style) -> String {
} }
fn generate_css(scopes: &[&str], theme: &Theme) -> String { 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(); let mut groups: HashMap<&Style, Vec<&str>> = HashMap::new();
for &scope in scopes { for &scope in scopes {
if let Some(style) = theme.styles.get(scope) { if let Some(style) = theme.styles.get(scope) {
groups.entry(style).or_default().push(scope); groups.entry(style).or_default().push(scope);
} }
} }
groups
let mut group_rules: Vec<String> = groups
.iter() .iter()
.map(|(style, scopes)| { .map(|(style, scopes)| {
let selectors = scopes let selectors = scopes
@@ -267,8 +304,12 @@ fn generate_css(scopes: &[&str], theme: &Theme) -> String {
.join(","); .join(",");
format!("{}{{{}}}", selectors, style_declarations(style)) format!("{}{{{}}}", selectors, style_declarations(style))
}) })
.collect::<Vec<_>>() .collect();
.join("\n") // Sort so the emitted stylesheet is deterministic across runs.
group_rules.sort();
rules.extend(group_rules);
rules.join("\n")
} }
#[func] #[func]
@@ -285,13 +326,8 @@ fn theme_css(light: String, dark: String) -> String {
return String::new(); return String::new();
}; };
let mut shared: Vec<&str> = light_theme let shared = shared_scopes(&light, &dark).unwrap_or_default();
.styles let shared: Vec<&str> = shared.iter().map(String::as_str).collect();
.keys()
.filter(|k| dark_theme.styles.contains_key(*k))
.map(String::as_str)
.collect();
shared.sort();
let light_css = generate_css(&shared, light_theme); let light_css = generate_css(&shared, light_theme);
let dark_css = generate_css(&shared, dark_theme); let dark_css = generate_css(&shared, dark_theme);