//! Tree-sitter-backed syntax highlighting for the editor viewport.

mod languages;

use std::path::Path;

use minui::{ColorPair, TabPolicy, Window, cell_width};
use redox_core::{Pos, TextBuffer};
use tree_sitter::{Node, Parser, Query, QueryCursor, Range, StreamingIterator, Tree};
use unicode_segmentation::UnicodeSegmentation;

use self::languages::{
    LanguageConfig, language_config_for, language_for_path as config_language_for_path,
};
use super::style::{SyntaxRole, UiStyle};
use crate::ui::helpers::apply_color_column;
use crate::{SOFT_TAB, SOFT_TAB_WIDTH};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyntaxLanguage {
    C,
    Cpp,
    Css,
    Go,
    Html,
    JavaScript,
    Json,
    Lua,
    Markdown,
    Python,
    Rust,
    Toml,
    TypeScript,
    Tsx,
    Yaml,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineSyntaxSpan {
    pub start_byte: usize,
    pub end_byte: usize,
    pub role: SyntaxRole,
    priority: u16,
}

#[derive(Default)]
pub struct SyntaxHighlighter {
    cache: Option<HighlightCache>,
    cache_stale: bool,
    active_scope_cache: Option<ActiveScopeCache>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyntaxScopePair {
    pub start: Pos,
    pub end: Pos,
}

#[derive(Debug, Clone, Copy)]
struct ActiveScopeCache {
    language: SyntaxLanguage,
    analysis_version: u64,
    cursor_char: usize,
    scope: Option<SyntaxScopePair>,
}

#[derive(Debug, Clone, Copy)]
pub struct VisibleLineSyntaxSpans<'a> {
    line_spans: &'a [Vec<LineSyntaxSpan>],
    first_line: usize,
    line_count: usize,
}

struct QuerySyntaxEngine {
    language: SyntaxLanguage,
    parser: Parser,
    query: Query,
    capture_roles: Vec<Option<SyntaxCapture>>,
    refine_role: fn(SyntaxRole, &str) -> SyntaxRole,
    inline: Option<InlineSyntaxEngine>,
}

struct InlineSyntaxEngine {
    parser: Parser,
    query: Query,
    capture_roles: Vec<Option<SyntaxCapture>>,
}

impl std::fmt::Debug for SyntaxHighlighter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SyntaxHighlighter")
            .field("cache", &self.cache)
            .finish()
    }
}

#[derive(Debug)]
pub(crate) struct HighlightCache {
    language: SyntaxLanguage,
    line_spans: Vec<Vec<LineSyntaxSpan>>,
    tree: Tree,
}

#[derive(Debug, Clone, Copy)]
struct TokenSpan {
    start_byte: usize,
    end_byte: usize,
    role: SyntaxRole,
    priority: u16,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct SyntaxCapture {
    pub(crate) role: SyntaxRole,
    pub(crate) priority: u16,
}

/// Language-agnostic syntax highlighting engine with Treesitter.
impl QuerySyntaxEngine {
    fn new(config: &'static LanguageConfig) -> Option<Self> {
        let mut parser = Parser::new();
        let language = (config.grammar)();
        if parser.set_language(&language).is_err() {
            return None;
        }
        let query_source = config.highlights_queries.join("\n");
        let query = Query::new(&language, &query_source).ok()?;
        let capture_roles = query
            .capture_names()
            .iter()
            .map(|capture| (config.capture_mapping)(capture))
            .collect();

        let inline = config.inline_grammar.and_then(|grammar| {
            let mut parser = Parser::new();
            let language = grammar();
            if parser.set_language(&language).is_err() {
                return None;
            }
            let query_source = config.inline_highlights_queries.join("\n");
            let query = Query::new(&language, &query_source).ok()?;
            let capture_roles = query
                .capture_names()
                .iter()
                .map(|capture| (config.capture_mapping)(capture))
                .collect();

            Some(InlineSyntaxEngine {
                parser,
                query,
                capture_roles,
            })
        });

        Some(Self {
            language: config.language,
            parser,
            query,
            capture_roles,
            refine_role: config.refine_role,
            inline,
        })
    }

    fn parse_line_spans(&mut self, source: &str) -> Option<(Vec<Vec<LineSyntaxSpan>>, Tree)> {
        let line_starts = compute_line_start_bytes(source);
        let mut line_spans: Vec<Vec<LineSyntaxSpan>> = vec![Vec::new(); line_starts.len().max(1)];

        let Some(tree) = self.parser.parse(source, None) else {
            return None;
        };

        let mut tokens = Vec::new();
        collect_query_tokens(
            &self.query,
            &self.capture_roles,
            self.refine_role,
            tree.root_node(),
            source,
            &mut tokens,
        );
        if let Some(inline) = &mut self.inline {
            inline.parse_tokens(source, tree.root_node(), self.refine_role, &mut tokens);
        }
        if self.language == SyntaxLanguage::Markdown {
            collect_markdown_highlight_tokens(source, &line_starts, &mut tokens);
        }

        for token in tokens {
            push_token_to_lines(token, &line_starts, source.len(), line_spans.as_mut_slice());
        }

        for spans in &mut line_spans {
            spans.sort_by_key(|span| (span.start_byte, span.end_byte, span.priority));
        }

        Some((line_spans, tree))
    }
}

impl InlineSyntaxEngine {
    fn parse_tokens(
        &mut self,
        source: &str,
        root: Node<'_>,
        refine_role: fn(SyntaxRole, &str) -> SyntaxRole,
        tokens: &mut Vec<TokenSpan>,
    ) {
        let mut ranges = Vec::new();
        collect_inline_ranges(root, &mut ranges);
        for range in ranges {
            if self.parser.set_included_ranges(&[range]).is_err() {
                continue;
            }
            let Some(tree) = self.parser.parse(source, None) else {
                continue;
            };
            collect_query_tokens(
                &self.query,
                &self.capture_roles,
                refine_role,
                tree.root_node(),
                source,
                tokens,
            );
        }
    }
}

fn collect_query_tokens(
    query: &Query,
    capture_roles: &[Option<SyntaxCapture>],
    refine_role: fn(SyntaxRole, &str) -> SyntaxRole,
    node: Node<'_>,
    source: &str,
    tokens: &mut Vec<TokenSpan>,
) {
    let mut query_cursor = QueryCursor::new();
    let mut captures = query_cursor.captures(query, node, source.as_bytes());
    while {
        captures.advance();
        captures.get().is_some()
    } {
        let (query_match, capture_index) = captures
            .get()
            .expect("query capture should exist after advance");
        let capture = query_match.captures[*capture_index];
        let Some(syntax_capture) = capture_roles.get(capture.index as usize).copied().flatten()
        else {
            continue;
        };

        let role = refine_role(syntax_capture.role, capture.node.kind());
        tokens.push(TokenSpan {
            start_byte: capture.node.start_byte(),
            end_byte: capture.node.end_byte(),
            role,
            priority: syntax_capture.priority,
        });
    }
}

fn collect_inline_ranges(node: Node<'_>, ranges: &mut Vec<Range>) {
    match node.kind() {
        "inline" | "pipe_table_cell" => {
            ranges.push(node.range());
        }
        _ => {
            for idx in 0..node.named_child_count() {
                if let Some(child) = node.named_child(idx as u32) {
                    collect_inline_ranges(child, ranges);
                }
            }
        }
    }
}

impl SyntaxHighlighter {
    pub(crate) fn compute_cache(
        buffer: &TextBuffer,
        language: SyntaxLanguage,
    ) -> Option<HighlightCache> {
        let config = language_config_for(language)?;
        let mut engine = QuerySyntaxEngine::new(config)?;
        let source = buffer.to_string();
        let (spans, tree) = engine.parse_line_spans(&source)?;
        Some(HighlightCache {
            language,
            line_spans: spans,
            tree,
        })
    }

    pub fn visible_line_spans_cached(
        &self,
        language: Option<SyntaxLanguage>,
        first_line: usize,
        line_count: usize,
    ) -> Option<VisibleLineSyntaxSpans<'_>> {
        let language = language?;
        let cache = self
            .cache
            .as_ref()
            .filter(|cache| cache.language == language)?;
        Some(VisibleLineSyntaxSpans {
            line_spans: &cache.line_spans,
            first_line,
            line_count,
        })
    }

