···8080dathan src/main.rs
8181dathan --format html src/main.rs -o main.html
8282cat src/main.rs | dathan --lang rust
8383-dathan --emit-css --theme ~/source/helix/theme.toml -o theme.css
8484-dathan --format terminal --theme ~/source/helix/theme.toml src/main.rs
8383+dathan --emit-css --theme acid -o theme.css
8484+dathan --format terminal --theme ~/.config/helix/themes/acid.toml src/main.rs
8585dathan --theme acid src/main.rs
8686dathan --format html --inline src/main.rs -o main.html
8787```
···1041042. `~/.config/helix/runtime`
1051053. `$HELIX_RUNTIME`
106106107107-The language registry is read from `~/source/helix/languages.toml` (or
108108-`--languages`), with `~/.config/helix/languages.toml` merged over it by language
109109-name.
107107+The base language registry is `--languages` if given, otherwise a
108108+`languages.toml` next to the `$HELIX_RUNTIME` tree (as in a Helix source
109109+checkout), otherwise `~/.config/helix/languages.toml`. When the base comes from
110110+`--languages` or `$HELIX_RUNTIME`, `~/.config/helix/languages.toml` is merged
111111+over it by language name.
110112111111-Language detection uses the file extension, then `file-types` globs, then a
113113+Language detection uses `file-types` globs, then the file extension, then a
112114`#!` shebang line. Override with `--lang`.
113115114116## Output
+138-22
src/languages.rs
···172172 self.by_name.get(name).copied()
173173 }
174174175175- /// Detect by extension, then by glob/suffix `file-types` entries.
175175+ /// Detect by glob/suffix `file-types` entries first (the longest matching
176176+ /// pattern wins), then by extension, mirroring Helix's precedence.
176177 pub fn language_for_filename(&self, path: &Path) -> Option<Language> {
177177- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
178178- if let Some(&lang) = self.by_extension.get(ext) {
179179- return Some(lang);
180180- }
181181- }
178178+ let path_str = path.to_str().unwrap_or_default();
182179 let name = path
183180 .file_name()
184181 .and_then(|f| f.to_str())
185182 .unwrap_or_default();
183183+184184+ // (pattern length, language index) of the best glob/suffix hit so far.
185185+ let mut best: Option<(usize, usize)> = None;
186186 for (i, data) in self.langs.iter().enumerate() {
187187 for ft in &data.file_types {
188188 if let FileType::Special(map) = ft {
189189- let hit = map.get("glob").is_some_and(|g| glob_match(g, name))
190190- || map
191191- .get("suffix")
192192- .is_some_and(|s| name.ends_with(s.as_str()));
193193- if hit {
194194- return Some(Language::new(
195195- u32::try_from(i).expect("language index fits in u32"),
196196- ));
189189+ let hit = map
190190+ .get("glob")
191191+ .filter(|g| glob_match(g, path_str))
192192+ .or_else(|| map.get("suffix").filter(|s| name.ends_with(s.as_str())));
193193+ if let Some(pattern) = hit {
194194+ if best.is_none_or(|(len, _)| pattern.len() > len) {
195195+ best = Some((pattern.len(), i));
196196+ }
197197 }
198198 }
199199 }
200200 }
201201- None
201201+ if let Some((_, i)) = best {
202202+ return Some(Language::new(
203203+ u32::try_from(i).expect("language index fits in u32"),
204204+ ));
205205+ }
206206+207207+ path.extension()
208208+ .and_then(|e| e.to_str())
209209+ .and_then(|ext| self.by_extension.get(ext).copied())
202210 }
203211204212 /// Resolve an injected-language token: exact name first, then the longest
···266274 String::from(slice)
267275}
268276269269-/// Minimal glob: supports a leading `*` wildcard (suffix match) or an exact
270270-/// filename match. Sufficient for the `file-types` globs Helix ships.
271271-fn glob_match(pattern: &str, name: &str) -> bool {
272272- match pattern.strip_prefix('*') {
273273- Some(suffix) => name.ends_with(suffix),
274274- None => pattern == name,
277277+/// Match a Helix `file-types` glob against a path, mirroring how Helix uses
278278+/// `globset` with default settings: `*` matches any characters, `/` included.
279279+/// A pattern starting with `/` is anchored to the whole path; any other
280280+/// pattern matches from a path component boundary (Helix prepends `*/`), plus
281281+/// from the start so a bare filename like `.gitignore` still matches.
282282+fn glob_match(pattern: &str, path: &str) -> bool {
283283+ if pattern.starts_with('/') {
284284+ return wildcard_match(pattern, path);
275285 }
286286+ std::iter::once(0)
287287+ .chain(path.match_indices('/').map(|(i, _)| i + 1))
288288+ .any(|start| wildcard_match(pattern, &path[start..]))
289289+}
290290+291291+/// Wildcard match where `*` matches any (possibly empty) run of characters
292292+/// and everything else is literal. Greedy with backtracking. Operates on
293293+/// bytes, which is UTF-8 safe because `*` is the only metacharacter and it
294294+/// matches arbitrary byte runs.
295295+fn wildcard_match(pattern: &str, text: &str) -> bool {
296296+ let (p, t) = (pattern.as_bytes(), text.as_bytes());
297297+ let (mut pi, mut ti) = (0, 0);
298298+ // Position after the most recent `*` and the text position it matched at.
299299+ let mut star: Option<(usize, usize)> = None;
300300+ while ti < t.len() {
301301+ if pi < p.len() && p[pi] == b'*' {
302302+ star = Some((pi + 1, ti));
303303+ pi += 1;
304304+ } else if pi < p.len() && p[pi] == t[ti] {
305305+ pi += 1;
306306+ ti += 1;
307307+ } else if let Some((sp, st)) = star {
308308+ // Backtrack: let the last `*` swallow one more byte.
309309+ pi = sp;
310310+ ti = st + 1;
311311+ star = Some((sp, st + 1));
312312+ } else {
313313+ return false;
314314+ }
315315+ }
316316+ p[pi..].iter().all(|&b| b == b'*')
276317}
277318278319/// Deep-merge an `overlay` `languages.toml` onto a `base`, the way Helix merges
···334375335376 #[test]
336377 fn glob_matching() {
337337- assert!(glob_match("*.toml", "Cargo.toml"));
378378+ // Bare filename patterns, at any depth or as the whole path.
338379 assert!(glob_match("Makefile", "Makefile"));
380380+ assert!(glob_match("Makefile", "src/Makefile"));
339381 assert!(!glob_match("Makefile", "makefile"));
382382+ assert!(!glob_match("Makefile", "notMakefile"));
383383+ // `*` anywhere, including patterns Helix ships.
384384+ assert!(glob_match("*.toml", "dir/Cargo.toml"));
385385+ assert!(glob_match(".*ignore", ".gitignore"));
386386+ assert!(glob_match(".env.*", "repo/.env.local"));
387387+ assert!(glob_match("_environment-*", "_environment-prod"));
340388 assert!(!glob_match("*.rs", "main.py"));
389389+ // Path-qualified patterns match trailing components.
390390+ assert!(glob_match(".git/config", "repo/.git/config"));
391391+ assert!(glob_match(".git/config", ".git/config"));
392392+ assert!(!glob_match(".git/config", "config"));
393393+ assert!(glob_match(
394394+ ".git/modules/*/config",
395395+ ".git/modules/sub/config"
396396+ ));
397397+ // Absolute patterns anchor to the whole path.
398398+ assert!(glob_match("/etc/hosts", "/etc/hosts"));
399399+ assert!(!glob_match("/etc/hosts", "/home/x/etc/hosts"));
400400+ }
401401+402402+ fn detection_loader() -> Loader {
403403+ let config = r#"
404404+[[language]]
405405+name = "yaml"
406406+file-types = ["yml", "yaml"]
407407+408408+[[language]]
409409+name = "docker-compose"
410410+grammar = "yaml"
411411+file-types = [{ glob = "docker-compose.yml" }, { glob = "docker-compose.yaml" }]
412412+413413+[[language]]
414414+name = "gitignore"
415415+file-types = [{ glob = ".*ignore" }]
416416+417417+[[language]]
418418+name = "git-config"
419419+file-types = [{ glob = ".git/config" }]
420420+"#;
421421+ Loader::new(
422422+ crate::runtime::Runtime::new(&[]),
423423+ toml::from_str(config).unwrap(),
424424+ )
425425+ .unwrap()
426426+ }
427427+428428+ #[test]
429429+ fn globs_take_precedence_over_extensions() {
430430+ let loader = detection_loader();
431431+ let lang = |name: &str| loader.language_for_name(name).unwrap();
432432+433433+ // The glob beats the `yml` extension, at any depth.
434434+ assert_eq!(
435435+ loader.language_for_filename(Path::new("docker-compose.yml")),
436436+ Some(lang("docker-compose"))
437437+ );
438438+ assert_eq!(
439439+ loader.language_for_filename(Path::new("deploy/docker-compose.yml")),
440440+ Some(lang("docker-compose"))
441441+ );
442442+ // Extension fallback still works.
443443+ assert_eq!(
444444+ loader.language_for_filename(Path::new("ci.yml")),
445445+ Some(lang("yaml"))
446446+ );
447447+ // Wildcard and path-qualified globs.
448448+ assert_eq!(
449449+ loader.language_for_filename(Path::new(".gitignore")),
450450+ Some(lang("gitignore"))
451451+ );
452452+ assert_eq!(
453453+ loader.language_for_filename(Path::new("repo/.git/config")),
454454+ Some(lang("git-config"))
455455+ );
456456+ assert_eq!(loader.language_for_filename(Path::new("config")), None);
341457 }
342458343459 #[test]
+25-19
src/main.rs
···8585 return write_output(cli.output.as_deref(), &css);
8686 }
87878888+ if cli.inline && cli.format == Format::Terminal {
8989+ return Err(anyhow!(
9090+ "--inline applies only to the class-based formats (html, edn-hiccup, json-hiccup)"
9191+ ));
9292+ }
9393+8894 let file = cli.file.as_deref();
8995 let source = if let Some(path) = file {
9096 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?
···95101 .context("reading from stdin")?;
96102 buf
97103 };
9898-9999- if cli.inline && cli.format == Format::Terminal {
100100- return Err(anyhow!(
101101- "--inline applies only to the class-based formats (html, edn-hiccup, json-hiccup)"
102102- ));
103103- }
104104105105 let merged = load_languages(&cli)?;
106106···145145 })
146146}
147147148148-/// Load the base `languages.toml` and merge the user config over it (by
149149-/// language name), mirroring how Helix layers user config on the default.
148148+/// Load the base `languages.toml` (`--languages`, else the `$HELIX_RUNTIME`-
149149+/// adjacent config, else the user config) and, unless the user config is itself
150150+/// the base, merge it over the base by language name, mirroring how Helix layers
151151+/// user config on the default.
150152fn load_languages(cli: &Cli) -> Result<toml::Value> {
151153 let user_path = home_dir().map(|h| h.join(".config/helix/languages.toml"));
154154+155155+ // A full base `languages.toml` sits next to a Helix runtime tree (as in a
156156+ // source checkout referenced by `$HELIX_RUNTIME`); the user config is an
157157+ // overlay merged over it, so prefer the runtime-adjacent base when present.
158158+ let env_base = std::env::var_os("HELIX_RUNTIME")
159159+ .and_then(|rt| Path::new(&rt).parent().map(|p| p.join("languages.toml")));
152160153161 let base_path = cli
154162 .languages
155163 .clone()
156156- .or_else(|| {
157157- first_existing([
158158- home_dir().map(|h| h.join("source/helix/languages.toml")),
159159- user_path.clone(),
160160- ])
161161- })
164164+ .or_else(|| first_existing([env_base, user_path.clone()]))
162165 .ok_or_else(|| anyhow!("no languages.toml found; pass --languages <path>"))?;
163166164167 let base = std::fs::read_to_string(&base_path)
···195198 let dirs = theme_dirs(&path, rt);
196199 Theme::load(&path, &dirs)
197200 }
198198- Err(_) => Ok(Theme::bundled_default()),
201201+ // Only fall back when no theme was requested; a --theme that cannot
202202+ // be resolved is an error.
203203+ Err(_) if cli.theme.is_none() => Ok(Theme::bundled_default()),
204204+ Err(e) => Err(e),
199205 }
200206}
201207···214220 return first_existing(candidates)
215221 .ok_or_else(|| anyhow!("theme not found by path or name: {}", theme.display()));
216222 }
217217- let mut candidates = vec![home_dir().map(|h| h.join("source/helix/theme.toml"))];
218223 // A theme bundled alongside any runtime root's parent.
219219- for root in rt.roots() {
220220- candidates.push(root.parent().map(|p| p.join("theme.toml")));
221221- }
224224+ let candidates = rt
225225+ .roots()
226226+ .iter()
227227+ .map(|root| root.parent().map(|p| p.join("theme.toml")));
222228 first_existing(candidates).ok_or_else(|| anyhow!("no theme.toml found; pass --theme <path>"))
223229}
224230
+27-3
src/runtime.rs
···7575#[cfg(test)]
7676mod tests {
7777 use super::*;
7878+ use std::ffi::OsString;
7879 use std::fs;
79808081 /// Create `<root>/grammars/<marker>.txt` so `find_file` has something to hit,
···8788 root
8889 }
89909191+ /// Set an env var for the test, restoring the previous value on drop
9292+ /// (including on panic) so other tests in the process are unaffected.
9393+ struct EnvGuard {
9494+ key: &'static str,
9595+ prev: Option<OsString>,
9696+ }
9797+9898+ impl EnvGuard {
9999+ fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
100100+ let prev = std::env::var_os(key);
101101+ std::env::set_var(key, value);
102102+ Self { key, prev }
103103+ }
104104+ }
105105+106106+ impl Drop for EnvGuard {
107107+ fn drop(&mut self) {
108108+ match &self.prev {
109109+ Some(v) => std::env::set_var(self.key, v),
110110+ None => std::env::remove_var(self.key),
111111+ }
112112+ }
113113+ }
114114+90115 #[test]
91116 fn config_runtime_beats_helix_runtime_and_overrides_win() {
92117 let base = std::env::temp_dir().join(format!("dathan-rt-{}", std::process::id()));
···97122 let env_rt = seed_root(&base, "env-runtime", "env");
98123 let override_rt = seed_root(&base, "override", "override");
99124100100- std::env::set_var("HOME", &home);
101101- std::env::set_var("HELIX_RUNTIME", &env_rt);
125125+ let _home_guard = EnvGuard::set("HOME", &home);
126126+ let _runtime_guard = EnvGuard::set("HELIX_RUNTIME", &env_rt);
102127103128 let rel = Path::new("grammars/test.txt");
104129···121146 assert_eq!(fs::read_to_string(found).unwrap(), "override");
122147 assert_eq!(rt.override_roots(), &[override_rt]);
123148124124- std::env::remove_var("HELIX_RUNTIME");
125149 let _ = fs::remove_dir_all(&base);
126150 }
127151}
-2
src/backend/hiccup.rs
···4141 }
42424343 fn text(&mut self, text: &str) {
4444- // The driver coalesces runs of equal scope, but tree-sitter can still
4545- // split text at injection/event boundaries; merge adjacent literals.
4644 let literal = edn_string(text);
4745 if let Some(frame) = self.stack.last_mut() {
4846 frame.children.push(literal);
+4
src/backend/terminal.rs
···6868 }
69697070 fn text(&mut self, text: &str) {
7171+ // A no-op call must not disturb `line_dirty` / `ended_with_newline`.
7272+ if text.is_empty() {
7373+ return;
7474+ }
7175 let before = self.out.len();
7276 // Fill each line's trailing cells with the active background by erasing
7377 // to end-of-line before every newline.