use iced::Color;
use std::sync::OnceLock;
use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
use tree_sitter_md::{INLINE_LANGUAGE as MD_INLINE_LANG, MarkdownParser, MarkdownTree};
use tree_sitter_bash::HIGHLIGHT_QUERY as BASH_HIGHLIGHT_QUERY;
use tree_sitter_c::HIGHLIGHT_QUERY as C_HIGHLIGHT_QUERY;
use tree_sitter_css::HIGHLIGHTS_QUERY as CSS_HIGHLIGHTS_QUERY;
use tree_sitter_go::HIGHLIGHTS_QUERY as GO_HIGHLIGHTS_QUERY;
use tree_sitter_html::HIGHLIGHTS_QUERY as HTML_HIGHLIGHTS_QUERY;
use tree_sitter_json::HIGHLIGHTS_QUERY as JSON_HIGHLIGHTS_QUERY;
use tree_sitter_md::HIGHLIGHT_QUERY_BLOCK as MD_HIGHLIGHT_QUERY_BLOCK;
use tree_sitter_md::HIGHLIGHT_QUERY_INLINE as MD_HIGHLIGHT_QUERY_INLINE;
use tree_sitter_ruby::HIGHLIGHTS_QUERY as RUBY_HIGHLIGHTS_QUERY;
use tree_sitter_sequel::HIGHLIGHTS_QUERY as SQL_HIGHLIGHTS_QUERY;
use tree_sitter_toml_ng::HIGHLIGHTS_QUERY as TOML_HIGHLIGHTS_QUERY;
use super::theme;
static RUST_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static JAVASCRIPT_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static PYTHON_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static TYPESCRIPT_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static TSX_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static JSON_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static TOML_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static BASH_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static CSS_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static HTML_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static GO_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static RUBY_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static C_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static SQL_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static MARKDOWN_BLOCK_QUERY: OnceLock<Option<Query>> = OnceLock::new();
static MARKDOWN_INLINE_QUERY: OnceLock<Option<Query>> = OnceLock::new();
pub(crate) fn cached_query(lang: HighlightLanguage) -> Option<&'static Query> {
let cell = match lang {
HighlightLanguage::Rust => &RUST_QUERY,
HighlightLanguage::JavaScript => &JAVASCRIPT_QUERY,
HighlightLanguage::TypeScript => &TYPESCRIPT_QUERY,
HighlightLanguage::TSX => &TSX_QUERY,
HighlightLanguage::Python => &PYTHON_QUERY,
HighlightLanguage::Json => &JSON_QUERY,
HighlightLanguage::Toml => &TOML_QUERY,
HighlightLanguage::Bash => &BASH_QUERY,
HighlightLanguage::Css => &CSS_QUERY,
HighlightLanguage::Html => &HTML_QUERY,
HighlightLanguage::Go => &GO_QUERY,
HighlightLanguage::Ruby => &RUBY_QUERY,
HighlightLanguage::C => &C_QUERY,
HighlightLanguage::Sql => &SQL_QUERY,
HighlightLanguage::Markdown => &MARKDOWN_BLOCK_QUERY,
};
cell.get_or_init(|| {
let (ts_lang, query_str) = lang.language_and_query();
Query::new(&ts_lang, query_str).ok()
})
.as_ref()
}
#[derive(Debug, Clone, PartialEq)]
pub struct HighlightSpan {
pub start: usize,
pub end: usize,
pub highlight_class: HighlightClass,
}
#[derive(Debug, Clone)]
pub struct FileHighlights {
pub spans: Vec<Vec<HighlightSpan>>,
}
impl FileHighlights {
#[must_use]
pub fn empty(line_count: usize) -> Self {
Self {
spans: vec![Vec::new(); line_count],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HighlightClass {
Keyword,
String,
Type,
Function,
Comment,
Number,
Operator,
Text,
Search,
SearchCurrent,
}
impl HighlightClass {
#[must_use]
pub const fn color(self) -> Color {
match self {
HighlightClass::Keyword => Color::from_rgb(0.835, 0.502, 1.0),
HighlightClass::String => Color::from_rgb(0.761, 0.851, 0.298),
HighlightClass::Type => Color::from_rgb(0.349, 0.761, 1.0),
HighlightClass::Function => Color::from_rgb(1.0, 0.706, 0.329),
HighlightClass::Comment => Color::from_rgb(0.353, 0.400, 0.451),
HighlightClass::Number => Color::from_rgb(0.361, 0.812, 1.0),
HighlightClass::Operator => Color::from_rgb(0.949, 0.588, 0.408),
HighlightClass::Text => theme::TEXT_PRIMARY,
HighlightClass::Search => Color::from_rgb(1.0, 0.667, 0.0),
HighlightClass::SearchCurrent => Color::from_rgb(1.0, 0.8, 0.2),
}
}
}
#[must_use]
pub fn parse_file_highlights(
parser: &mut Parser,
source: &str,
lang: HighlightLanguage,
) -> FileHighlights {
if lang == HighlightLanguage::Markdown {
return parse_markdown_highlights(source);
}
let ts_lang = lang.tree_sitter_language();
let _ = parser.set_language(&ts_lang);
let tree = parser.parse(source, None);
let Some(tree) = tree else {
return FileHighlights::empty(source.lines().count());
};
let Some(query_obj) = cached_query(lang) else {
return FileHighlights::empty(source.lines().count());
};
build_highlights_from_tree(&tree, source, query_obj)
}
#[must_use]
fn parse_markdown_highlights(source: &str) -> FileHighlights {
let mut markdown_parser = MarkdownParser::default();
let Some(markdown_tree) = markdown_parser.parse(source.as_bytes(), None) else {
return FileHighlights::empty(source.lines().count());
};
build_markdown_highlights_from_tree(&markdown_tree, source)
}
#[must_use]
pub(crate) fn build_markdown_highlights_from_tree(
markdown_tree: &MarkdownTree,
source: &str,
) -> FileHighlights {
let mut byte_spans: Vec<(usize, usize, HighlightClass)> = Vec::new();
if let Some(query) = cached_query(HighlightLanguage::Markdown) {
let block_tree = markdown_tree.block_tree();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, block_tree.root_node(), source.as_bytes());
matches.advance();
while let Some(m) = matches.get() {
for capture in m.captures {
let name = capture_index_to_name(query, capture.index);
if name == "none" {
continue;
}
byte_spans.push((
capture.node.start_byte(),
capture.node.end_byte(),
capture_class(name),
));
}
matches.advance();
}
}
let inline_query = MARKDOWN_INLINE_QUERY.get_or_init(|| {
let inline_lang: Language = MD_INLINE_LANG.into();
Query::new(&inline_lang, MD_HIGHLIGHT_QUERY_INLINE).ok()
});
if let Some(query) = inline_query.as_ref() {
for inline_tree in markdown_tree.inline_trees() {
let root = inline_tree.root_node();
let offset = root.start_byte();
let mut cursor = QueryCursor::new();
let inline_source = &source.as_bytes()[offset..root.end_byte()];
let mut matches = cursor.matches(query, root, inline_source);
matches.advance();
while let Some(m) = matches.get() {
for capture in m.captures {
byte_spans.push((
capture.node.start_byte(),
capture.node.end_byte(),
capture_class(capture_index_to_name(query, capture.index)),
));
}
matches.advance();
}
}
}
byte_spans.sort_by_key(|(s, e, _)| (*s, *e));
distribute_byte_spans(source, &byte_spans)
}
#[must_use]
pub(crate) fn distribute_byte_spans(
source: &str,
byte_spans: &[(usize, usize, HighlightClass)],
) -> FileHighlights {
use std::collections::BTreeSet;
let mut line_starts: Vec<usize> = Vec::with_capacity(source.lines().count() + 1);
let mut pos = 0;
line_starts.push(0);
for ch in source.bytes() {
pos += 1;
if ch == b'\n' {
line_starts.push(pos);
}
}
let mut lines: Vec<Vec<HighlightSpan>> = Vec::with_capacity(line_starts.len());
for line_idx in 0..line_starts.len() {
let line_start = line_starts[line_idx];
let line_end = line_starts
.get(line_idx + 1)
.map_or(source.len(), |e| if *e > 0 { e - 1 } else { 0 });
if line_start >= line_end {
lines.push(Vec::new());
continue;
}
let mut clipped: Vec<(usize, usize, HighlightClass)> = Vec::new();
let mut boundaries: BTreeSet<usize> = BTreeSet::from([line_start, line_end]);
for &(span_start, span_end, class) in byte_spans {
if span_end <= line_start || span_start >= line_end {
continue;
}
let s = span_start.max(line_start);
let e = span_end.min(line_end);
if s < e {
boundaries.insert(s);
boundaries.insert(e);
clipped.push((s, e, class));
}
}
let bounds: Vec<usize> = boundaries.into_iter().collect();
let mut line_spans: Vec<HighlightSpan> = Vec::new();
for window in bounds.windows(2) {
let seg_start = window[0];
let seg_end = window[1];
if seg_start >= seg_end {
continue;
}
let mut best_class = HighlightClass::Text;
let mut best_pri = span_paint_priority(HighlightClass::Text);
for &(s, e, class) in &clipped {
if s <= seg_start && e >= seg_end {
let pri = span_paint_priority(class);
if pri < best_pri {
best_pri = pri;
best_class = class;
}
}
}
line_spans.push(HighlightSpan {
start: seg_start - line_start,
end: seg_end - line_start,
highlight_class: best_class,
});
}
let mut merged: Vec<HighlightSpan> = Vec::with_capacity(line_spans.len());
for span in line_spans {
if let Some(last) = merged.last_mut() {
if last.highlight_class == span.highlight_class && last.end == span.start {
last.end = span.end;
continue;
}
}
merged.push(span);
}
lines.push(merged);
}
FileHighlights { spans: lines }
}
const fn span_paint_priority(class: HighlightClass) -> u8 {
match class {
HighlightClass::Operator => 0,
HighlightClass::Type => 1,
HighlightClass::String => 2,
HighlightClass::Function => 3,
HighlightClass::Keyword => 4,
HighlightClass::Number => 5,
HighlightClass::Comment => 6,
HighlightClass::Search | HighlightClass::SearchCurrent => 7,
HighlightClass::Text => 255,
}
}
#[must_use]
pub(crate) fn build_highlights_from_tree(
tree: &tree_sitter::Tree,
source: &str,
query_obj: &Query,
) -> FileHighlights {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query_obj, tree.root_node(), source.as_bytes());
let mut byte_spans: Vec<(usize, usize, HighlightClass)> = Vec::new();
matches.advance();
while let Some(m) = matches.get() {
for capture in m.captures {
byte_spans.push((
capture.node.start_byte(),
capture.node.end_byte(),
capture_class(capture_index_to_name(query_obj, capture.index)),
));
}
matches.advance();
}
byte_spans.sort_by_key(|(s, e, _)| (*s, *e));
distribute_byte_spans(source, &byte_spans)
}
fn capture_index_to_name(query: &Query, index: u32) -> &str {
query
.capture_names()
.get(index as usize)
.map_or("text", |s| &s[..])
}
fn capture_class(capture_name: &str) -> HighlightClass {
match capture_name {
"keyword" | "keyword.operator" | "constant" | "constant.builtin" | "boolean"
| "conditional" | "storageclass" | "charset" | "import" | "keyframes" | "media"
| "namespace" | "supports" | "label" => HighlightClass::Keyword,
"type" | "type.builtin" | "type.qualifier" | "tag" | "tag.error" | "constructor" => {
HighlightClass::Type
}
"function"
| "function.builtin"
| "function.call"
| "function.special"
| "function.method"
| "function.method.builtin"
| "method"
| "attribute"
| "property"
| "field" => HighlightClass::Function,
"string"
| "string.special"
| "string.special.key"
| "string.special.regex"
| "string.special.symbol"
| "escape"
| "embedded" => HighlightClass::String,
"comment" => HighlightClass::Comment,
"number" | "float" => HighlightClass::Number,
"operator"
| "delimiter"
| "punctuation.bracket"
| "punctuation.delimiter"
| "punctuation.special" => HighlightClass::Operator,
"variable" | "variable.builtin" | "variable.parameter" | "parameter" | "spell" => {
HighlightClass::Text
}
"text.title" => HighlightClass::Type,
"text.literal" => HighlightClass::String,
"text.uri" => HighlightClass::Function,
"text.reference" => HighlightClass::Text,
"text.emphasis" => HighlightClass::Function, "text.strong" => HighlightClass::Keyword, "string.escape" => HighlightClass::Text,
_ => HighlightClass::Text,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HighlightLanguage {
Rust,
JavaScript,
TypeScript,
TSX,
Python,
Json,
Toml,
Bash,
Css,
Html,
Go,
Ruby,
C,
Sql,
Markdown,
}
impl HighlightLanguage {
#[must_use]
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"rs" => Some(HighlightLanguage::Rust),
"js" | "jsx" | "mjs" | "cjs" => Some(HighlightLanguage::JavaScript),
"ts" => Some(HighlightLanguage::TypeScript),
"tsx" => Some(HighlightLanguage::TSX),
"py" | "pyi" | "pyx" => Some(HighlightLanguage::Python),
"json" => Some(HighlightLanguage::Json),
"toml" => Some(HighlightLanguage::Toml),
"sh" | "bash" | "zsh" => Some(HighlightLanguage::Bash),
"css" => Some(HighlightLanguage::Css),
"html" | "htm" => Some(HighlightLanguage::Html),
"go" => Some(HighlightLanguage::Go),
"rb" => Some(HighlightLanguage::Ruby),
"c" | "h" => Some(HighlightLanguage::C),
"sql" => Some(HighlightLanguage::Sql),
"md" | "markdown" => Some(HighlightLanguage::Markdown),
_ => None,
}
}
#[must_use]
pub fn from_path(path: &str) -> Option<Self> {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.and_then(HighlightLanguage::from_extension)
}
pub(crate) fn language_and_query(self) -> (Language, &'static str) {
let lang = crate::util::tree_sitter::tree_sitter_language_for_extension(self.extension())
.expect("HighlightLanguage variant should have a valid extension mapping");
let query = match self {
HighlightLanguage::Rust => RUST_HIGHLIGHT_QUERY,
HighlightLanguage::JavaScript => JAVASCRIPT_HIGHLIGHT_QUERY,
HighlightLanguage::TypeScript => TYPESCRIPT_HIGHLIGHT_QUERY,
HighlightLanguage::TSX => TSX_HIGHLIGHT_QUERY,
HighlightLanguage::Python => PYTHON_HIGHLIGHT_QUERY,
HighlightLanguage::Json => JSON_HIGHLIGHTS_QUERY,
HighlightLanguage::Toml => TOML_HIGHLIGHTS_QUERY,
HighlightLanguage::Bash => BASH_HIGHLIGHT_QUERY,
HighlightLanguage::Css => CSS_HIGHLIGHTS_QUERY,
HighlightLanguage::Html => HTML_HIGHLIGHTS_QUERY,
HighlightLanguage::Go => GO_HIGHLIGHTS_QUERY,
HighlightLanguage::Ruby => RUBY_HIGHLIGHTS_QUERY,
HighlightLanguage::C => C_HIGHLIGHT_QUERY,
HighlightLanguage::Sql => SQL_HIGHLIGHTS_QUERY,
HighlightLanguage::Markdown => MD_HIGHLIGHT_QUERY_BLOCK,
};
(lang, query)
}
pub(crate) fn tree_sitter_language(self) -> Language {
self.language_and_query().0
}
#[must_use]
pub const fn extension(self) -> &'static str {
match self {
HighlightLanguage::Rust => "rs",
HighlightLanguage::JavaScript => "js",
HighlightLanguage::TypeScript => "ts",
HighlightLanguage::TSX => "tsx",
HighlightLanguage::Python => "py",
HighlightLanguage::Json => "json",
HighlightLanguage::Toml => "toml",
HighlightLanguage::Bash => "sh",
HighlightLanguage::Css => "css",
HighlightLanguage::Html => "html",
HighlightLanguage::Go => "go",
HighlightLanguage::Ruby => "rb",
HighlightLanguage::C => "c",
HighlightLanguage::Sql => "sql",
HighlightLanguage::Markdown => "md",
}
}
}
const RUST_HIGHLIGHT_QUERY: &str = r#"
;; Keywords
[
"as" "async" "await" "break" "const" "continue" "dyn"
"else" "enum" "extern" "false" "fn" "for" "if" "impl" "in"
"let" "loop" "match" "mod" "move" "pub" "ref" "return"
"static" "struct" "trait" "true" "type" "unsafe"
"use" "where" "while" "yield"
] @keyword
;; mutable_specifier for "mut"
(mutable_specifier) @keyword
;; Types
(type_identifier) @type
(primitive_type) @type
(scoped_type_identifier path: (identifier) @type)
(generic_function type_arguments: (type_arguments (type_identifier) @type))
;; Function definitions and calls
(function_item name: (identifier) @function)
(function_signature_item name: (identifier) @function)
(call_expression function: (identifier) @function.call)
(call_expression function: (field_expression field: (field_identifier) @function.call))
(macro_invocation macro: (identifier) @function.call)
;; String literals
(string_literal) @string
(raw_string_literal) @string
(char_literal) @string
;; Comments
(line_comment) @comment
(block_comment) @comment
;; Numbers
(integer_literal) @number
(float_literal) @number
;; Operators and punctuation
[
"+" "-" "*" "/" "%" "=" "==" "!=" "<" ">" "<=" ">=" "&&" "||" "!"
"&" "|" "^" "<<" ">>" "+=" "-=" "*=" "/=" "%=" "&=" "|=" "^=" "<<=" ">>="
"->" "=>" "::" "." ".." "..=" ";" "," ":" "@"
] @operator
"#;
const JAVASCRIPT_HIGHLIGHT_QUERY: &str = r"
;; Functions
(function_declaration name: (identifier) @function)
(method_definition name: (property_identifier) @function)
(arrow_function) @function
(generator_function_declaration name: (identifier) @function)
;; Strings
(string) @string
(template_string) @string
;; Comments
(comment) @comment
;; Numbers
(number) @number
";
const TYPESCRIPT_HIGHLIGHT_QUERY: &str = r"
;; Functions
(function_declaration name: (identifier) @function)
(method_definition name: (property_identifier) @function)
(arrow_function) @function
(generator_function_declaration name: (identifier) @function)
;; Strings
(string) @string
(template_string) @string
;; Comments
(comment) @comment
;; Numbers
(number) @number
";
const TSX_HIGHLIGHT_QUERY: &str = r"
;; Functions
(function_declaration name: (identifier) @function)
(method_definition name: (property_identifier) @function)
(arrow_function) @function
(generator_function_declaration name: (identifier) @function)
;; Strings
(string) @string
(template_string) @string
;; Comments
(comment) @comment
;; Numbers
(number) @number
";
const PYTHON_HIGHLIGHT_QUERY: &str = r#"
;; Types (class names)
(class_definition name: (identifier) @type)
;; Functions
(function_definition name: (identifier) @function)
(call function: (identifier) @function.call)
(call function: (attribute attribute: (identifier) @function.call))
;; Strings
(string) @string
(string_start) @string
(string_content) @string
(string_end) @string
;; Comments
(comment) @comment
;; Numbers
(integer) @number
(float) @number
;; Operators
[
"+" "-" "*" "/" "//" "%" "**" "=" "+=" "-=" "*=" "/=" "//=" "%="
"**=" "==" "!=" "<" ">" "<=" ">=" "and" "or" "not" "is" "in"
"&" "|" "^" "~" "<<" ">>" "@" ":=" "." ";" "," ":" "->"
] @operator
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_all_queries_compile() {
for (name, variant) in [
("Rust", HighlightLanguage::Rust),
("JS", HighlightLanguage::JavaScript),
("TS", HighlightLanguage::TypeScript),
("TSX", HighlightLanguage::TSX),
("Python", HighlightLanguage::Python),
("JSON", HighlightLanguage::Json),
("TOML", HighlightLanguage::Toml),
("Bash", HighlightLanguage::Bash),
("CSS", HighlightLanguage::Css),
("HTML", HighlightLanguage::Html),
("Go", HighlightLanguage::Go),
("Ruby", HighlightLanguage::Ruby),
("C", HighlightLanguage::C),
("SQL", HighlightLanguage::Sql),
("MD block", HighlightLanguage::Markdown),
] {
let (lang, query) = variant.language_and_query();
let q = tree_sitter::Query::new(&lang, query);
assert!(q.is_ok(), "{name} query failed: {:?}", q.err());
}
let md_inline = MD_INLINE_LANG.into();
let q = tree_sitter::Query::new(&md_inline, MD_HIGHLIGHT_QUERY_INLINE);
assert!(q.is_ok(), "MD inline query failed: {:?}", q.err());
}
#[test]
fn test_unsupported_extension() {
assert!(HighlightLanguage::from_extension("cpp").is_none());
}
#[test]
fn test_line_starts_simple() {
let source = "fn main() {\n let x = 42;\n}\n";
let mut line_starts: Vec<usize> = Vec::new();
let mut pos = 0;
line_starts.push(0);
for ch in source.bytes() {
pos += 1;
if ch == b'\n' {
line_starts.push(pos);
}
}
assert_eq!(
line_starts.len(),
4,
"line_starts: {line_starts:?}, source len: {}",
source.len()
);
let line_end = line_starts
.get(1)
.map_or(source.len(), |e| e.saturating_sub(1));
assert_eq!(line_end, 11);
let line0 = &source[0..line_end];
assert_eq!(line0, "fn main() {");
}
#[test]
fn test_parse_file_highlights_single_line() {
let code = "fn main() {}";
let mut parser = Parser::new();
let fh = parse_file_highlights(&mut parser, code, HighlightLanguage::Rust);
assert_eq!(fh.spans.len(), 1);
let has_keyword = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Keyword);
assert!(
has_keyword,
"spans: {:?}",
fh.spans[0]
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
}
#[test]
fn test_parse_file_highlights_rust() {
let code = "fn main() {\n let x = 42;\n}\n";
let mut parser = Parser::new();
let fh = parse_file_highlights(&mut parser, code, HighlightLanguage::Rust);
assert!(fh.spans.len() >= 3, "got {} spans", fh.spans.len());
let has_keyword = fh.spans[1]
.iter()
.any(|s| s.highlight_class == HighlightClass::Keyword);
assert!(has_keyword, "Expected keyword 'let' on line 1");
let has_number = fh.spans[1]
.iter()
.any(|s| s.highlight_class == HighlightClass::Number);
assert!(has_number, "Expected number '42' on line 1");
}
#[test]
fn test_parse_file_highlights_empty() {
let mut parser = Parser::new();
let fh = parse_file_highlights(&mut parser, "", HighlightLanguage::Rust);
assert!(fh.spans.len() <= 1);
if !fh.spans.is_empty() {
assert!(
fh.spans[0].is_empty(),
"empty source should have no captures"
);
}
}
#[test]
fn test_parse_file_highlights_python_multiline() {
let code = "def foo():\n \"\"\"A docstring.\"\"\"\n return 1\n";
let mut parser = Parser::new();
let fh = parse_file_highlights(&mut parser, code, HighlightLanguage::Python);
assert_eq!(fh.spans.len(), 4);
}
#[test]
fn test_file_highlights_empty_constructor() {
let fh = FileHighlights::empty(3);
assert_eq!(fh.spans.len(), 3);
for span_vec in &fh.spans {
assert!(
span_vec.is_empty(),
"empty() should produce empty vecs, got {span_vec:?}"
);
}
}
#[test]
fn test_single_token_line_not_filtered() {
let code = "42\n";
let mut parser = Parser::new();
let fh = parse_file_highlights(&mut parser, code, HighlightLanguage::Rust);
assert_eq!(
fh.spans.len(),
2,
"should have 2 lines (42 + trailing empty)"
);
let has_number = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Number);
assert!(
has_number,
"single token line '42' should have Number highlighting, got: {:?}",
fh.spans[0]
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
if fh.spans[0].len() == 1 {
let s = &fh.spans[0][0];
assert!(
s.end == 2 || s.end == 0,
"single span should cover full line or be empty"
);
}
}
#[test]
fn test_markdown_extension() {
assert_eq!(
HighlightLanguage::from_extension("md"),
Some(HighlightLanguage::Markdown)
);
assert_eq!(
HighlightLanguage::from_extension("markdown"),
Some(HighlightLanguage::Markdown)
);
}
#[test]
fn test_markdown_path() {
assert_eq!(
HighlightLanguage::from_path("README.md"),
Some(HighlightLanguage::Markdown)
);
assert_eq!(
HighlightLanguage::from_path("docs/guide.markdown"),
Some(HighlightLanguage::Markdown)
);
}
#[test]
fn test_parse_markdown_highlights_heading() {
let code = "# Hello World\n\nSome text.\n";
let fh = parse_markdown_highlights(code);
assert!(
fh.spans.len() >= 3,
"expected at least 3 lines, got {}",
fh.spans.len()
);
let has_title = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Type);
assert!(
has_title,
"expected text.title (Type) on heading line, got: {:?}",
fh.spans[0]
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
}
#[test]
fn test_parse_markdown_highlights_link() {
let code = "Visit [example](https://example.com) for info.\n";
let fh = parse_markdown_highlights(code);
let has_uri = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Function);
assert!(
has_uri,
"expected URI (Function) highlight in link, got: {:?}",
fh.spans[0]
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
}
#[test]
fn test_parse_markdown_highlights_empty() {
let code = "";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
assert!(
fh.spans[0].is_empty(),
"empty source should have no captures"
);
}
fn line0_has_class_in_range(
fh: &FileHighlights,
class: HighlightClass,
lo: usize,
hi: usize,
label: &str,
) {
let found = fh.spans[0]
.iter()
.any(|s| s.highlight_class == class && s.start < hi && s.end > lo);
assert!(
found,
"expected {label} ({class:?}) in [{lo},{hi}) on line 0; spans: {:?}",
fh.spans[0]
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
}
fn line_has_class_in_range(
fh: &FileHighlights,
line: usize,
class: HighlightClass,
lo: usize,
hi: usize,
label: &str,
) {
let spans = fh
.spans
.get(line)
.unwrap_or_else(|| panic!("expected line {line} to exist"));
let found = spans
.iter()
.any(|s| s.highlight_class == class && s.start < hi && s.end > lo);
assert!(
found,
"expected {label} ({class:?}) in [{lo},{hi}) on line {line}; spans: {:?}",
spans
.iter()
.map(|s| format!("({},{},{:?})", s.start, s.end, s.highlight_class))
.collect::<Vec<_>>()
);
}
#[test]
fn test_markdown_italic_star() {
let code = "*italic*";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1, "single line expected");
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 1, "opening *");
line0_has_class_in_range(&fh, HighlightClass::Function, 1, 7, "italic content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 7, 8, "closing *");
}
#[test]
fn test_markdown_bold_single_char() {
let code = "**X**";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 2, "opening **");
line0_has_class_in_range(&fh, HighlightClass::Keyword, 2, 3, "X content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 3, 5, "closing **");
}
#[test]
fn test_markdown_bold_star() {
let code = "**bold**";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 2, "opening **");
line0_has_class_in_range(&fh, HighlightClass::Keyword, 2, 6, "bold content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 6, 8, "closing **");
}
#[test]
fn test_markdown_italic_underscore() {
let code = "_italic_";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 1, "opening _");
line0_has_class_in_range(&fh, HighlightClass::Function, 1, 7, "italic content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 7, 8, "closing _");
}
#[test]
fn test_markdown_bold_underscore() {
let code = "__bold__";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 2, "opening __");
line0_has_class_in_range(&fh, HighlightClass::Keyword, 2, 6, "bold content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 6, 8, "closing __");
}
#[test]
fn test_markdown_inline_code() {
let code = "`code`";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
line0_has_class_in_range(&fh, HighlightClass::Operator, 0, 1, "opening `");
line0_has_class_in_range(&fh, HighlightClass::String, 1, 5, "code content");
line0_has_class_in_range(&fh, HighlightClass::Operator, 5, 6, "closing `");
}
#[test]
fn test_markdown_backslash_escape() {
let code = r"\*literal";
let fh = parse_markdown_highlights(code);
assert_eq!(fh.spans.len(), 1);
let escape_has_bad_class = fh.spans[0].iter().any(|s| {
s.start < 2
&& s.end > 0
&& (s.highlight_class == HighlightClass::Function
|| s.highlight_class == HighlightClass::Keyword)
});
assert!(
!escape_has_bad_class,
"escaped `\\*` should not have emphasis/bold colour; spans: {:?}",
fh.spans[0]
);
}
#[test]
fn test_markdown_heading_with_emphasis() {
let code = "# *hello*\n";
let fh = parse_markdown_highlights(code);
assert!(!fh.spans.is_empty(), "at least heading line");
let has_title = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Type);
assert!(has_title, "heading should have Type (text.title) highlight");
}
#[test]
fn test_markdown_combined_heading_bold() {
let code = "## **important**\n";
let fh = parse_markdown_highlights(code);
assert!(!fh.spans.is_empty());
let has_type = fh.spans[0]
.iter()
.any(|s| s.highlight_class == HighlightClass::Type);
assert!(has_type, "heading should have Type");
}
#[test]
fn test_markdown_inline_after_blank_line() {
let code = "First paragraph.\n\n*emphasis after blank*\n";
let fh = parse_markdown_highlights(code);
assert!(
fh.spans.len() >= 3,
"expected paragraph, blank, and emphasis lines; got {}",
fh.spans.len()
);
line_has_class_in_range(&fh, 2, HighlightClass::Function, 1, 22, "emphasis content");
line_has_class_in_range(&fh, 2, HighlightClass::Operator, 0, 1, "opening *");
}
#[test]
fn test_markdown_inline_in_list_items() {
let code = "- **bold item**\n- *italic item*\n";
let fh = parse_markdown_highlights(code);
assert!(fh.spans.len() >= 2, "expected two list lines");
line_has_class_in_range(&fh, 0, HighlightClass::Keyword, 4, 13, "bold list content");
line_has_class_in_range(
&fh,
1,
HighlightClass::Function,
4,
15,
"italic list content",
);
}
#[test]
fn test_markdown_inline_after_second_paragraph() {
let code = "Paragraph one.\n\nParagraph two with `code`.\n";
let fh = parse_markdown_highlights(code);
assert!(fh.spans.len() >= 3);
line_has_class_in_range(&fh, 2, HighlightClass::String, 20, 24, "inline code");
}
#[test]
fn test_distribute_byte_spans_no_overlap() {
let source = "abcde";
let spans = vec![
(0, 1, HighlightClass::Keyword),
(2, 5, HighlightClass::Number),
];
let fh = distribute_byte_spans(source, &spans);
assert_eq!(fh.spans.len(), 1);
let expected = vec![
HighlightSpan {
start: 0,
end: 1,
highlight_class: HighlightClass::Keyword,
},
HighlightSpan {
start: 1,
end: 2,
highlight_class: HighlightClass::Text,
},
HighlightSpan {
start: 2,
end: 5,
highlight_class: HighlightClass::Number,
},
];
assert_eq!(fh.spans[0], expected, "non-overlapping spans");
}
#[test]
fn test_distribute_byte_spans_overlap_tail_emission() {
let source = "**bold**";
let spans = vec![
(0, 2, HighlightClass::Operator), (0, 8, HighlightClass::Keyword), (6, 8, HighlightClass::Operator), ];
let fh = distribute_byte_spans(source, &spans);
assert_eq!(fh.spans.len(), 1, "single line");
let expected = vec![
HighlightSpan {
start: 0,
end: 2,
highlight_class: HighlightClass::Operator,
},
HighlightSpan {
start: 2,
end: 6,
highlight_class: HighlightClass::Keyword,
},
HighlightSpan {
start: 6,
end: 8,
highlight_class: HighlightClass::Operator,
},
];
assert_eq!(fh.spans[0], expected, "delimiter priority overlap");
}
#[test]
fn test_distribute_byte_spans_partial_overlap() {
let source = "abcdef";
let spans = vec![
(1, 3, HighlightClass::String), (2, 5, HighlightClass::Keyword), ];
let fh = distribute_byte_spans(source, &spans);
assert_eq!(fh.spans.len(), 1);
let expected = vec![
HighlightSpan {
start: 0,
end: 1,
highlight_class: HighlightClass::Text,
},
HighlightSpan {
start: 1,
end: 3,
highlight_class: HighlightClass::String,
},
HighlightSpan {
start: 3,
end: 5,
highlight_class: HighlightClass::Keyword,
},
HighlightSpan {
start: 5,
end: 6,
highlight_class: HighlightClass::Text,
},
];
assert_eq!(fh.spans[0], expected, "partial overlap");
}
#[test]
fn test_distribute_byte_spans_multi_line_overlap() {
let source = "hello\n*world*";
let spans = vec![
(6, 7, HighlightClass::Operator),
(6, 13, HighlightClass::Function),
(12, 13, HighlightClass::Operator),
];
let fh = distribute_byte_spans(source, &spans);
assert_eq!(fh.spans.len(), 2, "two lines");
assert_eq!(
fh.spans[0],
vec![HighlightSpan {
start: 0,
end: 5,
highlight_class: HighlightClass::Text
}],
"line 0 plain text fill"
);
let expected_line1 = vec![
HighlightSpan {
start: 0,
end: 1,
highlight_class: HighlightClass::Operator,
},
HighlightSpan {
start: 1,
end: 6,
highlight_class: HighlightClass::Function,
},
HighlightSpan {
start: 6,
end: 7,
highlight_class: HighlightClass::Operator,
},
];
assert_eq!(fh.spans[1], expected_line1, "line 1 overlap");
}
}