    pub fn visible_line_spans_for_display_cached(
        &self,
        language: Option<SyntaxLanguage>,
        first_line: usize,
        line_count: usize,
    ) -> Option<VisibleLineSyntaxSpans<'_>> {
        let language = language?;
        let cache = self
            .cache
            .as_ref()
            .filter(|cache| cache.language == language)?;
        Some(VisibleLineSyntaxSpans {
            line_spans: &cache.line_spans,
            first_line,
            line_count,
        })
    }

    pub(crate) fn has_cache_for(&self, language: SyntaxLanguage) -> bool {
        !self.cache_stale
            && self
                .cache
                .as_ref()
                .is_some_and(|cache| cache.language == language)
    }

    pub(crate) fn mark_cache_stale(&mut self) {
        if self.cache.is_some() {
            self.cache_stale = true;
        }
    }

    pub(crate) fn clear_cache(&mut self) {
        self.cache = None;
        self.cache_stale = false;
        self.active_scope_cache = None;
    }

    pub(crate) fn has_stale_cache_for(&self, language: SyntaxLanguage) -> bool {
        self.cache_stale
            && self
                .cache
                .as_ref()
                .is_some_and(|cache| cache.language == language)
    }

    #[cfg(test)]
    pub(crate) fn has_any_cache_for(&self, language: SyntaxLanguage) -> bool {
        self.cache
            .as_ref()
            .is_some_and(|cache| cache.language == language)
    }

    pub fn active_scope_pair_cached(
        &mut self,
        buffer: &TextBuffer,
        language: Option<SyntaxLanguage>,
        analysis_version: u64,
        cursor: Pos,
    ) -> Option<SyntaxScopePair> {
        let language = language?;
        let cursor_char = buffer.pos_to_char(cursor);
        if self.cache_stale {
            return None;
        }

        if let Some(cached) = self.active_scope_cache
            && cached.language == language
            && cached.analysis_version == analysis_version
            && cached.cursor_char == cursor_char
        {
            return cached.scope;
        }

        let config = language_config_for(language)?;
        let cache = self
            .cache
            .as_ref()
            .filter(|cache| cache.language == language)?;
        let cursor_byte = buffer.rope().char_to_byte(cursor_char);
        let root = cache.tree.root_node();
        let scope = root
            .named_descendant_for_byte_range(cursor_byte, cursor_byte)
            .or_else(|| {
                cursor_byte
                    .checked_sub(1)
                    .and_then(|byte| root.named_descendant_for_byte_range(byte, byte))
            })
            .and_then(|node| active_scope_pair_for_node(buffer, config, node));
        self.active_scope_cache = Some(ActiveScopeCache {
            language,
            analysis_version,
            cursor_char,
            scope,
        });
        scope
    }

    pub fn active_scope_pair_for_display_cached(
        &mut self,
        buffer: &TextBuffer,
        language: Option<SyntaxLanguage>,
        analysis_version: u64,
        cursor: Pos,
    ) -> Option<SyntaxScopePair> {
        let stale_scope = if self.cache_stale {
            let cursor_char = buffer.pos_to_char(cursor);
            language
                .and_then(|language| {
                    self.active_scope_cache.filter(|cached| {
                        cached.language == language && cached.cursor_char == cursor_char
                    })
                })
                .and_then(|cached| cached.scope)
        } else {
            None
        };
        self.active_scope_pair_cached(buffer, language, analysis_version, cursor)
            .or(stale_scope)
    }

    pub(crate) fn replace_cache(&mut self, cache: Option<HighlightCache>) {
        self.cache = cache;
        self.cache_stale = false;
        self.active_scope_cache = None;
    }
}

impl<'a> VisibleLineSyntaxSpans<'a> {
    pub fn get(&self, row: usize) -> Option<&'a [LineSyntaxSpan]> {
        if row >= self.line_count {
            return None;
        }

        Some(
            self.line_spans
                .get(self.first_line.saturating_add(row))
                .map(Vec::as_slice)
                .unwrap_or(&[]),
        )
    }
}

impl std::ops::Index<usize> for VisibleLineSyntaxSpans<'_> {
    type Output = [LineSyntaxSpan];

    fn index(&self, index: usize) -> &Self::Output {
        self.get(index).expect("visible syntax row out of bounds")
    }
}

fn active_scope_pair_for_node(
    buffer: &TextBuffer,
    config: &LanguageConfig,
    node: Node<'_>,
) -> Option<SyntaxScopePair> {
    let mut current = Some(node);
    while let Some(candidate) = current {
        if let Some(pair) = structural_scope_pair_for_node(buffer, config, candidate) {
            return Some(pair);
        }
        current = candidate.parent();
    }
    None
}

fn structural_scope_pair_for_node(
    buffer: &TextBuffer,
    config: &LanguageConfig,
    node: Node<'_>,
) -> Option<SyntaxScopePair> {
    if node.start_position().row >= node.end_position().row {
        return None;
    }

    if let Some(pair) = delimiter_wrapped_scope_pair(buffer, node) {
        return Some(pair);
    }

    config
        .scope_kinds
        .contains(&node.kind())
        .then(|| node_scope_pair(buffer, node))
        .flatten()
}

fn delimiter_wrapped_scope_pair(buffer: &TextBuffer, node: Node<'_>) -> Option<SyntaxScopePair> {
    let start_pos = byte_to_pos(buffer, node.start_byte())?;
    let end_pos = byte_to_pos(buffer, node.end_byte().checked_sub(1)?)?;
    let start_char = buffer.char_at(start_pos)?;
    let end_char = buffer.char_at(end_pos)?;
    matches!((start_char, end_char), ('{', '}') | ('[', ']') | ('(', ')')).then_some(
        SyntaxScopePair {
            start: start_pos,
            end: end_pos,
        },
    )
}

fn node_scope_pair(buffer: &TextBuffer, node: Node<'_>) -> Option<SyntaxScopePair> {
    let last_pos = byte_to_pos(buffer, node.end_byte().checked_sub(1)?)?;
    Some(SyntaxScopePair {
        start: byte_to_pos(buffer, node.start_byte())?,
        end: Pos::new(last_pos.line.saturating_add(1), 0),
    })
}

fn byte_to_pos(buffer: &TextBuffer, byte_idx: usize) -> Option<Pos> {
    if byte_idx > buffer.rope().len_bytes() {
        return None;
    }
    let char_idx = buffer.rope().byte_to_char(byte_idx);
    Some(buffer.char_to_pos(char_idx))
}

pub fn language_for_path(path: Option<&Path>) -> Option<SyntaxLanguage> {
    config_language_for_path(path)
}

pub fn language_for_name(name: &str) -> Option<SyntaxLanguage> {
    let normalised = name.trim().to_ascii_lowercase();
    match normalised.as_str() {
        "c" => Some(SyntaxLanguage::C),
        "cpp" | "c++" | "cc" | "cxx" => Some(SyntaxLanguage::Cpp),
        "css" => Some(SyntaxLanguage::Css),
        "go" | "golang" => Some(SyntaxLanguage::Go),
        "html" => Some(SyntaxLanguage::Html),
        "javascript" | "js" | "jsx" | "mjs" | "cjs" => Some(SyntaxLanguage::JavaScript),
        "json" | "jsonc" => Some(SyntaxLanguage::Json),
        "lua" => Some(SyntaxLanguage::Lua),
        "markdown" | "md" => Some(SyntaxLanguage::Markdown),
        "python" | "py" => Some(SyntaxLanguage::Python),
        "rust" | "rs" => Some(SyntaxLanguage::Rust),
        "toml" => Some(SyntaxLanguage::Toml),
        "typescript" | "ts" => Some(SyntaxLanguage::TypeScript),
        "tsx" | "typescriptreact" => Some(SyntaxLanguage::Tsx),
        "yaml" | "yml" => Some(SyntaxLanguage::Yaml),
        _ => None,
    }
}

