use ratatui::style::{Color, Modifier, Style};
use tree_sitter_highlight::{
HighlightConfiguration, HighlightEvent, Highlighter as TsHighlighter,
};
const HIGHLIGHT_NAMES: &[&str] = &[
"constant.numeric",
"constant.character.escape",
"constant.character",
"constant.builtin.boolean",
"constant.builtin",
"constant",
"string",
"function.method",
"function",
"keyword.control.conditional",
"keyword.control.repeat",
"keyword.control.import",
"keyword.control",
"keyword.storage.type",
"keyword.operator",
"keyword",
"operator",
"tag",
"variable",
"markup.heading.marker",
"markup.heading.1",
"markup.heading.2",
"markup.heading.3",
"markup.heading.4",
"markup.heading.5",
"markup.heading.6",
"markup.heading",
"markup.bold",
"markup.italic",
"markup.quote",
"markup.raw.block",
"markup.raw",
"markup.list",
"comment",
"punctuation.bracket",
"punctuation.delimiter",
"punctuation",
];
fn style_for_name(name: &str, theme: &super::theme::Theme) -> Style {
match name {
"comment" => Style::default()
.fg(theme.syntax_comment)
.add_modifier(Modifier::ITALIC),
"string" => Style::default().fg(theme.syntax_string),
"constant.numeric" => Style::default().fg(theme.syntax_number),
"constant.character.escape" => Style::default().fg(theme.syntax_operator),
"constant.character" => Style::default().fg(theme.syntax_number),
"constant.builtin.boolean" | "constant.builtin" | "constant" => {
Style::default().fg(theme.syntax_number)
}
"function" | "function.method" => Style::default().fg(theme.syntax_function),
"keyword.control.conditional" | "keyword.control.repeat" | "keyword.control.import"
| "keyword.control" | "keyword.storage.type" | "keyword" => Style::default()
.fg(theme.syntax_keyword)
.add_modifier(Modifier::BOLD),
"keyword.operator" => Style::default().fg(theme.syntax_keyword),
"operator" => Style::default().fg(theme.syntax_operator),
"tag" => Style::default().fg(theme.syntax_tag),
"variable" => Style::default(),
"markup.heading.marker" => Style::default()
.fg(theme.syntax_heading)
.add_modifier(Modifier::DIM),
"markup.heading.1" => Style::default()
.fg(theme.syntax_heading)
.add_modifier(Modifier::BOLD),
"markup.heading.2" => Style::default()
.fg(theme.syntax_heading)
.add_modifier(Modifier::BOLD),
"markup.heading.3" | "markup.heading.4" | "markup.heading.5" | "markup.heading.6"
| "markup.heading" => Style::default().fg(theme.syntax_heading),
"markup.bold" => Style::default()
.fg(theme.syntax_bold)
.add_modifier(Modifier::BOLD),
"markup.italic" => Style::default()
.fg(theme.syntax_italic)
.add_modifier(Modifier::ITALIC),
"markup.quote" => Style::default()
.fg(theme.syntax_quote)
.add_modifier(Modifier::ITALIC),
"markup.raw.block" | "markup.raw" => {
Style::default().fg(theme.syntax_raw).bg(Color::Reset)
}
"markup.list" => Style::default().fg(theme.syntax_list_marker),
"punctuation.bracket" | "punctuation.delimiter" | "punctuation" => {
Style::default().add_modifier(Modifier::DIM)
}
_ => Style::default(),
}
}
#[derive(Debug, Clone)]
pub struct StyledRun {
pub text: String,
pub style: Style,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockSelection {
pub row_min: usize,
pub row_max: usize,
pub col_min: usize,
pub col_max: usize,
}
impl BlockSelection {
pub fn from_anchor_and_cursor(anchor: (usize, usize), cursor: (usize, usize)) -> Self {
let (a_r, a_c) = anchor;
let (c_r, c_c) = cursor;
Self {
row_min: a_r.min(c_r),
row_max: a_r.max(c_r),
col_min: a_c.min(c_c),
col_max: a_c.max(c_c),
}
}
pub fn contains(&self, row: usize, col: usize) -> bool {
row >= self.row_min && row <= self.row_max && col >= self.col_min && col <= self.col_max
}
}
#[derive(Debug, Clone)]
pub struct VisualRow {
pub runs: Vec<StyledRun>,
pub src_row: usize,
pub src_col_start: usize,
pub width_chars: usize,
}
pub fn wrap_line(runs: &[StyledRun], src_row: usize, width: usize) -> Vec<VisualRow> {
if width == 0 {
return vec![VisualRow {
runs: runs.to_vec(),
src_row,
src_col_start: 0,
width_chars: runs.iter().map(|r| r.text.chars().count()).sum(),
}];
}
let chars: Vec<(char, Style)> = runs
.iter()
.flat_map(|r| r.text.chars().map(move |c| (c, r.style)))
.collect();
if chars.is_empty() {
return vec![VisualRow {
runs: Vec::new(),
src_row,
src_col_start: 0,
width_chars: 0,
}];
}
let mut out: Vec<VisualRow> = Vec::new();
let mut i = 0usize;
while i < chars.len() {
let remaining = chars.len() - i;
let take = remaining.min(width);
let mut end = i + take;
if end < chars.len() {
if let Some(rel) = chars[i..end]
.iter()
.rposition(|(c, _)| c.is_whitespace())
{
end = i + rel + 1;
}
}
let segment = &chars[i..end];
let mut row_runs: Vec<StyledRun> = Vec::new();
for (c, style) in segment {
if let Some(last) = row_runs.last_mut() {
if last.style == *style {
last.text.push(*c);
continue;
}
}
row_runs.push(StyledRun {
text: c.to_string(),
style: *style,
});
}
out.push(VisualRow {
runs: row_runs,
src_row,
src_col_start: i,
width_chars: end - i,
});
i = end;
}
out
}
pub type AddedFlags<'a> = Option<&'a [bool]>;
#[derive(Debug, Clone, Copy)]
pub struct RowHit {
pub col_start: usize,
pub col_end: usize,
pub is_current: bool,
}
fn match_style_at(
row_hits: &[RowHit],
col: usize,
theme: &super::theme::Theme,
) -> Option<Style> {
for hit in row_hits {
if col >= hit.col_start && col < hit.col_end {
return Some(if hit.is_current {
Style::default()
.bg(theme.search_current_bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(theme.search_match_bg).fg(Color::Black)
});
}
}
None
}
fn lex_style_at(
hits: &[super::lexicon::LexHit],
col: usize,
theme: &super::theme::Theme,
) -> Option<Style> {
use super::lexicon::LexCategory;
fn rank(c: LexCategory) -> u8 {
match c {
LexCategory::Place => 4,
LexCategory::Character => 3,
LexCategory::Artefact => 2,
LexCategory::Note => 1,
}
}
let mut chosen: Option<LexCategory> = None;
for hit in hits {
if col >= hit.col_start && col < hit.col_end {
chosen = Some(match chosen {
Some(prev) if rank(prev) >= rank(hit.category) => prev,
_ => hit.category,
});
}
}
chosen.map(|cat| match cat {
LexCategory::Place => Style::default()
.fg(theme.places_fg)
.add_modifier(Modifier::BOLD),
LexCategory::Character => Style::default()
.fg(theme.characters_fg)
.add_modifier(Modifier::BOLD),
LexCategory::Artefact => Style::default()
.fg(theme.artefacts_fg)
.add_modifier(Modifier::BOLD),
LexCategory::Note => Style::default()
.fg(theme.notes_underline_fg)
.add_modifier(Modifier::UNDERLINED),
})
}
pub fn diff_added(saved: &str, current: &str) -> Vec<bool> {
let s: Vec<char> = saved.chars().collect();
let c: Vec<char> = current.chars().collect();
let prefix = s.iter().zip(c.iter()).take_while(|(a, b)| a == b).count();
let s_rem = &s[prefix..];
let c_rem = &c[prefix..];
let suffix = s_rem
.iter()
.rev()
.zip(c_rem.iter().rev())
.take_while(|(a, b)| a == b)
.count();
let mut flags = vec![false; c.len()];
let end = c.len().saturating_sub(suffix);
for f in &mut flags[prefix..end] {
*f = true;
}
flags
}
pub fn build_visual_row_spans(
row: &VisualRow,
selection: Option<((usize, usize), (usize, usize))>,
block: Option<BlockSelection>,
added: AddedFlags,
matches: &[RowHit],
lex_hits: &[super::lexicon::LexHit],
correction: AddedFlags,
theme: &super::theme::Theme,
) -> Vec<ratatui::text::Span<'static>> {
use ratatui::text::Span;
let sel_range_in_row: Option<(usize, usize)> = selection.and_then(|((r1, c1), (r2, c2))| {
let row_start = row.src_col_start;
let row_end = row.src_col_start + row.width_chars;
if row.src_row < r1 || row.src_row > r2 {
return None;
}
let sel_start = if row.src_row == r1 { c1 } else { 0 };
let sel_end = if row.src_row == r2 { c2 } else { usize::MAX };
let s = sel_start.max(row_start);
let e = sel_end.min(row_end);
if s >= e {
None
} else {
Some((s - row_start, e - row_start))
}
});
let mut out: Vec<Span<'static>> = Vec::new();
let mut visual_col = 0usize;
for run in &row.runs {
for c in run.text.chars() {
let src_col = row.src_col_start + visual_col;
let is_selected =
sel_range_in_row.is_some_and(|(s, e)| visual_col >= s && visual_col < e);
let is_block = block.is_some_and(|b| b.contains(row.src_row, src_col));
let is_added = added
.and_then(|flags| flags.get(src_col).copied())
.unwrap_or(false);
let mut style = run.style;
if is_added {
style = style.add_modifier(Modifier::BOLD);
}
if let Some(lex_style) = lex_style_at(lex_hits, src_col, theme) {
style = style.patch(lex_style);
}
let is_corrected = correction
.and_then(|flags| flags.get(src_col).copied())
.unwrap_or(false);
if is_corrected {
style = style
.fg(theme.grammar_change_fg)
.add_modifier(Modifier::BOLD);
}
if let Some(match_style) = match_style_at(matches, src_col, theme) {
style = style.patch(match_style);
}
if is_selected || is_block {
style = style.add_modifier(Modifier::REVERSED);
}
if let Some(last) = out.last_mut() {
if last.style == style {
last.content.to_mut().push(c);
visual_col += 1;
continue;
}
}
out.push(Span::styled(c.to_string(), style));
visual_col += 1;
}
}
out
}
pub fn build_row_spans(
runs: &[StyledRun],
row: usize,
scroll_col: usize,
width: usize,
selection: Option<((usize, usize), (usize, usize))>,
block: Option<BlockSelection>,
added: AddedFlags,
matches: &[RowHit],
lex_hits: &[super::lexicon::LexHit],
correction: AddedFlags,
theme: &super::theme::Theme,
) -> Vec<ratatui::text::Span<'static>> {
use ratatui::text::Span;
if width == 0 {
return Vec::new();
}
let sel_range: Option<(usize, usize)> = selection.and_then(|((r1, c1), (r2, c2))| {
if row < r1 || row > r2 {
return None;
}
let start = if row == r1 { c1 } else { 0 };
let end = if row == r2 { c2 } else { usize::MAX };
if start >= end {
None
} else {
Some((start, end))
}
});
let mut out: Vec<Span<'static>> = Vec::new();
let mut col = 0usize;
let viewport_end = scroll_col.saturating_add(width);
for run in runs {
let chars: Vec<char> = run.text.chars().collect();
let run_start = col;
let run_end = col + chars.len();
if run_end <= scroll_col {
col = run_end;
continue;
}
if run_start >= viewport_end {
break;
}
let chunk_start = run_start.max(scroll_col);
let chunk_end = run_end.min(viewport_end);
for src_col in chunk_start..chunk_end {
let rel = src_col - run_start;
let ch = chars[rel];
let is_selected = sel_range.is_some_and(|(s, e)| src_col >= s && src_col < e);
let is_block = block.is_some_and(|b| b.contains(row, src_col));
let is_added = added
.and_then(|flags| flags.get(src_col).copied())
.unwrap_or(false);
let mut style = run.style;
if is_added {
style = style.add_modifier(Modifier::BOLD);
}
if let Some(lex_style) = lex_style_at(lex_hits, src_col, theme) {
style = style.patch(lex_style);
}
let is_corrected = correction
.and_then(|flags| flags.get(src_col).copied())
.unwrap_or(false);
if is_corrected {
style = style
.fg(theme.grammar_change_fg)
.add_modifier(Modifier::BOLD);
}
if let Some(match_style) = match_style_at(matches, src_col, theme) {
style = style.patch(match_style);
}
if is_selected || is_block {
style = style.add_modifier(Modifier::REVERSED);
}
if let Some(last) = out.last_mut() {
if last.style == style {
last.content.to_mut().push(ch);
continue;
}
}
out.push(Span::styled(ch.to_string(), style));
}
col = run_end;
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypstMode {
Markup,
Code,
Math,
}
impl TypstMode {
pub fn call_prefix(self) -> &'static str {
match self {
TypstMode::Markup => "#",
TypstMode::Code | TypstMode::Math => "",
}
}
}
pub fn typst_mode_at(source: &str, byte_offset: usize) -> TypstMode {
let mut parser = tree_sitter::Parser::new();
if parser
.set_language(crate::grammar::language())
.is_err()
{
return TypstMode::Markup;
}
let Some(tree) = parser.parse(source, None) else {
return TypstMode::Markup;
};
let byte = byte_offset.min(source.len());
let mut node = tree
.root_node()
.descendant_for_byte_range(byte, byte)
.unwrap_or(tree.root_node());
loop {
match node.kind() {
"math" | "fraction" | "attach" | "align" | "prime" => {
return TypstMode::Math;
}
"code" | "flow" | "block" | "branch" | "call" | "lambda" | "let"
| "set" | "show" | "if" | "for" | "while" | "return" | "import"
| "include" | "context" => {
return TypstMode::Code;
}
"content" | "text" | "heading" | "emph" | "strong" | "item"
| "term" | "section" | "source_file" => {
return TypstMode::Markup;
}
_ => {}
}
match node.parent() {
Some(p) => node = p,
None => return TypstMode::Markup,
}
}
}
pub struct TypstHighlighter {
inner: TsHighlighter,
config: HighlightConfiguration,
}
impl TypstHighlighter {
pub fn new() -> Result<Self, String> {
let highlights = include_str!("../../assets/typst/highlights.scm");
let mut config =
HighlightConfiguration::new(crate::grammar::language(), highlights, "", "")
.map_err(|e| format!("tree-sitter-typst highlights query: {e}"))?;
config.configure(HIGHLIGHT_NAMES);
Ok(Self {
inner: TsHighlighter::new(),
config,
})
}
pub fn highlight_lines(
&mut self,
source: &str,
theme: &super::theme::Theme,
) -> Vec<Vec<StyledRun>> {
match self.try_highlight(source, theme) {
Ok(lines) => lines,
Err(_) => plain_lines(source),
}
}
fn try_highlight(
&mut self,
source: &str,
theme: &super::theme::Theme,
) -> Result<Vec<Vec<StyledRun>>, String> {
let bytes = source.as_bytes();
let events = self
.inner
.highlight(&self.config, bytes, None, |_| None)
.map_err(|e| format!("highlight: {e}"))?;
let mut stack: Vec<Style> = Vec::new();
let mut current_style = Style::default();
let mut lines: Vec<Vec<StyledRun>> = vec![Vec::new()];
let push_text = |lines: &mut Vec<Vec<StyledRun>>, text: &str, style: Style| {
for (i, segment) in text.split('\n').enumerate() {
if i > 0 {
lines.push(Vec::new());
}
if segment.is_empty() {
continue;
}
let line = lines.last_mut().unwrap();
if let Some(last) = line.last_mut() {
if last.style == style {
last.text.push_str(segment);
continue;
}
}
line.push(StyledRun {
text: segment.to_string(),
style,
});
}
};
for event in events {
match event.map_err(|e| format!("highlight event: {e}"))? {
HighlightEvent::Source { start, end } => {
let text = std::str::from_utf8(&bytes[start..end])
.map_err(|e| format!("non-utf8 source: {e}"))?;
push_text(&mut lines, text, current_style);
}
HighlightEvent::HighlightStart(h) => {
stack.push(current_style);
let name = HIGHLIGHT_NAMES
.get(h.0)
.copied()
.unwrap_or("");
let inherited = style_for_name(name, theme);
current_style = merge(current_style, inherited);
}
HighlightEvent::HighlightEnd => {
current_style = stack.pop().unwrap_or_default();
}
}
}
if lines.len() > 1 && lines.last().map_or(false, |l| l.is_empty()) {
}
Ok(lines)
}
}
fn plain_lines(source: &str) -> Vec<Vec<StyledRun>> {
source
.split('\n')
.map(|line| {
if line.is_empty() {
Vec::new()
} else {
vec![StyledRun {
text: line.to_string(),
style: Style::default(),
}]
}
})
.collect()
}
fn merge(outer: Style, inner: Style) -> Style {
let fg = inner.fg.or(outer.fg);
let bg = inner.bg.or(outer.bg);
let modifier = outer.add_modifier | inner.add_modifier;
Style::default()
.add_modifier(modifier)
.patch(Style {
fg,
bg,
..Style::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ThemeConfig;
fn t() -> super::super::theme::Theme {
super::super::theme::Theme::from_config(&ThemeConfig::default())
}
#[test]
fn heading_gets_highlighted() {
let mut h = TypstHighlighter::new().unwrap();
let theme = t();
let lines = h.highlight_lines("= Hello world\n\nplain text", &theme);
assert!(!lines.is_empty(), "highlight produced no lines");
let line0 = &lines[0];
assert!(!line0.is_empty(), "heading line had no runs: {:?}", line0);
let has_color = line0.iter().any(|r| r.style.fg.is_some());
assert!(has_color, "expected a colored run in `= Hello world`, got {:?}", line0);
}
#[test]
fn comment_recognized() {
let mut h = TypstHighlighter::new().unwrap();
let theme = t();
let lines = h.highlight_lines("// a comment", &theme);
let line0 = &lines[0];
let expected = theme.syntax_comment;
let has_themed = line0
.iter()
.any(|r| r.text.contains("comment") && r.style.fg == Some(expected));
assert!(has_themed, "expected themed comment colour, got {:?}", line0);
}
#[test]
fn empty_input_one_empty_line() {
let mut h = TypstHighlighter::new().unwrap();
let theme = t();
let lines = h.highlight_lines("", &theme);
assert_eq!(lines.len(), 1);
assert!(lines[0].is_empty());
}
#[test]
fn mode_at_file_scope_is_markup() {
assert_eq!(typst_mode_at("", 0), TypstMode::Markup);
let src = "Hello world.";
assert_eq!(typst_mode_at(src, 6), TypstMode::Markup);
}
#[test]
fn mode_at_inside_code_block_is_code() {
let src = "Some text. #{ }";
let pos = src.find('{').unwrap() + 1;
assert_eq!(typst_mode_at(src, pos), TypstMode::Code);
}
#[test]
fn mode_at_inside_function_args_is_code() {
let src = "Prose. #text(size: 12pt)";
let pos = src.find('(').unwrap() + 1;
assert_eq!(typst_mode_at(src, pos), TypstMode::Code);
}
#[test]
fn mode_at_inside_content_block_is_markup() {
let src = "#text[hello, world]";
let pos = src.find("hello").unwrap();
assert_eq!(typst_mode_at(src, pos), TypstMode::Markup);
}
#[test]
fn mode_at_inside_math_is_math() {
let src = "Prose. $x^2$ more.";
let pos = src.find("x^").unwrap();
assert_eq!(typst_mode_at(src, pos), TypstMode::Math);
}
#[test]
fn call_prefix_for_each_mode() {
assert_eq!(TypstMode::Markup.call_prefix(), "#");
assert_eq!(TypstMode::Code.call_prefix(), "");
assert_eq!(TypstMode::Math.call_prefix(), "");
}
}