use std::path::Path;
use std::sync::LazyLock;
use inkjet::Language;
use inkjet::constants::HIGHLIGHT_NAMES;
use inkjet::theme::{self, Modifier as InkMod, Style as InkStyle, Theme};
use inkjet::tree_sitter_highlight::{
HighlightConfiguration, HighlightEvent, Highlighter as TsHighlighter,
};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
static THEME: LazyLock<Theme> =
LazyLock::new(|| Theme::from_helix(theme::vendored::ONEDARK).expect("bundled theme is valid"));
fn helixify(query: &str) -> String {
query
.replace("@text.title", "@markup.heading")
.replace("@text.strong", "@markup.bold")
.replace("@text.emphasis", "@markup.italic")
.replace("@text.literal", "@markup.raw.inline")
.replace("@text.uri", "@markup.link.url")
.replace("@text.reference", "@markup.link.text")
.replace("@text.quote", "@markup.quote")
}
fn md_config(
lang: tree_sitter::Language,
name: &str,
highlights: &str,
injections: &str,
) -> Option<HighlightConfiguration> {
let mut c = HighlightConfiguration::new(lang, name, &helixify(highlights), injections, "").ok()?;
c.configure(HIGHLIGHT_NAMES);
Some(c)
}
static MD_BLOCK: LazyLock<Option<HighlightConfiguration>> = LazyLock::new(|| {
md_config(
tree_sitter_md::LANGUAGE.into(),
"markdown",
tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
tree_sitter_md::INJECTION_QUERY_BLOCK,
)
});
static MD_INLINE: LazyLock<Option<HighlightConfiguration>> = LazyLock::new(|| {
md_config(
tree_sitter_md::INLINE_LANGUAGE.into(),
"markdown_inline",
tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
tree_sitter_md::INJECTION_QUERY_INLINE,
)
});
#[derive(Clone, Copy, Debug)]
pub enum Syntax {
Lang(Language),
Markdown,
}
fn inject_config<'a>(name: &str) -> Option<&'a HighlightConfiguration> {
match name {
"markdown_inline" | "markdown-inline" | "inline" => MD_INLINE.as_ref(),
other => Language::from_token(other).map(|l| l.config()),
}
}
fn default_style() -> Style {
Style::default().fg(to_rgb(THEME.fg))
}
pub(crate) fn detect_language(path: &Path) -> Language {
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& let Some(lang) = Language::from_token(ext.to_ascii_lowercase())
{
return lang;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& let Some(lang) = Language::from_token(name.to_lowercase())
{
return lang;
}
Language::Plaintext
}
pub fn detect_syntax(path: &Path) -> Syntax {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "markdown" | "mdown" | "mkd" | "mkdn" | "mdwn"
) {
return Syntax::Markdown;
}
}
Syntax::Lang(detect_language(path))
}
pub struct CodeHighlighter {
inner: inkjet::Highlighter,
ts: TsHighlighter,
}
impl CodeHighlighter {
pub fn new() -> Self {
Self {
inner: inkjet::Highlighter::new(),
ts: TsHighlighter::new(),
}
}
pub fn highlight(&mut self, syntax: Syntax, source: &str) -> Vec<Line<'static>> {
let events: Option<Vec<HighlightEvent>> = match syntax {
Syntax::Lang(lang) => self
.inner
.highlight_raw(lang, &source)
.ok()
.map(|it| it.filter_map(Result::ok).collect()),
Syntax::Markdown => MD_BLOCK.as_ref().and_then(|cfg| {
self.ts
.highlight(cfg, source.as_bytes(), None, inject_config)
.ok()
.map(|it| it.filter_map(Result::ok).collect())
}),
};
match events {
Some(events) => render_events(source, events.into_iter()),
None => plain_lines(source),
}
}
}
fn render_events(source: &str, events: impl Iterator<Item = HighlightEvent>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
for event in events {
match event {
HighlightEvent::HighlightStart(h) => stack.push(h.0),
HighlightEvent::HighlightEnd => {
stack.pop();
}
HighlightEvent::Source { start, end } => {
let style = stack
.last()
.map(|&idx| style_for(idx))
.unwrap_or_else(default_style);
push_text(&mut lines, &mut current, &source[start..end], style);
}
}
}
lines.push(Line::from(std::mem::take(&mut current)));
lines
}
fn push_text(
lines: &mut Vec<Line<'static>>,
current: &mut Vec<Span<'static>>,
text: &str,
style: Style,
) {
let mut segments = text.split('\n');
if let Some(first) = segments.next()
&& !first.is_empty()
{
current.push(Span::styled(first.to_string(), style));
}
for seg in segments {
lines.push(Line::from(std::mem::take(current)));
if !seg.is_empty() {
current.push(Span::styled(seg.to_string(), style));
}
}
}
fn plain_lines(source: &str) -> Vec<Line<'static>> {
source
.split('\n')
.map(|l| Line::styled(l.to_string(), default_style()))
.collect()
}
fn style_for(idx: usize) -> Style {
let mut key = HIGHLIGHT_NAMES.get(idx).copied().unwrap_or("");
loop {
if let Some(style) = THEME.get_style(key) {
return convert(style);
}
match key.rfind('.') {
Some(pos) => key = &key[..pos],
None => return default_style(),
}
}
}
fn convert(style: &InkStyle) -> Style {
let mut out = Style::default().fg(style.fg.map(to_rgb).unwrap_or(to_rgb(THEME.fg)));
if let Some(bg) = style.bg {
out = out.bg(to_rgb(bg));
}
for m in &style.modifiers {
out = out.add_modifier(match m {
InkMod::Bold => Modifier::BOLD,
InkMod::Dim => Modifier::DIM,
InkMod::Italic => Modifier::ITALIC,
InkMod::Underlined => Modifier::UNDERLINED,
InkMod::SlowBlink => Modifier::SLOW_BLINK,
InkMod::FastBlink => Modifier::RAPID_BLINK,
InkMod::Reversed => Modifier::REVERSED,
InkMod::Hidden => Modifier::HIDDEN,
InkMod::Strikethrough => Modifier::CROSSED_OUT,
InkMod::Normal => Modifier::empty(),
});
}
out
}
fn to_rgb(c: theme::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn detect_by_extension() {
assert!(matches!(
detect_language(&PathBuf::from("src/main.rs")),
Language::Rust
));
assert!(matches!(
detect_language(&PathBuf::from("a/b/script.py")),
Language::Python
));
}
fn has_rgb(lines: &[Line]) -> bool {
lines.iter().any(|l| {
l.spans
.iter()
.any(|s| matches!(s.style.fg, Some(Color::Rgb(..))))
})
}
#[test]
fn highlights_rust_into_multiple_lines_with_color() {
let mut h = CodeHighlighter::new();
let src = "fn main() {\n let x = 1;\n}\n";
let lines = h.highlight(Syntax::Lang(Language::Rust), src);
assert!(lines.len() >= 3, "got {} lines", lines.len());
assert!(has_rgb(&lines), "expected at least one rgb-colored span");
}
#[test]
fn detect_markdown_extension() {
assert!(matches!(
detect_syntax(&PathBuf::from("docs/README.md")),
Syntax::Markdown
));
assert!(matches!(
detect_syntax(&PathBuf::from("a/b.rs")),
Syntax::Lang(Language::Rust)
));
}
#[test]
fn highlights_markdown_with_fenced_code() {
let mut h = CodeHighlighter::new();
let src = "# Title\n\nSome **bold** text.\n\n```rust\nfn main() {}\n```\n";
let lines = h.highlight(Syntax::Markdown, src);
assert!(lines.len() >= 6, "got {} lines", lines.len());
assert!(has_rgb(&lines), "expected markdown highlighting to add color");
}
fn span_count_of_line(lines: &[Line], needle: &str) -> Option<usize> {
lines.iter().find_map(|l| {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
(text.trim() == needle).then_some(l.spans.len())
})
}
#[test]
fn markdown_markup_itself_is_colored() {
let mut h = CodeHighlighter::new();
let lines = h.highlight(Syntax::Markdown, "# Heading\n\nsome **bold** word\n");
assert!(
has_rgb(&lines),
"expected markdown headings/emphasis to be colored"
);
}
#[test]
fn markdown_injects_language_into_code_fence() {
let mut h = CodeHighlighter::new();
let rust = h.highlight(Syntax::Markdown, "```rust\nfn main() {}\n```\n");
let unknown = h.highlight(Syntax::Markdown, "```zzqq\nfn main() {}\n```\n");
let rust_spans = span_count_of_line(&rust, "fn main() {}").expect("rust code line");
let unknown_spans = span_count_of_line(&unknown, "fn main() {}").expect("unknown code line");
assert!(
rust_spans > unknown_spans,
"expected rust injection to tokenize the fence (rust={rust_spans}, unknown={unknown_spans})"
);
}
}