pub fn line_spans_for_source(
    source: &str,
    language: Option<SyntaxLanguage>,
) -> Option<Vec<Vec<LineSyntaxSpan>>> {
    let language = language?;
    let config = language_config_for(language)?;
    let mut engine = QuerySyntaxEngine::new(config)?;
    let (spans, _) = engine.parse_line_spans(source)?;
    Some(spans)
}

pub fn scope_guides_enabled(language: Option<SyntaxLanguage>) -> bool {
    !matches!(language, Some(SyntaxLanguage::Markdown))
}

pub(crate) fn smart_newline_insert(
    buffer: &TextBuffer,
    language: Option<SyntaxLanguage>,
    cursor: Pos,
) -> Option<(String, Pos)> {
    let language = smart_indent_language(language)?;
    let source = buffer.to_string();
    let cursor = buffer.clamp_pos(cursor);
    let line = buffer.clamp_line(cursor.line);
    let line_text = buffer.line_string(line);
    let left = line_text.chars().take(cursor.col).collect::<String>();
    let right = line_text.chars().skip(cursor.col).collect::<String>();
    let virtual_source = if left == line_text {
        source
    } else {
        let mut source = source;
        let line_start = source_line_start_byte(&source, line)?;
        let cursor_byte = line_start + left.len();
        let line_end = line_start + line_text.len();
        source.replace_range(cursor_byte..line_end, "");
        source
    };
    let tree = parse_tree(&virtual_source, language)?;

    let base_indent = floored_indent(leading_indent(&line_text));
    let inner_indent = indent_after_line(&virtual_source, &tree, language, line)
        .unwrap_or_else(|| base_indent.clone());
    let right_trimmed = right.trim_start();
    let quote_split = quote_delimiter_split(&left, right_trimmed);
    if delimiter_split(&virtual_source, &tree, line, right_trimmed) || quote_split {
        let split_indent = if quote_split {
            let mut indent = base_indent.clone();
            indent.push_str(SOFT_TAB);
            indent
        } else {
            inner_indent
        };
        let insert = format!("\n{split_indent}\n{base_indent}");
        let cursor = Pos::new(line + 1, split_indent.chars().count());
        return Some((insert, cursor));
    }

    let mut indent = desired_indent_for_line_source(&virtual_source, language, line + 1)?;
    if right.is_empty() && line + 1 < buffer.len_lines() {
        let next_text = buffer.line_string(line + 1);
        let next_trimmed = next_text.trim_start();
        if starts_with_closing_delimiter(next_trimmed) || starts_with_html_closing(next_trimmed) {
            let surrounding_indent =
                max_indent(&inner_indent, &floored_indent(leading_indent(&next_text)));
            indent = max_indent(&indent, &surrounding_indent);
        }
    }
    Some((
        format!("\n{indent}"),
        Pos::new(line + 1, indent.chars().count()),
    ))
}

pub(crate) fn smart_open_line_insert(
    buffer: &TextBuffer,
    language: Option<SyntaxLanguage>,
    line: usize,
    above: bool,
) -> Option<(String, Pos)> {
    let language = smart_indent_language(language)?;
    let source = buffer.to_string();
    let line = buffer.clamp_line(line);
    let insert_pos = if above {
        Pos::new(line, 0)
    } else {
        Pos::new(line, buffer.line_len_chars(line))
    };
    let insert_byte = source_line_start_byte(&source, insert_pos.line)?
        + buffer
            .line_string(insert_pos.line)
            .chars()
            .take(insert_pos.col)
            .map(char::len_utf8)
            .sum::<usize>();

    let mut virtual_source = source;
    virtual_source.insert(insert_byte, '\n');
    let new_line = if above { line } else { line + 1 };
    let indent = desired_indent_for_line_source(&virtual_source, language, new_line)?;
    let insert = if above {
        format!("{indent}\n")
    } else {
        format!("\n{indent}")
    };

    Some((insert, Pos::new(new_line, indent.chars().count())))
}

pub(crate) fn desired_indent_for_line(
    buffer: &TextBuffer,
    language: Option<SyntaxLanguage>,
    line: usize,
) -> Option<String> {
    let language = smart_indent_language(language)?;
    desired_indent_for_line_source(&buffer.to_string(), language, line)
}

fn desired_indent_for_line_source(
    source: &str,
    language: SyntaxLanguage,
    line: usize,
) -> Option<String> {
    let tree = parse_tree(source, language)?;
    let lines = source.lines().collect::<Vec<_>>();
    let line_text = lines.get(line).copied().unwrap_or("");
    let Some(prev_line) = line.checked_sub(1) else {
        return Some(String::new());
    };
    let prev_text = lines.get(prev_line).copied().unwrap_or("");
    if prev_text.trim().is_empty() {
        if line_text.trim().is_empty()
            && let Some(next_text) = lines.get(line + 1).copied()
            && (starts_with_closing_delimiter(next_text.trim_start())
                || starts_with_html_closing(next_text.trim_start()))
        {
            let mut indent = floored_indent(leading_indent(next_text));
            indent.push_str(SOFT_TAB);
            return Some(indent);
        }
        return Some(String::new());
    }

    if language == SyntaxLanguage::Markdown
        && let Some(indent) = markdown_indent_after_line(prev_text)
    {
        return Some(indent);
    }
    let mut indent = indent_after_line(source, &tree, language, prev_line)
        .unwrap_or_else(|| floored_indent(leading_indent(prev_text)));

    let trimmed = line_text.trim_start();
    if starts_with_closing_delimiter(trimmed) || starts_with_html_closing(trimmed) {
        remove_one_indent_level(&mut indent);
    }

    Some(indent)
}

fn smart_indent_language(language: Option<SyntaxLanguage>) -> Option<SyntaxLanguage> {
    language
}

fn parse_tree(source: &str, language: SyntaxLanguage) -> Option<Tree> {
    let config = language_config_for(language)?;
    let mut parser = Parser::new();
    parser.set_language(&(config.grammar)()).ok()?;
    parser.parse(source, None)
}

fn indent_after_line(
    source: &str,
    tree: &Tree,
    language: SyntaxLanguage,
    line: usize,
) -> Option<String> {
    let lines = source.lines().collect::<Vec<_>>();
    let text = lines.get(line).copied()?;
    let mut indent = floored_indent(leading_indent(text));
    if opens_line(source, tree, language, line) {
        indent.push_str(SOFT_TAB);
    }
    Some(indent)
}

fn opens_line(source: &str, tree: &Tree, language: SyntaxLanguage, line: usize) -> bool {
    let Some((ch, byte)) = trailing_significant_char(source, tree, line) else {
        return false;
    };
    if ch == '`' {
        return false;
    }
    if paired_closer_for(ch).is_some() {
        return true;
    }
    if language == SyntaxLanguage::Python && ch == ':' {
        return true;
    }
    if matches!(language, SyntaxLanguage::Html | SyntaxLanguage::Tsx) {
        let lines = source.lines().collect::<Vec<_>>();
        if let Some(text) = lines.get(line) {
            return opens_html_tag(text);
        }
    }
    ch == ':' && node_kind_at_byte(tree, byte).is_some_and(|kind| kind.contains("mapping"))
}

fn delimiter_split(source: &str, tree: &Tree, line: usize, right_trimmed: &str) -> bool {
    let Some((ch, _)) = trailing_significant_char(source, tree, line) else {
        return false;
    };
    if ch == '`' {
        return false;
    }
    paired_closer_for(ch).is_some_and(|closer| right_trimmed.starts_with(closer))
}

fn quote_delimiter_split(left: &str, right_trimmed: &str) -> bool {
    let Some((quote, byte_idx)) = trailing_non_whitespace_char(left) else {
        return false;
    };
    if !matches!(quote, '"' | '\'' | '`') || !right_trimmed.starts_with(quote) {
        return false;
    }
    quote == '`' || !is_escaped_at(left, byte_idx)
}

fn trailing_non_whitespace_char(text: &str) -> Option<(char, usize)> {
    text.char_indices()
        .rev()
        .find(|(_, ch)| !ch.is_whitespace())
        .map(|(idx, ch)| (ch, idx))
}

fn is_escaped_at(text: &str, byte_idx: usize) -> bool {
    let mut backslashes = 0usize;
    for ch in text[..byte_idx].chars().rev() {
        if ch == '\\' {
            backslashes += 1;
        } else {
            break;
        }
    }
    backslashes % 2 == 1
}

fn trailing_significant_char(source: &str, tree: &Tree, line: usize) -> Option<(char, usize)> {
    let line_start = source_line_start_byte(source, line)?;
    let line_text = source.lines().nth(line)?;
    for (byte_offset, ch) in line_text.char_indices().rev() {
        if ch.is_whitespace() {
            continue;
        }
        let byte = line_start + byte_offset;
        if is_string_or_comment_node(tree, byte) {
            continue;
        }
        return Some((ch, byte));
    }
    None
}

fn node_kind_at_byte(tree: &Tree, byte: usize) -> Option<&str> {
    tree.root_node()
        .named_descendant_for_byte_range(byte, byte)
        .map(|node| node.kind())
}

fn is_string_or_comment_node(tree: &Tree, byte: usize) -> bool {
    let mut node = tree.root_node().named_descendant_for_byte_range(byte, byte);
    while let Some(current) = node {
        let kind = current.kind();
        if kind.contains("string") || kind.contains("comment") {
            return true;
        }
        node = current.parent();
    }
    false
}

fn leading_indent(text: &str) -> &str {
    let end = text
        .char_indices()
        .find_map(|(idx, ch)| (!matches!(ch, ' ' | '\t')).then_some(idx))
        .unwrap_or(text.len());
    &text[..end]
}

fn floored_indent(indent: &str) -> String {
    SOFT_TAB.repeat(indent_width(indent) / SOFT_TAB_WIDTH)
}

fn indent_width(indent: &str) -> usize {
    let mut col = 0usize;
    for ch in indent.chars() {
        match ch {
            '\t' => col += SOFT_TAB_WIDTH - (col % SOFT_TAB_WIDTH),
            ' ' => col += 1,
            _ => break,
        }
    }
    col
}

fn max_indent(left: &str, right: &str) -> String {
    if indent_width(left) >= indent_width(right) {
        left.to_string()
    } else {
        right.to_string()
    }
}

fn remove_one_indent_level(indent: &mut String) {
    if indent.ends_with('\t') {
        indent.pop();
        return;
    }

    let remove = indent
        .chars()
        .rev()
        .take_while(|ch| *ch == ' ')
        .take(SOFT_TAB_WIDTH)
        .count();
    for _ in 0..remove {
        indent.pop();
    }
}

fn paired_closer_for(ch: char) -> Option<char> {
    match ch {
        '{' => Some('}'),
        '[' => Some(']'),
        '(' => Some(')'),
        '<' => Some('>'),
        '`' => Some('`'),
        _ => None,
    }
}

fn starts_with_closing_delimiter(trimmed: &str) -> bool {
    trimmed
        .chars()
        .next()
        .is_some_and(|ch| matches!(ch, '}' | ']' | ')' | '>'))
}

fn starts_with_html_closing(trimmed: &str) -> bool {
    trimmed.starts_with("</")
}

fn opens_html_tag(text: &str) -> bool {
    let trimmed = text.trim();
    trimmed.starts_with('<')
        && !trimmed.starts_with("</")
        && !trimmed.starts_with("<!")
        && !trimmed.ends_with("/>")
        && trimmed.contains('>')
}

fn markdown_indent_after_line(text: &str) -> Option<String> {
    let base_width = indent_width(leading_indent(text));
    let mut rest = &text[leading_indent(text).len()..];
    let mut continuation_width = base_width;
    let mut saw_block_quote = false;

    while let Some(after_marker) = rest.strip_prefix('>') {
        saw_block_quote = true;
        continuation_width += 1;
        rest = after_marker;
        if let Some(after_space) = rest.strip_prefix(' ') {
            continuation_width += 1;
            rest = after_space;
        }
        let nested = leading_indent(rest);
        continuation_width += indent_width(nested);
        rest = &rest[nested.len()..];
    }

    if let Some(marker_len) = markdown_list_marker_len(rest) {
        continuation_width += marker_len;
        return Some(" ".repeat(continuation_width));
    }

    saw_block_quote.then(|| " ".repeat(continuation_width))
}

fn markdown_list_marker_len(text: &str) -> Option<usize> {
    if let Some(ch) = text.chars().next()
        && matches!(ch, '-' | '*' | '+')
        && text.chars().nth(1).is_some_and(char::is_whitespace)
    {
        return Some(2);
    }

    let mut digit_end = 0usize;
    for (idx, ch) in text.char_indices() {
        if ch.is_ascii_digit() {
            digit_end = idx + ch.len_utf8();
            continue;
        }
        break;
    }
    if digit_end == 0 {
        return None;
    }

    let mut chars = text[digit_end..].chars();
    let delimiter = chars.next()?;
    let space = chars.next()?;
    matches!(delimiter, '.' | ')')
        .then_some(())
        .filter(|_| space.is_whitespace())?;
    Some(digit_end + delimiter.len_utf8() + space.len_utf8())
}

fn source_line_start_byte(source: &str, line: usize) -> Option<usize> {
    compute_line_start_bytes(source).get(line).copied()
}

pub fn draw_line_with_syntax(
    window: &mut dyn Window,
    row: u16,
    col: u16,
    source_line: &str,
    scroll_x: usize,
    width_cells: usize,
    base_color: ColorPair,
    color_column: Option<(usize, minui::Color)>,
    style: UiStyle,
    spans: &[LineSyntaxSpan],
) -> minui::Result<()> {
    if width_cells == 0 {
        return Ok(());
    }

    let mut line_cells = 0usize;
    let mut byte_idx = 0usize;
    let mut syntax_idx = 0usize;
    let visible_end = scroll_x.saturating_add(width_cells);
    let mut pending_start: Option<usize> = None;
    let mut pending_end = 0usize;
    let mut pending_visible_x = 0usize;
    let mut pending_colors = base_color;

    for g in source_line.graphemes(true) {
        let g_width = cell_width(g, TabPolicy::Fixed(4)) as usize;
        let start_cell = line_cells;
        let end_cell = line_cells.saturating_add(g_width);
        let start_byte = byte_idx;
        let end_byte = byte_idx.saturating_add(g.len());

        line_cells = end_cell;
        byte_idx = end_byte;

        if end_cell <= scroll_x {
            continue;
        }
        if start_cell >= visible_end {
            break;
        }

        let clipped_start = start_cell.max(scroll_x);
        let clipped_end = end_cell.min(visible_end);
        if clipped_start >= clipped_end {
            continue;
        }

        while syntax_idx < spans.len() && spans[syntax_idx].end_byte <= start_byte {
            syntax_idx += 1;
        }

        let colors = syntax_color_for_range(
            base_color,
            style,
            &spans[syntax_idx..],
            start_byte,
            end_byte,
        );
        let colors = apply_color_column(colors, color_column, start_cell, end_cell);
        let visible_x = clipped_start.saturating_sub(scroll_x);

        if g == "\t" {
            flush_pending_syntax_span(
                window,
                row,
                col,
                source_line,
                pending_start.take(),
                pending_end,
                pending_visible_x,
                pending_colors,
            )?;
            let spaces = " ".repeat(clipped_end.saturating_sub(clipped_start));
            window.write_str_colored(row, col.saturating_add(visible_x as u16), &spaces, colors)?;
            continue;
        }

        if clipped_start != start_cell || clipped_end != end_cell {
            continue;
        }

        if pending_start.is_some() && pending_colors == colors && pending_end == start_byte {
            pending_end = end_byte;
            continue;
        }

        flush_pending_syntax_span(
            window,
            row,
            col,
            source_line,
            pending_start.take(),
            pending_end,
            pending_visible_x,
            pending_colors,
        )?;
        pending_start = Some(start_byte);
        pending_end = end_byte;
        pending_visible_x = visible_x;
        pending_colors = colors;
    }

    flush_pending_syntax_span(
        window,
        row,
        col,
        source_line,
        pending_start,
        pending_end,
        pending_visible_x,
        pending_colors,
    )?;

    draw_color_column_gap(
        window,
        row,
        col,
        source_line,
        scroll_x,
        width_cells,
        color_column,
    )
}

fn flush_pending_syntax_span(
    window: &mut dyn Window,
    row: u16,
    col: u16,
    source_line: &str,
    start: Option<usize>,
    end: usize,
    visible_x: usize,
    colors: ColorPair,
) -> minui::Result<()> {
    let Some(start) = start else {
        return Ok(());
    };
    if start >= end {
        return Ok(());
    }

    window.write_str_colored(
        row,
        col.saturating_add(visible_x as u16),
        &source_line[start..end],
        colors,
    )
}

pub fn syntax_color_for_range(
    base_color: ColorPair,
    style: UiStyle,
    spans: &[LineSyntaxSpan],
    start_byte: usize,
    end_byte: usize,
) -> ColorPair {
    if let Some(span) = best_span_for_range(spans, start_byte, end_byte) {
        style.syntax.color_for(span.role)
    } else {
        base_color
    }
}

pub fn lexical_fallback_line_spans(source_line: &str) -> Vec<LineSyntaxSpan> {
    let mut spans = Vec::new();
    let bytes = source_line.as_bytes();
    let mut cursor = 0;

    while cursor < bytes.len() {
        if bytes[cursor].is_ascii_whitespace() {
            cursor += 1;
            continue;
        }

        if let Some(comment_end) = lexical_comment_end(bytes, cursor) {
            spans.push(LineSyntaxSpan {
                start_byte: cursor,
                end_byte: comment_end,
                role: SyntaxRole::Comment,
                priority: 10,
            });
            if comment_end == bytes.len() {
                break;
            }
            cursor = comment_end;
            continue;
        }

        if matches!(bytes[cursor], b'"' | b'\'' | b'`') {
            let end = lexical_string_end(bytes, cursor);
            spans.push(LineSyntaxSpan {
                start_byte: cursor,
                end_byte: end,
                role: SyntaxRole::String,
                priority: 10,
            });
            cursor = end;
            continue;
        }

        if bytes[cursor].is_ascii_digit() {
            let end = lexical_number_end(bytes, cursor);
            spans.push(LineSyntaxSpan {
                start_byte: cursor,
                end_byte: end,
                role: SyntaxRole::Number,
                priority: 10,
            });
            cursor = end;
            continue;
        }

        if is_identifier_start(bytes[cursor]) {
            let end = lexical_identifier_end(bytes, cursor);
            let word = &source_line[cursor..end];
            if is_fallback_boolean(word) {
                spans.push(LineSyntaxSpan {
                    start_byte: cursor,
                    end_byte: end,
                    role: SyntaxRole::Boolean,
                    priority: 10,
                });
            } else if !is_fallback_keyword(word) && identifier_is_function_call(bytes, end) {
                spans.push(LineSyntaxSpan {
                    start_byte: cursor,
                    end_byte: end,
                    role: SyntaxRole::Function,
                    priority: 10,
                });
            }
            cursor = end;
            continue;
        }

        cursor += 1;
    }

    spans
}

// Fix for syntax highlighting colour flicker
pub fn merge_line_spans_for_display(
    base: &[LineSyntaxSpan],
    fallback: &[LineSyntaxSpan],
) -> Vec<LineSyntaxSpan> {
    if base.is_empty() {
        return fallback.to_vec();
    }
    if fallback.is_empty() {
        return base.to_vec();
    }

    let mut merged = base.to_vec();
    for fallback_span in fallback {
        let mut cursor = fallback_span.start_byte;
        for span in base {
            if span.start_byte >= fallback_span.end_byte {
                break;
            }
            if span.end_byte <= cursor {
                continue;
            }
            if span.start_byte > cursor {
                merged.push(LineSyntaxSpan {
                    start_byte: cursor,
                    end_byte: span.start_byte.min(fallback_span.end_byte),
                    role: fallback_span.role,
                    priority: fallback_span.priority,
                });
            }
            cursor = cursor.max(span.end_byte);
            if cursor >= fallback_span.end_byte {
                break;
            }
        }
        if cursor < fallback_span.end_byte {
            merged.push(LineSyntaxSpan {
                start_byte: cursor,
                end_byte: fallback_span.end_byte,
                role: fallback_span.role,
                priority: fallback_span.priority,
            });
        }
    }
    merged.sort_by_key(|span| (span.start_byte, span.end_byte, span.priority));
    merged
}

fn lexical_comment_end(bytes: &[u8], cursor: usize) -> Option<usize> {
    match bytes.get(cursor..cursor.saturating_add(2)) {
        Some(b"//" | b"--") => Some(bytes.len()),
        Some(b"/*") => {
            let mut end = cursor + 2;
            while end + 1 < bytes.len() {
                if bytes[end] == b'*' && bytes[end + 1] == b'/' {
                    return Some(end + 2);
                }
                end += 1;
            }
            Some(bytes.len())
        }
        _ if matches!(bytes[cursor], b'#' | b';')
            && (cursor == 0 || bytes[cursor - 1].is_ascii_whitespace()) =>
        {
            Some(bytes.len())
        }
        _ => None,
    }
}

fn lexical_string_end(bytes: &[u8], start: usize) -> usize {
    let quote = bytes[start];
    let mut cursor = start + 1;
    while cursor < bytes.len() {
        if quote != b'`' && bytes[cursor] == b'\\' {
            cursor = (cursor + 2).min(bytes.len());
            continue;
        }
        if bytes[cursor] == quote {
            return cursor + 1;
        }
        cursor += 1;
    }
    bytes.len()
}

fn lexical_number_end(bytes: &[u8], start: usize) -> usize {
    if bytes[start] == b'0'
        && matches!(bytes.get(start + 1), Some(b'x' | b'X'))
        && bytes
            .get(start + 2)
            .is_some_and(|byte| byte.is_ascii_hexdigit())
    {
        let mut cursor = start + 3;
        while cursor < bytes.len() && bytes[cursor].is_ascii_hexdigit() {
            cursor += 1;
        }
        return cursor;
    }

    let mut cursor = start + 1;
    while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
        cursor += 1;
    }
    if cursor < bytes.len()
        && bytes[cursor] == b'.'
        && bytes
            .get(cursor + 1)
            .is_some_and(|byte| byte.is_ascii_digit())
    {
        cursor += 2;
        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
            cursor += 1;
        }
    }
    cursor
}

fn lexical_identifier_end(bytes: &[u8], start: usize) -> usize {
    let mut cursor = start + 1;
    while cursor < bytes.len() && is_identifier_continue(bytes[cursor]) {
        cursor += 1;
    }
    cursor
}

fn is_identifier_start(byte: u8) -> bool {
    byte == b'_' || byte.is_ascii_alphabetic()
}

fn is_identifier_continue(byte: u8) -> bool {
    is_identifier_start(byte) || byte.is_ascii_digit()
}

fn identifier_is_function_call(bytes: &[u8], end: usize) -> bool {
    bytes.get(end) == Some(&b'(')
}

fn is_fallback_boolean(word: &str) -> bool {
    matches!(
        word,
        "true"
            | "false"
            | "True"
            | "False"
            | "TRUE"
            | "FALSE"
            | "null"
            | "nil"
            | "None"
            | "NULL"
            | "NIL"
            | "yes"
            | "no"
            | "on"
            | "off"
    )
}

fn is_fallback_keyword(word: &str) -> bool {
    matches!(
        word,
        "if" | "else"
            | "for"
            | "while"
            | "switch"
            | "return"
            | "fn"
            | "function"
            | "def"
            | "class"
            | "struct"
            | "enum"
            | "let"
            | "const"
            | "var"
            | "my"
            | "do"
            | "catch"
            | "try"
            | "match"
            | "case"
            | "new"
    )
}

fn draw_color_column_gap(
    window: &mut dyn Window,
    row: u16,
    col: u16,
    source_line: &str,
    scroll_x: usize,
    width_cells: usize,
    color_column: Option<(usize, minui::Color)>,
) -> minui::Result<()> {
    let Some((visible_col, bg)) = color_column else {
        return Ok(());
    };
    if visible_col >= width_cells {
        return Ok(());
    }

    let line_width = cell_width(source_line, TabPolicy::Fixed(4)) as usize;
    if line_width > scroll_x.saturating_add(visible_col) {
        return Ok(());
    }

    window.write_str_colored(
        row,
        col.saturating_add(visible_col as u16),
        " ",
        ColorPair::new(minui::Color::Transparent, bg),
    )
}

fn compute_line_start_bytes(source: &str) -> Vec<usize> {
    let mut starts = vec![0];
    for (byte_idx, b) in source.bytes().enumerate() {
        if b == b'\n' {
            starts.push(byte_idx + 1);
        }
    }
    starts
}

fn collect_markdown_highlight_tokens(
    source: &str,
    line_starts: &[usize],
    tokens: &mut Vec<TokenSpan>,
) {
    for (line_idx, line_start) in line_starts.iter().copied().enumerate() {
        let line_end = line_starts
            .get(line_idx + 1)
            .copied()
            .map(|next| next.saturating_sub(1))
            .unwrap_or(source.len());
        let line = &source[line_start..line_end];
        let bytes = line.as_bytes();
        let mut cursor = 0;

        while let Some(open_offset) = find_markdown_highlight_delimiter(bytes, cursor) {
            let content_start = open_offset + 2;
            let Some(close_offset) = find_markdown_highlight_delimiter(bytes, content_start) else {
                break;
            };

            if close_offset > content_start {
                let start = line_start + open_offset;
                let end = line_start + close_offset + 2;
                if !source[line_start + content_start..line_start + close_offset]
                    .trim()
                    .is_empty()
                    && !token_overlaps_role(tokens.as_slice(), start, end, SyntaxRole::MarkdownCode)
                {
                    tokens.push(TokenSpan {
                        start_byte: start,
                        end_byte: end,
                        role: SyntaxRole::MarkdownHighlight,
                        priority: 180,
                    });
                }
            }

            cursor = close_offset + 2;
        }
    }
}

fn find_markdown_highlight_delimiter(bytes: &[u8], start: usize) -> Option<usize> {
    let mut cursor = start;
    while cursor + 1 < bytes.len() {
        if bytes[cursor] == b'='
            && bytes[cursor + 1] == b'='
            && !is_escaped_ascii_byte(bytes, cursor)
        {
            return Some(cursor);
        }
        cursor += 1;
    }
    None
}

fn is_escaped_ascii_byte(bytes: &[u8], idx: usize) -> bool {
    let mut slash_count = 0;
    let mut cursor = idx;
    while cursor > 0 && bytes[cursor - 1] == b'\\' {
        slash_count += 1;
        cursor -= 1;
    }
    slash_count % 2 == 1
}

fn token_overlaps_role(
    tokens: &[TokenSpan],
    start_byte: usize,
    end_byte: usize,
    role: SyntaxRole,
) -> bool {
    tokens.iter().any(|token| {
        token.role == role && token.start_byte < end_byte && token.end_byte > start_byte
    })
}

fn line_index_for_byte(line_starts: &[usize], byte: usize) -> usize {
    match line_starts.binary_search(&byte) {
        Ok(idx) => idx,
        Err(0) => 0,
        Err(idx) => idx - 1,
    }
}

fn push_token_to_lines(
    token: TokenSpan,
    line_starts: &[usize],
    source_len: usize,
    line_spans: &mut [Vec<LineSyntaxSpan>],
) {
    if token.start_byte >= token.end_byte || token.start_byte >= source_len {
        return;
    }

    let mut cursor = token.start_byte;
    let token_end = token.end_byte.min(source_len);

    while cursor < token_end {
        let line_idx = line_index_for_byte(line_starts, cursor);
        let line_start = line_starts[line_idx];
        let next_line_start = line_starts.get(line_idx + 1).copied().unwrap_or(source_len);
        let line_content_end = if line_idx + 1 < line_starts.len() {
            next_line_start.saturating_sub(1)
        } else {
            next_line_start
        };

        if cursor >= line_content_end {
            cursor = next_line_start;
            continue;
        }

        let seg_end = token_end.min(line_content_end);
        if seg_end > cursor {
            line_spans[line_idx].push(LineSyntaxSpan {
                start_byte: cursor.saturating_sub(line_start),
                end_byte: seg_end.saturating_sub(line_start),
                role: token.role,
                priority: token.priority,
            });
        }

        if seg_end >= token_end {
            break;
        }
        cursor = next_line_start;
    }
}

fn best_span_for_range(
    spans: &[LineSyntaxSpan],
    start_byte: usize,
    end_byte: usize,
) -> Option<&LineSyntaxSpan> {
    spans
        .iter()
        .take_while(|span| span.start_byte < end_byte)
        .filter(|span| span.end_byte > start_byte)
        .max_by_key(|span| (span.priority, span.end_byte.saturating_sub(span.start_byte)))
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use redox_core::{Pos, TextBuffer};

    use super::{SyntaxHighlighter, SyntaxLanguage, language_for_path};
    use crate::ui::style::SyntaxRole;

    #[test]
    fn detects_rust_paths() {
        assert_eq!(
            language_for_path(Some(Path::new("/tmp/main.rs"))),
            Some(SyntaxLanguage::Rust)
        );
        assert_eq!(
            language_for_path(Some(Path::new("/tmp/MAIN.RS"))),
            Some(SyntaxLanguage::Rust)
        );
        assert_eq!(
            language_for_path(Some(Path::new("/tmp/readme.md"))),
            Some(SyntaxLanguage::Markdown)
        );
        assert_eq!(language_for_path(None), None);
    }

    #[test]
    fn detects_new_tree_sitter_language_paths() {
        let cases = [
            ("/tmp/main.c", SyntaxLanguage::C),
            ("/tmp/main.h", SyntaxLanguage::C),
            ("/tmp/main.cc", SyntaxLanguage::Cpp),
            ("/tmp/main.cpp", SyntaxLanguage::Cpp),
            ("/tmp/main.cxx", SyntaxLanguage::Cpp),
            ("/tmp/style.css", SyntaxLanguage::Css),
            ("/tmp/main.go", SyntaxLanguage::Go),
            ("/tmp/index.html", SyntaxLanguage::Html),
            ("/tmp/index.htm", SyntaxLanguage::Html),
            ("/tmp/app.js", SyntaxLanguage::JavaScript),
            ("/tmp/app.jsx", SyntaxLanguage::JavaScript),
            ("/tmp/app.mjs", SyntaxLanguage::JavaScript),
            ("/tmp/config.json", SyntaxLanguage::Json),
            ("/tmp/init.lua", SyntaxLanguage::Lua),
            ("/tmp/readme.markdown", SyntaxLanguage::Markdown),
            ("/tmp/main.py", SyntaxLanguage::Python),
            ("/tmp/main.pyi", SyntaxLanguage::Python),
            ("/tmp/Cargo.toml", SyntaxLanguage::Toml),
            ("/tmp/app.ts", SyntaxLanguage::TypeScript),
            ("/tmp/app.tsx", SyntaxLanguage::Tsx),
            ("/tmp/config.yaml", SyntaxLanguage::Yaml),
            ("/tmp/config.yml", SyntaxLanguage::Yaml),
        ];

        for (path, language) in cases {
            assert_eq!(language_for_path(Some(Path::new(path))), Some(language));
        }
    }

    #[test]
    fn scope_guides_are_disabled_for_markdown_only() {
        assert!(!super::scope_guides_enabled(Some(SyntaxLanguage::Markdown)));
        assert!(super::scope_guides_enabled(Some(SyntaxLanguage::Python)));
        assert!(super::scope_guides_enabled(Some(SyntaxLanguage::Rust)));
        assert!(super::scope_guides_enabled(None));
    }

    #[test]
    fn paired_closer_includes_backtick() {
        assert_eq!(super::paired_closer_for('`'), Some('`'));
    }

    #[test]
    fn markdown_inline_code_backtick_does_not_open_indent() {
        assert_eq!(
            super::desired_indent_for_line_source("`code`\n", SyntaxLanguage::Markdown, 1),
            Some(String::new())
        );

        let tree = super::parse_tree("`", SyntaxLanguage::Markdown).expect("markdown tree");
        assert!(!super::delimiter_split("`", &tree, 0, "`"));
    }

    #[test]
    fn comments_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("// comment\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Rust,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Rust), 0, 1)
            .expect("rust spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 0 && span.end_byte >= 10
        }));
    }

    #[test]
    fn string_quotes_are_highlighted_as_string() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("let s = \"hello\";\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Rust,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Rust), 0, 1)
            .expect("rust spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::String && span.start_byte <= 8 && span.end_byte >= 15
        }));
    }

    #[test]
    fn go_comments_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("// comment\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Go,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Go), 0, 1)
            .expect("go spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 0 && span.end_byte >= 10
        }));
    }

    #[test]
    fn c_comments_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("// comment\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(&buffer, SyntaxLanguage::C));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::C), 0, 1)
            .expect("c spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 0 && span.end_byte >= 10
        }));
    }

    #[test]
    fn cpp_uses_c_and_cpp_highlight_queries() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("auto value = nullptr; // comment\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Cpp,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Cpp), 0, 1)
            .expect("cpp spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Type && span.start_byte == 0 && span.end_byte == 4
        }));
        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte <= 22 && span.end_byte >= 32
        }));
    }

    #[test]
    fn new_tree_sitter_languages_highlight_basic_tokens() {
        let cases = [
            (SyntaxLanguage::Css, "body { color: red; }\n"),
            (SyntaxLanguage::Html, "<div class=\"note\">hi</div>\n"),
            (SyntaxLanguage::JavaScript, "const value = true;\n"),
            (SyntaxLanguage::Json, "{\"enabled\": true}\n"),
            (SyntaxLanguage::Lua, "local value = true\nprint(value)\n"),
            (SyntaxLanguage::Toml, "[package]\nname = \"redox\"\n"),
            (
                SyntaxLanguage::TypeScript,
                "type User = { name: string };\n",
            ),
            (
                SyntaxLanguage::Tsx,
                "const el = <Button label=\"Save\" />;\n",
            ),
            (SyntaxLanguage::Yaml, "name: redox\n"),
        ];

        for (language, source) in cases {
            let mut highlighter = SyntaxHighlighter::default();
            let buffer = TextBuffer::from_str(source);
            highlighter.replace_cache(SyntaxHighlighter::compute_cache(&buffer, language));
            let spans = highlighter
                .visible_line_spans_cached(Some(language), 0, 1)
                .expect("spans");

            assert!(
                !spans[0].is_empty(),
                "{language:?} should produce at least one highlight span"
            );
        }
    }

    #[test]
    fn typescript_combines_javascript_and_typescript_highlight_queries() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str(
            "export type User = { name: string };\nconst label = format(user.name, \"ok\");\n",
        );
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::TypeScript,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::TypeScript), 0, 2)
            .expect("typescript spans");

        assert!(spans[0].iter().any(|span| span.role == SyntaxRole::Keyword));
        assert!(
            spans[0]
                .iter()
                .any(|span| span.role == SyntaxRole::TypeBuiltin)
        );
        assert!(
            spans[1]
                .iter()
                .any(|span| span.role == SyntaxRole::Function)
        );
        assert!(spans[1].iter().any(|span| span.role == SyntaxRole::String));
    }

    #[test]
    fn markdown_headings_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("# Title\n\nBody\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 1)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownHeading && span.start_byte == 0 && span.end_byte >= 7
        }));
    }

    #[test]
    fn markdown_inline_emphasis_is_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("This is *italic*, **bold**, and `code`.\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 1)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownEmphasis && span.start_byte <= 8 && span.end_byte >= 16
        }));
        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownStrong && span.start_byte <= 18 && span.end_byte >= 26
        }));
        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownCode && span.start_byte <= 32 && span.end_byte >= 38
        }));
    }

    #[test]
    fn markdown_highlight_marks_equal_delimited_text() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("Keep ==this== bright, not `==this==`.\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 1)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownHighlight
                && span.start_byte == 5
                && span.end_byte == 13
        }));
        assert_eq!(
            spans[0]
                .iter()
                .filter(|span| span.role == SyntaxRole::MarkdownHighlight)
                .count(),
            1
        );
    }

    #[test]
    fn lexical_fallback_highlights_basic_code_shapes() {
        let spans = super::lexical_fallback_line_spans(
            "answer = call_one(\"text // no\", true, 42) // comment",
        );

        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Function && span.start_byte == 9 && span.end_byte == 17
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::String && span.start_byte == 18 && span.end_byte == 30
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Boolean && span.start_byte == 32 && span.end_byte == 36
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Number && span.start_byte == 38 && span.end_byte == 40
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 42 && span.end_byte == 52
        }));
    }

    #[test]
    fn lexical_fallback_hash_and_semicolon_comments_need_boundary() {
        let code_spans = super::lexical_fallback_line_spans("foo(); bar();");

        assert!(
            !code_spans
                .iter()
                .any(|span| span.role == SyntaxRole::Comment)
        );

        let hash_spans = super::lexical_fallback_line_spans("value # comment");
        assert!(hash_spans.iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 6 && span.end_byte == 15
        }));

        let semicolon_spans = super::lexical_fallback_line_spans("  ; comment");
        assert!(semicolon_spans.iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 2 && span.end_byte == 11
        }));
    }

    #[test]
    fn lexical_fallback_ignores_keywords_that_look_like_calls() {
        let spans = super::lexical_fallback_line_spans("if (enabled) run(0x10, nil);");

        assert!(
            !spans
                .iter()
                .any(|span| span.role == SyntaxRole::Function && span.start_byte == 0)
        );
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Function && span.start_byte == 13 && span.end_byte == 16
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Number && span.start_byte == 17 && span.end_byte == 21
        }));
        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Boolean && span.start_byte == 23 && span.end_byte == 26
        }));
    }

    #[test]
    fn lexical_fallback_requires_function_paren_to_touch_identifier() {
        let spans = super::lexical_fallback_line_spans("call_now(1); not_a_call (2);");

        assert!(spans.iter().any(|span| {
            span.role == SyntaxRole::Function && span.start_byte == 0 && span.end_byte == 8
        }));
        assert!(
            !spans
                .iter()
                .any(|span| span.role == SyntaxRole::Function && span.start_byte == 13)
        );
    }

    #[test]
    fn markdown_code_blocks_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("```rust\nlet answer = 42;\n```\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 1, 1)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownCode && span.start_byte == 0 && span.end_byte >= 16
        }));
    }

    #[test]
    fn markdown_frontmatter_is_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("---\ntitle: Redox\n---\n\n# Title\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 3)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownFrontmatter
                && span.start_byte == 0
                && span.end_byte >= 3
        }));
        assert!(spans[1].iter().any(|span| {
            span.role == SyntaxRole::MarkdownFrontmatter
                && span.start_byte == 0
                && span.end_byte >= 12
        }));
    }

    #[test]
    fn markdown_links_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("[Redox](https://example.com)\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 1)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownLink && span.start_byte == 0 && span.end_byte >= 28
        }));
    }

    #[test]
    fn markdown_list_markers_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("* item\n- other\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Markdown), 0, 2)
            .expect("markdown spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::MarkdownListMarker
                && span.start_byte == 0
                && span.end_byte >= 1
        }));
        assert!(spans[1].iter().any(|span| {
            span.role == SyntaxRole::MarkdownListMarker
                && span.start_byte == 0
                && span.end_byte >= 1
        }));
    }

    #[test]
    fn python_comments_are_highlighted() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("# comment\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Python,
        ));
        let spans = highlighter
            .visible_line_spans_cached(Some(SyntaxLanguage::Python), 0, 1)
            .expect("python spans");

        assert!(spans[0].iter().any(|span| {
            span.role == SyntaxRole::Comment && span.start_byte == 0 && span.end_byte >= 9
        }));
    }

    #[test]
    fn active_scope_pair_uses_multiline_structural_node() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("fn main() {\n    println!(\"hi\");\n}\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Rust,
        ));
        let scope = highlighter
            .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Rust), 0, Pos::new(1, 15))
            .expect("scope");

        assert_eq!(scope.start, Pos::new(0, 10));
        assert_eq!(scope.end, Pos::new(2, 0));
    }

    #[test]
    fn stale_active_scope_for_display_matches_cached_cursor_only() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("fn main() {\n    println!(\"hi\");\n}\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Rust,
        ));
        let cursor = Pos::new(1, 15);
        let scope = highlighter
            .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Rust), 0, cursor)
            .expect("scope");

        highlighter.mark_cache_stale();

        assert_eq!(
            highlighter.active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Rust), 0, cursor),
            None
        );
        assert_eq!(
            highlighter.active_scope_pair_for_display_cached(
                &buffer,
                Some(SyntaxLanguage::Rust),
                1,
                cursor,
            ),
            Some(scope)
        );
        assert_eq!(
            highlighter.active_scope_pair_for_display_cached(
                &buffer,
                Some(SyntaxLanguage::Rust),
                1,
                Pos::new(1, 16),
            ),
            None
        );
    }

    #[test]
    fn stale_visible_line_spans_for_display_reuse_previous_cache() {
        let buffer = TextBuffer::from_str("// note\n");
        let mut highlighter = SyntaxHighlighter::default();
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Rust,
        ));

        highlighter.mark_cache_stale();

        let spans = highlighter
            .visible_line_spans_for_display_cached(Some(SyntaxLanguage::Rust), 0, 1)
            .expect("stale display cache should still be available");
        assert!(
            spans[0]
                .iter()
                .any(|span| span.role == SyntaxRole::Comment && span.start_byte == 0)
        );
    }

    #[test]
    fn merge_line_spans_for_display_only_fills_uncovered_gaps() {
        let merged = super::merge_line_spans_for_display(
            &[super::LineSyntaxSpan {
                start_byte: 0,
                end_byte: 5,
                role: SyntaxRole::Keyword,
                priority: 20,
            }],
            &[super::LineSyntaxSpan {
                start_byte: 0,
                end_byte: 10,
                role: SyntaxRole::Comment,
                priority: 10,
            }],
        );

        assert!(merged.iter().any(|span| span.role == SyntaxRole::Keyword
            && span.start_byte == 0
            && span.end_byte == 5));
        assert!(merged.iter().any(|span| span.role == SyntaxRole::Comment
            && span.start_byte == 5
            && span.end_byte == 10));
        assert!(!merged.iter().any(|span| span.role == SyntaxRole::Comment
            && span.start_byte == 0
            && span.end_byte == 10));
    }

    #[test]
    fn new_brace_languages_use_tree_sitter_scope_nodes() {
        let cases = [
            (
                SyntaxLanguage::C,
                "int main() {\n    return 0;\n}\n",
                Pos::new(0, 11),
            ),
            (
                SyntaxLanguage::Cpp,
                "int main() {\n    return 0;\n}\n",
                Pos::new(0, 11),
            ),
            (
                SyntaxLanguage::Go,
                "func main() {\n    println(\"hi\")\n}\n",
                Pos::new(0, 12),
            ),
            (
                SyntaxLanguage::Css,
                "body {\n    color: red;\n}\n",
                Pos::new(0, 5),
            ),
            (
                SyntaxLanguage::JavaScript,
                "if (value) {\n    run();\n}\n",
                Pos::new(0, 11),
            ),
            (
                SyntaxLanguage::Json,
                "{\n  \"enabled\": true\n}\n",
                Pos::new(0, 0),
            ),
            (
                SyntaxLanguage::TypeScript,
                "if (value) {\n    run();\n}\n",
                Pos::new(0, 11),
            ),
            (
                SyntaxLanguage::Tsx,
                "if (value) {\n    run();\n}\n",
                Pos::new(0, 11),
            ),
            (
                SyntaxLanguage::Toml,
                "values = [\n  \"redox\"\n]\n",
                Pos::new(0, 9),
            ),
        ];

        for (language, source, expected_start) in cases {
            let mut highlighter = SyntaxHighlighter::default();
            let buffer = TextBuffer::from_str(source);
            highlighter.replace_cache(SyntaxHighlighter::compute_cache(&buffer, language));
            let scope = highlighter
                .active_scope_pair_cached(&buffer, Some(language), 0, Pos::new(1, 4))
                .expect("scope");

            assert_eq!(scope.start, expected_start);
            assert_eq!(scope.end, Pos::new(2, 0));
        }
    }

    #[test]
    fn lua_uses_tree_sitter_scope_nodes() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("function main()\n    print(\"hi\")\nend\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Lua,
        ));
        let scope = highlighter
            .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Lua), 0, Pos::new(1, 4))
            .expect("lua scope");

        assert_eq!(scope.start, Pos::new(0, 0));
        assert_eq!(scope.end, Pos::new(3, 0));
    }

    #[test]
    fn tag_and_whitespace_languages_use_tree_sitter_scope_nodes() {
        let cases = [
            (
                SyntaxLanguage::Html,
                "<main>\n  <p>Text</p>\n</main>\n",
                Pos::new(0, 0),
                3,
            ),
            (
                SyntaxLanguage::Yaml,
                "root:\n  child: value\n",
                Pos::new(1, 2),
                2,
            ),
        ];

        for (language, source, expected_start, expected_end_line) in cases {
            let mut highlighter = SyntaxHighlighter::default();
            let buffer = TextBuffer::from_str(source);
            highlighter.replace_cache(SyntaxHighlighter::compute_cache(&buffer, language));
            let scope = highlighter
                .active_scope_pair_cached(&buffer, Some(language), 0, Pos::new(1, 4))
                .expect("scope");

            assert_eq!(scope.start, expected_start);
            assert_eq!(scope.end.line, expected_end_line);
        }
    }

    #[test]
    fn python_active_scope_pair_uses_tree_sitter_node_range() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("def main():\n    print(\"hi\")\n    return 1\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Python,
        ));
        let scope = highlighter
            .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Python), 0, Pos::new(1, 8))
            .expect("scope");

        assert_eq!(scope.start, Pos::new(0, 0));
        assert_eq!(scope.end.line, 3);
    }

    #[test]
    fn python_clause_scope_pairs_use_clause_node_ranges() {
        let cases = [
            (
                "try:\n    risky()\nexcept ValueError:\n    recover()\n",
                Pos::new(3, 4),
                Pos::new(2, 0),
            ),
            (
                "try:\n    risky()\nexcept* ValueError:\n    recover()\n",
                Pos::new(3, 4),
                Pos::new(2, 0),
            ),
            (
                "try:\n    risky()\nexcept ValueError:\n    recover()\nelse:\n    ok()\n",
                Pos::new(5, 4),
                Pos::new(4, 0),
            ),
            (
                "try:\n    risky()\nfinally:\n    done()\n",
                Pos::new(3, 4),
                Pos::new(2, 0),
            ),
        ];

        for (source, cursor, expected_start) in cases {
            let mut highlighter = SyntaxHighlighter::default();
            let buffer = TextBuffer::from_str(source);
            highlighter.replace_cache(SyntaxHighlighter::compute_cache(
                &buffer,
                SyntaxLanguage::Python,
            ));
            let scope = highlighter
                .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Python), 0, cursor)
                .expect("scope");

            assert_eq!(scope.start, expected_start, "{source}");
            assert_eq!(scope.end.line, cursor.line + 1, "{source}");
        }
    }

    #[test]
    fn markdown_active_scope_pair_uses_tree_sitter_section() {
        let mut highlighter = SyntaxHighlighter::default();
        let buffer = TextBuffer::from_str("# Title\n\nBody\n");
        highlighter.replace_cache(SyntaxHighlighter::compute_cache(
            &buffer,
            SyntaxLanguage::Markdown,
        ));
        let scope = highlighter
            .active_scope_pair_cached(&buffer, Some(SyntaxLanguage::Markdown), 0, Pos::new(2, 1))
            .expect("scope");

        assert_eq!(scope.start, Pos::new(0, 0));
        assert_eq!(scope.end.line, 3);
    }
}
