pub mod ast_grep;
pub mod config_extractors;
#[cfg(test)]
mod inline_tests;
pub mod languages;
// ponytail: Program 015 SP-0C spike module — same-file + use-import Rust resolver.
#[cfg(feature = "cbm-spike")]
pub mod resolver;
pub mod xref;

use std::collections::HashMap;
use std::panic;

use tree_sitter::Parser;

use tree_sitter::Node;

use crate::domain::{
    FileClassification, FileOutcome, FileProcessingResult, LanguageId, ParseDiagnostic,
    ReferenceRecord, SymbolRecord,
};
use crate::hash::digest_hex;

type ParseSourceOutput = (
    Vec<SymbolRecord>,
    bool,
    Option<ParseDiagnostic>,
    Vec<ReferenceRecord>,
    HashMap<String, String>,
);

pub fn process_file(
    relative_path: &str,
    bytes: &[u8],
    language: LanguageId,
) -> FileProcessingResult {
    process_file_with_classification(
        relative_path,
        bytes,
        language,
        FileClassification::for_code_path(relative_path),
    )
}

pub fn process_file_with_classification(
    relative_path: &str,
    bytes: &[u8],
    language: LanguageId,
    classification: FileClassification,
) -> FileProcessingResult {
    let byte_len = bytes.len() as u64;
    let content_hash = digest_hex(bytes);

    // Config files use native parsers, not tree-sitter.
    if config_extractors::is_config_language(&language) {
        let result = config_extractors::extractor_for(&language).map(|e| e.extract(bytes));
        let (symbols, outcome, parse_diagnostic) = match result {
            Some(r) => {
                let (outcome, parse_diagnostic) = match r.outcome {
                    config_extractors::ExtractionOutcome::Ok => (FileOutcome::Processed, None),
                    config_extractors::ExtractionOutcome::Partial(diagnostic) => (
                        FileOutcome::PartialParse {
                            warning: diagnostic.summary(),
                        },
                        Some(diagnostic),
                    ),
                    config_extractors::ExtractionOutcome::Failed(diagnostic) => (
                        FileOutcome::Failed {
                            error: diagnostic.summary(),
                        },
                        Some(diagnostic),
                    ),
                };
                (r.symbols, outcome, parse_diagnostic)
            }
            None => (vec![], FileOutcome::Processed, None),
        };
        return FileProcessingResult {
            relative_path: relative_path.to_string(),
            language,
            classification,
            outcome,
            parse_diagnostic,
            symbols,
            byte_len,
            content_hash,
            references: vec![],
            alias_map: HashMap::new(),
        };
    }

    let source = String::from_utf8_lossy(bytes);

    // `.tsx` requires the TSX grammar; `.ts` stays on plain TypeScript. The
    // distinction is carried by the file extension, not the LanguageId.
    let is_tsx = LanguageId::is_tsx_path(relative_path);

    // SF-STRESS-005: `.h` maps to C by extension, but cross-platform repos fill
    // `.h` with C++ (`class`/`namespace`/`::`). The C grammar extracts ZERO
    // symbols from a C++ class header, so disambiguate the grammar from content
    // here and adopt the C++ verdict (grammar AND reported language) when it
    // parses the header better than C. A plain C header parses clean as C and is
    // never touched. Objective-C headers (`@interface`) have no shipped grammar
    // and stay C — reported honestly as a partial, not silently mislabeled.
    let (language, parse_result) =
        if matches!(language, LanguageId::C) && LanguageId::is_c_header_path(relative_path) {
            resolve_c_header_parse(&source)
        } else {
            let parse_result = panic::catch_unwind(|| parse_source(&source, &language, is_tsx));
            (language, parse_result)
        };

    match parse_result {
        Ok(Ok((symbols, has_error, diagnostic, references, alias_map))) => {
            let outcome = if has_error {
                let warning = diagnostic.as_ref().map(|d| d.summary()).unwrap_or_else(|| {
                    "tree-sitter reported syntax errors in the parse tree".to_string()
                });
                FileOutcome::PartialParse { warning }
            } else {
                FileOutcome::Processed
            };
            FileProcessingResult {
                relative_path: relative_path.to_string(),
                language,
                classification,
                outcome,
                parse_diagnostic: diagnostic,
                symbols,
                byte_len,
                content_hash,
                references,
                alias_map,
            }
        }
        Ok(Err(err)) => FileProcessingResult {
            relative_path: relative_path.to_string(),
            language,
            classification,
            outcome: FileOutcome::Failed {
                error: err.to_string(),
            },
            parse_diagnostic: None,
            symbols: vec![],
            byte_len,
            content_hash,
            references: vec![],
            alias_map: HashMap::new(),
        },
        Err(_panic) => FileProcessingResult {
            relative_path: relative_path.to_string(),
            language,
            classification,
            outcome: FileOutcome::Failed {
                error: "tree-sitter parser panicked during parsing".to_string(),
            },
            parse_diagnostic: None,
            symbols: vec![],
            byte_len,
            content_hash,
            references: vec![],
            alias_map: HashMap::new(),
        },
    }
}

/// Walk the tree-sitter tree and collect info about the deepest useful ERROR or MISSING node.
/// Returns (message, line, column, byte_span) for building a `ParseDiagnostic`.
fn collect_deepest_error_node(root: &Node, source: &str) -> Option<(String, u32, u32, (u32, u32))> {
    let mut cursor = root.walk();
    let mut stack = vec![(*root, 0usize)];
    let mut best: Option<(Node, usize)> = None;

    while let Some((node, depth)) = stack.pop() {
        if node.is_error() || node.is_missing() {
            best = match best {
                Some((current, current_depth))
                    if !error_candidate_is_better(node, depth, current, current_depth) =>
                {
                    Some((current, current_depth))
                }
                _ => Some((node, depth)),
            };
        }
        // Push children in reverse so we visit left-to-right via the stack.
        cursor.reset(node);
        if cursor.goto_first_child() {
            let mut children = vec![cursor.node()];
            while cursor.goto_next_sibling() {
                children.push(cursor.node());
            }
            stack.extend(children.into_iter().rev().map(|child| (child, depth + 1)));
        }
    }

    best.map(|(node, _depth)| {
        let start = node.start_position();
        let snippet_start = node.start_byte().min(source.len());
        // Clamp the 40-byte snippet window down to the nearest UTF-8 char
        // boundary — tree-sitter reports byte offsets, and `snippet_start +
        // 40` can land mid-multibyte-char, which would panic str slicing.
        let snippet_limit = snippet_start + 40;
        let span_end = node.end_byte().min(source.len());
        let mut snippet_end = if span_end > snippet_start {
            span_end.min(snippet_limit)
        } else {
            snippet_limit.min(source.len())
        };
        while snippet_end > snippet_start && !source.is_char_boundary(snippet_end) {
            snippet_end -= 1;
        }
        let snippet = &source[snippet_start..snippet_end];
        let kind = if node.is_missing() {
            format!("missing {}", node.kind())
        } else {
            "error".to_string()
        };
        let message = format!("syntax {kind} near `{}`", snippet.replace('\n', "\\n"));
        (
            message,
            start.row as u32 + 1,    // 1-based line
            start.column as u32 + 1, // 1-based column
            (node.start_byte() as u32, node.end_byte() as u32),
        )
    })
}

/// Append a NUL-byte hint to a tree-sitter diagnostic message when `source`
/// contains literal `0x00` bytes. tree-sitter cannot parse NUL in source (the
/// lexer treats it as input end / blank), so the deepest-error snippet looks
/// like `syntax error near ` ` — meaningless on its own. The hint names the real
/// cause and the offending offsets so health/quarantine output is actionable.
fn augment_message_with_nul_hint(message: String, source: &[u8]) -> String {
    let scan = crate::domain::index::scan_nul_bytes(source);
    if !scan.has_nul() {
        return message;
    }
    let offsets = scan
        .first_offsets
        .iter()
        .map(|o| o.to_string())
        .collect::<Vec<_>>()
        .join(", ");
    let offset_suffix = if scan.count > scan.first_offsets.len() {
        format!("{offsets}, ...")
    } else {
        offsets
    };
    let plural = if scan.count == 1 { "" } else { "s" };
    format!(
        "{message} (file contains {} NUL byte{plural} at offset(s) {offset_suffix}; \
         tree-sitter cannot parse NUL in source)",
        scan.count
    )
}

fn error_candidate_is_better(
    candidate: Node,
    candidate_depth: usize,
    current: Node,
    current_depth: usize,
) -> bool {
    if candidate_depth != current_depth {
        return candidate_depth > current_depth;
    }

    let candidate_len = candidate.end_byte().saturating_sub(candidate.start_byte());
    let current_len = current.end_byte().saturating_sub(current.start_byte());
    if candidate_len != current_len {
        return candidate_len < current_len;
    }

    if candidate.start_byte() != current.start_byte() {
        return candidate.start_byte() < current.start_byte();
    }

    candidate.is_missing() && !current.is_missing()
}

pub(crate) fn parse_source(
    source: &str,
    language: &LanguageId,
    is_tsx: bool,
) -> Result<ParseSourceOutput, String> {
    let mut parser = Parser::new();

    let ts_language = match language {
        LanguageId::Rust => tree_sitter_rust::LANGUAGE.into(),
        LanguageId::Python => tree_sitter_python::LANGUAGE.into(),
        LanguageId::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
        // `.tsx` needs the JSX-aware TSX grammar; `.ts` keeps the plain
        // TypeScript grammar (which still accepts legacy `<T>expr` casts).
        LanguageId::TypeScript if is_tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
        LanguageId::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
        LanguageId::Go => tree_sitter_go::LANGUAGE.into(),
        LanguageId::Java => tree_sitter_java::LANGUAGE.into(),
        LanguageId::C => tree_sitter_c::LANGUAGE.into(),
        LanguageId::Cpp => tree_sitter_cpp::LANGUAGE.into(),
        LanguageId::CSharp => tree_sitter_c_sharp::LANGUAGE.into(),
        LanguageId::Ruby => tree_sitter_ruby::LANGUAGE.into(),
        LanguageId::Php => tree_sitter_php::LANGUAGE_PHP.into(),
        LanguageId::Swift => tree_sitter_swift::LANGUAGE.into(),
        LanguageId::Perl => tree_sitter_perl::LANGUAGE.into(),
        LanguageId::Kotlin => tree_sitter_kotlin_sg::LANGUAGE.into(),
        LanguageId::Dart => tree_sitter_dart::LANGUAGE.into(),
        LanguageId::Elixir => tree_sitter_elixir::LANGUAGE.into(),
        LanguageId::Json
        | LanguageId::Toml
        | LanguageId::Yaml
        | LanguageId::Markdown
        | LanguageId::Env => unreachable!("config types are handled before parse_source"),
        LanguageId::Html => tree_sitter_html::LANGUAGE.into(),
        LanguageId::Css => tree_sitter_css::LANGUAGE.into(),
        LanguageId::Scss => tree_sitter_scss::language(),
    };

    parser
        .set_language(&ts_language)
        .map_err(|e| format!("failed to set language: {e}"))?;

    let tree = parser
        .parse(source, None)
        .ok_or_else(|| "tree-sitter parse returned None".to_string())?;

    let root = tree.root_node();
    let has_error = root.has_error();

    // SF-STRESS-007: on Windows checkouts (core.autocrlf=true) a backslash line
    // continuation inside a C/C++ string literal ends with `\` + CR + LF.
    // tree-sitter-cpp/-c lex `\`+CR as an escape_sequence, leaving a raw LF that
    // `string_content` cannot match -> parse error. Error recovery then re-enters
    // mid-literal and harvests embedded-DSL text (HLSL shader `struct`/`main`) as
    // PHANTOM C++ symbols that are well-formed siblings OUTSIDE the ERROR nodes,
    // so they cannot be filtered by skipping ERROR subtrees and they pollute
    // exact-match search at full confidence. Recover by reparsing a copy with the
    // line splice rewritten `\`+CR+LF -> `\`+LF+CR (CR is valid string_content and
    // whitespace): this is BYTE-LENGTH PRESERVING, so every real symbol's byte
    // span is identical between the on-disk source and the reparsed buffer — no
    // offset map is needed. We adopt the reparse ONLY if it is fully clean (no
    // ERROR/MISSING anywhere), which proves the splice was the sole defect; any
    // unrelated error keeps the original (dirty) parse, so a genuinely broken
    // file is never silently "fixed".
    if has_error
        && matches!(language, LanguageId::C | LanguageId::Cpp)
        && source_has_backslash_crlf(source)
        && let Some(recovered) = recover_c_family_backslash_crlf(source, language)
    {
        return Ok(recovered);
    }

    let symbols = languages::extract_symbols(&root, source, language);
    let (references, alias_map) = xref::extract_references(&root, source, language, is_tsx);

    let diagnostic = if has_error {
        collect_deepest_error_node(&root, source).map(|(message, line, column, span)| {
            ParseDiagnostic {
                parser: "tree-sitter".to_string(),
                // NUL bytes (0x00) are valid UTF-8 but tree-sitter cannot parse
                // them in source — they truncate the lexer's view and render as a
                // blank in the error snippet (`syntax error near ` `), hiding the
                // real cause. When the source carries NUL bytes, append a hint so
                // health/quarantine output explains the actual problem.
                message: augment_message_with_nul_hint(message, source.as_bytes()),
                line: Some(line),
                column: Some(column),
                byte_span: Some(span),
                fallback_used: false,
            }
        })
    } else {
        None
    };

    Ok((symbols, has_error, diagnostic, references, alias_map))
}

/// Returns `true` when `source` contains a backslash immediately followed by a
/// CRLF (`\` `\r` `\n`) — the Windows line-continuation byte pattern that
/// tree-sitter-c/-cpp mis-lex inside string literals (SF-STRESS-007). Cheap
/// pre-gate so the recovery reparse runs only on files that actually carry the
/// pattern.
fn source_has_backslash_crlf(source: &str) -> bool {
    let b = source.as_bytes();
    b.windows(3).any(|w| w == [b'\\', b'\r', b'\n'])
}

/// SF-STRESS-007 recovery: reparse `source` with every `\`+CR+LF line splice
/// rewritten to `\`+LF+CR and return the reparse output IFF it is fully clean.
///
/// The rewrite is byte-length preserving (3 bytes -> 3 bytes, a pure transpose
/// of CR and LF after the backslash), so byte offsets — and therefore every
/// extracted symbol's `byte_range` and every reference span — are IDENTICAL
/// between the on-disk source and the reparsed buffer. CR is valid
/// `string_content` and ordinary whitespace, so moving it after the LF lets the
/// `\`+LF escape/line-continuation lex correctly while keeping the line count
/// and all spans intact.
///
/// Returns `None` (caller keeps the original dirty parse) when the rewritten
/// buffer STILL has any ERROR/MISSING node: a residual error proves the splice
/// was not the sole defect, so we must not silently substitute a different parse.
fn recover_c_family_backslash_crlf(
    source: &str,
    language: &LanguageId,
) -> Option<ParseSourceOutput> {
    // Transpose `\`+CR+LF -> `\`+LF+CR. Byte-length preserving, so spans hold.
    let bytes = source.as_bytes();
    let mut buf = bytes.to_vec();
    let mut i = 0usize;
    while i + 2 < buf.len() {
        if buf[i] == b'\\' && buf[i + 1] == b'\r' && buf[i + 2] == b'\n' {
            buf[i + 1] = b'\n';
            buf[i + 2] = b'\r';
            i += 3;
        } else {
            i += 1;
        }
    }
    // The transpose only moves existing valid-UTF-8 bytes, so the buffer stays
    // valid UTF-8; the lossy fallback never allocates for well-formed input.
    let rewritten = String::from_utf8_lossy(&buf);

    // `.h`/.c/.cpp are never `.tsx`; the flavor flag is irrelevant here. Reparse
    // under the SAME grammar — this fixes lexing, not language routing.
    let output = panic::catch_unwind(|| parse_source(&rewritten, language, false))
        .ok()?
        .ok()?;
    // Adopt ONLY a fully clean reparse; `has_error` is the second tuple element.
    if output.1 {
        return None;
    }
    Some(output)
}

/// Count ERROR and MISSING nodes in a tree-sitter tree.
///
/// `root.has_error()` is a boolean; for picking the *better* of two candidate
/// grammars on the same `.h` header (SF-STRESS-005) we need a magnitude — a C++
/// class header parsed as C explodes into hundreds of ERROR nodes, while the
/// same bytes parsed as C++ have zero. The count is the comparison key in
/// [`resolve_c_header_parse`].
fn count_error_nodes(root: &Node) -> usize {
    let mut cursor = root.walk();
    let mut stack = vec![*root];
    let mut count = 0usize;
    while let Some(node) = stack.pop() {
        if node.is_error() || node.is_missing() {
            count += 1;
        }
        cursor.reset(node);
        if cursor.goto_first_child() {
            stack.push(cursor.node());
            while cursor.goto_next_sibling() {
                stack.push(cursor.node());
            }
        }
    }
    count
}

/// Parse `source` under `language` and return the symbol output alongside the
/// ERROR/MISSING node count, so [`resolve_c_header_parse`] can pick the grammar
/// that parses a `.h` header with fewer errors. Returns `None` on a parse
/// failure or parser panic (treated as "infinitely bad" by the caller).
fn parse_source_scored(source: &str, language: &LanguageId) -> Option<(ParseSourceOutput, usize)> {
    let mut parser = Parser::new();
    let ts_language = match language {
        LanguageId::C => tree_sitter_c::LANGUAGE.into(),
        LanguageId::Cpp => tree_sitter_cpp::LANGUAGE.into(),
        // Only C/C++ grammars are scored for header disambiguation.
        _ => return None,
    };
    parser.set_language(&ts_language).ok()?;
    let tree = parser.parse(source, None)?;
    let error_nodes = count_error_nodes(&tree.root_node());
    // `.h` headers are never `.tsx`; the flavor flag is irrelevant here.
    let output = parse_source(source, language, false).ok()?;
    Some((output, error_nodes))
}

/// SF-STRESS-005: choose the grammar for a `.h` header from its CONTENT and
/// return the chosen parse plus the [`LanguageId`] to report.
///
/// `.h` maps to [`LanguageId::C`] by extension, but cross-platform repos fill
/// `.h` with C++ (`class`/`namespace`/`::`/`template`). The C grammar has no
/// `class_specifier`/`template`/`namespace` rule, so a C++ class header parses
/// into hundreds of ERROR nodes and the symbol extractor recovers NOTHING —
/// total symbol loss. The fix disambiguates in two complementary layers:
///
///   (a) Cheap content heuristic: if the header carries C++-only markers and no
///       blocking Objective-C marker, parse with the C++ grammar directly,
///       skipping a C parse that is known to fail. The header IS C++, so it is
///       reported as [`LanguageId::Cpp`].
///   (b) Error-count fallback for headers the heuristic does not flag: parse as
///       C; if that is clean, keep it (a plain C header stays C — the common
///       case, no extra work). Only when the C parse ERRORS do we make a
///       one-shot C++ attempt and adopt C++ iff it parses with STRICTLY fewer
///       ERROR/MISSING nodes. A tie keeps C (the extension default), so a header
///       that is genuinely C is never relabeled on a wash.
///
/// Objective-C honesty: `@interface`/`@property`/`@import` parse cleanly under
/// NEITHER grammar (no tree-sitter-objc is shipped). When an ObjC marker is
/// present without a C++ marker, the header stays C and surfaces as an honest
/// partial parse — never silently mislabeled as C++ to fake success. If the C++
/// grammar happened to recover fewer errors on such a file the error-count
/// fallback could still pick it, but the heuristic short-circuits the obvious
/// ObjC case to C first, and either way the result is reported with the grammar
/// that actually parsed it.
///
/// The return shape mirrors the inline `parse_source` call it replaces: the
/// `Result<Result<...>, _>` outer layer is the `catch_unwind` envelope the
/// caller already matches on.
fn resolve_c_header_parse(
    source: &str,
) -> (
    LanguageId,
    std::thread::Result<Result<ParseSourceOutput, String>>,
) {
    // Layer (a): content heuristic. C++ markers route straight to the C++
    // grammar (and C++ reporting); an ObjC-only header is left to the C grammar.
    let header_kind = classify_c_header_content(source);
    if matches!(header_kind, CHeaderContent::Cpp) {
        let output = panic::catch_unwind(|| parse_source(source, &LanguageId::Cpp, false));
        return (LanguageId::Cpp, output);
    }

    // Layer (b): parse as C first. A clean C parse (the overwhelmingly common
    // case for real C headers, and for ObjC headers there is nothing better to
    // offer) is kept as-is with no second parse.
    let c_scored = panic::catch_unwind(|| parse_source_scored(source, &LanguageId::C));
    let c_errors = match &c_scored {
        Ok(Some((_, errors))) => *errors,
        // C parse failed/panicked: fall back to the plain C parse path so the
        // caller reports the failure exactly as it would have without this hook.
        _ => {
            let output = panic::catch_unwind(|| parse_source(source, &LanguageId::C, false));
            return (LanguageId::C, output);
        }
    };
    if c_errors == 0 {
        // Clean C parse — a real C header. Keep C, no C++ attempt.
        return (
            LanguageId::C,
            c_scored.map(|opt| Ok(opt.expect("checked Some above").0)),
        );
    }

    // C parse has errors. Objective-C content has no better grammar to offer, so
    // do not burn a second parse chasing it — keep the honest C partial.
    if matches!(header_kind, CHeaderContent::ObjectiveC) {
        return (
            LanguageId::C,
            c_scored.map(|opt| Ok(opt.expect("checked Some above").0)),
        );
    }

    // Layer (b) C++ attempt: adopt C++ only if it parses with STRICTLY fewer
    // errors than C. A tie or a worse C++ parse keeps C (the extension default).
    let cpp_scored = panic::catch_unwind(|| parse_source_scored(source, &LanguageId::Cpp));
    match cpp_scored {
        Ok(Some((cpp_output, cpp_errors))) if cpp_errors < c_errors => {
            (LanguageId::Cpp, Ok(Ok(cpp_output)))
        }
        _ => (
            LanguageId::C,
            c_scored.map(|opt| Ok(opt.expect("checked Some above").0)),
        ),
    }
}

/// Coarse content classification of a `.h` header for grammar routing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CHeaderContent {
    /// Carries a C++-only construct (`class`/`namespace`/`template`/`::`/…).
    Cpp,
    /// Carries an Objective-C construct (`@interface`/`@property`/`@import`/…)
    /// and no C++ marker. No tree-sitter-objc grammar is shipped.
    ObjectiveC,
    /// No C++ or Objective-C marker found — treat as plain C.
    C,
}

/// Scan a `.h` header for C++-only and Objective-C markers, ignoring matches
/// inside line/block comments and string/char literals so a comment mentioning
/// `class` or a `"a::b"` string cannot misroute a plain C header.
///
/// C++ markers (none are legal in C89..C23): the `class`/`namespace`/`template`
/// keywords, the `::` scope-resolution operator, access labels `public:`/
/// `private:`/`protected:`, and `extern "C++"`. Objective-C markers: the `@`
/// directives `@interface`/`@implementation`/`@protocol`/`@property`/`@end` and
/// `#import`. A C++ marker wins over an ObjC marker (a `.h` with both is C++
/// with embedded ObjC-C++, routed to the C++ grammar).
fn classify_c_header_content(source: &str) -> CHeaderContent {
    let bytes = source.as_bytes();
    let mut found_cpp = false;
    let mut found_objc = false;
    let mut i = 0usize;
    let n = bytes.len();

    // Word-boundary helper: a keyword marker must not be glued to an
    // identifier character on either side (so `subclass`/`classify` do not match
    // `class`). Operates on byte offsets into `bytes`.
    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
    let word_at = |start: usize, kw: &[u8]| -> bool {
        let end = start + kw.len();
        if end > n || &bytes[start..end] != kw {
            return false;
        }
        let before_ok = start == 0 || !is_ident(bytes[start - 1]);
        let after_ok = end == n || !is_ident(bytes[end]);
        before_ok && after_ok
    };

    while i < n {
        let b = bytes[i];
        // Skip line comments.
        if b == b'/' && i + 1 < n && bytes[i + 1] == b'/' {
            i += 2;
            while i < n && bytes[i] != b'\n' {
                i += 1;
            }
            continue;
        }
        // Skip block comments.
        if b == b'/' && i + 1 < n && bytes[i + 1] == b'*' {
            i += 2;
            while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
                i += 1;
            }
            i = (i + 2).min(n);
            continue;
        }
        // Skip string / char literals (handle escapes so `"\""` stays inside).
        if b == b'"' || b == b'\'' {
            let quote = b;
            i += 1;
            while i < n {
                if bytes[i] == b'\\' {
                    i += 2;
                    continue;
                }
                if bytes[i] == quote {
                    i += 1;
                    break;
                }
                i += 1;
            }
            continue;
        }

        // C++ scope operator `::` — the single strongest C++ signal in headers
        // (`std::`, `flutter::DartProject`). Not valid anywhere in C.
        if b == b':' && i + 1 < n && bytes[i + 1] == b':' {
            found_cpp = true;
            i += 2;
            continue;
        }

        // Objective-C `@` directives.
        if b == b'@' {
            for kw in [
                b"interface".as_slice(),
                b"implementation",
                b"protocol",
                b"property",
                b"end",
            ] {
                if word_at(i + 1, kw) {
                    found_objc = true;
                    break;
                }
            }
            i += 1;
            continue;
        }

        // `#import` is an Objective-C preprocessor directive (C/C++ use
        // `#include`). Allow whitespace between `#` and `import`.
        if b == b'#' {
            let mut j = i + 1;
            while j < n && (bytes[j] == b' ' || bytes[j] == b'\t') {
                j += 1;
            }
            if word_at(j, b"import") {
                found_objc = true;
            }
            i += 1;
            continue;
        }

        // C++ keyword markers at a word boundary.
        if is_ident(b) && (i == 0 || !is_ident(bytes[i - 1])) {
            if word_at(i, b"class")
                || word_at(i, b"namespace")
                || word_at(i, b"template")
                || word_at(i, b"public") && bytes.get(i + 6).is_some_and(|&c| c == b':')
                || word_at(i, b"private") && bytes.get(i + 7).is_some_and(|&c| c == b':')
                || word_at(i, b"protected") && bytes.get(i + 9).is_some_and(|&c| c == b':')
            {
                found_cpp = true;
            }
            // Advance to the end of this identifier run to avoid re-scanning.
            i += 1;
            while i < n && is_ident(bytes[i]) {
                i += 1;
            }
            continue;
        }

        i += 1;
    }

    if found_cpp {
        CHeaderContent::Cpp
    } else if found_objc {
        CHeaderContent::ObjectiveC
    } else {
        CHeaderContent::C
    }
}

/// Cache-key discriminant for [`expected_partial_memo`]: which known grammar
/// limitation a cached verdict belongs to.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
enum ExpectedPartialCheck {
    /// SF-003: TS import-type immediately followed by `[]`.
    TsImportTypeArray,
    /// SF-004: Angular control-flow relational operators in `.html`.
    AngularControlFlow,
}

/// Upper bound on memo entries; at the cap the map is cleared wholesale.
/// Entries are a few machine words each, so the bound is generous; clearing
/// (vs an LRU) keeps the code trivial, and a refill costs one
/// re-classification per still-live file.
const EXPECTED_PARTIAL_MEMO_CAP: usize = 4096;

/// Process-wide memo for the SF-003/SF-004 neutralize-and-reparse verdicts,
/// keyed by (check, content length, content hash).
///
/// The classifiers cost up to two FULL tree-sitter parses per call and sit on
/// render paths invoked repeatedly with identical content: `health_stats`
/// iterates every partial-parse file per `health` call, and the
/// `get_file_context` / `validate_file_syntax` / sidecar envelopes re-classify
/// per render. A file's content is immutable for a given index generation, so
/// a (length, 64-bit content hash) key dedups those re-parses. A wrong-verdict
/// collision requires both equal 64-bit hashes AND equal lengths on different
/// content that is also classified differently — astronomically unlikely for
/// the worst case of a mislabeled health bucket. Stale entries from edited
/// files age out via the wholesale clear at [`EXPECTED_PARTIAL_MEMO_CAP`].
static EXPECTED_PARTIAL_MEMO: std::sync::LazyLock<std::sync::Mutex<ExpectedPartialMemoMap>> =
    std::sync::LazyLock::new(|| std::sync::Mutex::new(ExpectedPartialMemoMap::new()));

/// Memo key: (which check, content length, 64-bit content hash).
type ExpectedPartialMemoKey = (ExpectedPartialCheck, u64, u64);
/// Verdict map behind [`EXPECTED_PARTIAL_MEMO`].
type ExpectedPartialMemoMap = std::collections::HashMap<ExpectedPartialMemoKey, bool>;

/// Memoize one expected-partial classification: return the cached (check,
/// content) verdict, computing and inserting it on a miss.
///
/// `compute` runs WITHOUT the lock held, so two threads may race to compute
/// the same verdict — harmless (the verdict is deterministic) and preferable
/// to holding a global lock across a tree-sitter parse. A poisoned lock falls
/// back to the inner map: the map holds only derived verdicts, so there is no
/// invariant a panicking writer could have broken.
fn expected_partial_memo(
    check: ExpectedPartialCheck,
    content: &[u8],
    compute: impl FnOnce() -> bool,
) -> bool {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    content.hash(&mut hasher);
    let key = (check, content.len() as u64, hasher.finish());
    if let Some(&verdict) = EXPECTED_PARTIAL_MEMO
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
        .get(&key)
    {
        return verdict;
    }
    let verdict = compute();
    let mut memo = EXPECTED_PARTIAL_MEMO
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    if memo.len() >= EXPECTED_PARTIAL_MEMO_CAP {
        memo.clear();
    }
    memo.insert(key, verdict);
    verdict
}

/// Matches a TypeScript import-type member (`import('mod').Member`) immediately
/// followed by one or more postfix array suffixes (`[]`), allowing interior
/// whitespace. Capture group 1 is the scalar import-type member alone; the
/// trailing array suffixes are replaced with a single space when this is used
/// as a `${1} ` replacement (token-preserving: never fuses the member with a
/// following identifier fragment).
///
/// This is the construct that `tree-sitter-typescript 0.23.2` mis-parses
/// (SF-003): scalar `import('rxjs').Subscription` parses clean everywhere, but
/// the `[]` array suffix breaks the parse.
static IMPORT_TYPE_ARRAY_SUFFIX_RE: std::sync::LazyLock<regex::Regex> =
    std::sync::LazyLock::new(|| {
        regex::Regex::new(
            r#"(import\s*\(\s*['"][^'"]+['"]\s*\)\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\[\s*\])+"#,
        )
        .expect("SF-003 import-type-array regex is a valid pattern")
    });

/// SF-003: recognize a partial parse whose ONLY cause is the known
/// `tree-sitter-typescript 0.23.2` grammar limitation on an import-type
/// immediately followed by a `[]` array suffix (e.g.
/// `import('rxjs').Subscription[]`).
///
/// Soundness — this validates the WHOLE construct, not just the error prefix.
/// The naive "error node text starts with `import(` and the next char is `[`"
/// heuristic is UNSOUND: a genuinely broken file such as
/// `import('rxjs').Subscription[] = [ ; foo bar` produces a byte-identical
/// error node, so a prefix check would wrongly mark it clean.
///
/// Instead we neutralize only the suspected limitation and re-parse the whole
/// file: replace the `[]` array suffix on every import-type member with a single
/// space (leaving the scalar import-type, which parses clean in every position)
/// and re-parse. The replacement is a SPACE rather than a deletion so it is
/// token-preserving: deleting an empty `[]` between an import-type member and a
/// trailing identifier fragment (`import('x').Sub[]scription`) would glue them
/// into one valid identifier and falsely excuse a broken file; a space keeps
/// them as two tokens, so the broken file stays broken.
/// We return `true` iff:
///   1. the language is TypeScript, AND
///   2. the original source genuinely has a parse error, AND
///   3. at least one import-type-array construct is present (the regex matched),
///      AND
///   4. after stripping ONLY those array suffixes the file parses completely
///      clean (no ERROR/MISSING node anywhere).
///
/// Because the transform changes nothing but the array suffix (replaced by a
/// single space), a clean re-parse proves the array suffix was the SOLE cause of
/// the error. Any unrelated error elsewhere keeps the transformed parse dirty, so
/// a genuinely broken file stays classified as a partial parse.
pub(crate) fn is_expected_typescript_import_type_array_limitation(
    language: &LanguageId,
    content: &[u8],
    is_tsx: bool,
) -> bool {
    if !matches!(language, LanguageId::TypeScript) {
        return false;
    }

    // Memoized: the regex pre-gate plus up to two full re-parses below run at
    // most once per distinct content (see `expected_partial_memo`); render
    // paths calling this repeatedly with unchanged content hit the cache.
    expected_partial_memo(ExpectedPartialCheck::TsImportTypeArray, content, || {
        let source = String::from_utf8_lossy(content);

        // The construct must actually be present; otherwise this limitation is not
        // what we are looking at.
        if !IMPORT_TYPE_ARRAY_SUFFIX_RE.is_match(&source) {
            return false;
        }

        // Defensive: only meaningful for files that genuinely failed to parse. If
        // the original parses clean there is no limitation to excuse.
        let original_has_error =
            match panic::catch_unwind(|| parse_source(&source, language, is_tsx)) {
                Ok(Ok((_, has_error, _, _, _))) => has_error,
                _ => return false,
            };
        if !original_has_error {
            return false;
        }

        // Neutralize ONLY the array suffix on import-type members, then re-parse the
        // whole file. A clean re-parse proves the array suffix was the sole cause.
        //
        // The `[]` run is replaced with a SINGLE SPACE (not deleted) so the
        // neutralization is token-preserving. Deleting an empty `[]` between an
        // import-type member and a trailing identifier fragment would GLUE them into
        // a single valid identifier (e.g. `import('x').Sub[]scription` ->
        // `import('x').Subscription`), making a genuinely broken file re-parse clean
        // and be falsely excused. A space keeps `Sub[]scription` as two tokens
        // (`Sub scription`, still broken) while the legitimate `Subscription[]`
        // becomes `Subscription ` (a scalar import-type with trailing whitespace,
        // still clean). Multi-dim `[][]` is one match, replaced by one space.
        let neutralized = IMPORT_TYPE_ARRAY_SUFFIX_RE.replace_all(&source, "${1} ");
        match panic::catch_unwind(|| parse_source(&neutralized, language, is_tsx)) {
            Ok(Ok((_, has_error, _, _, _))) => !has_error,
            _ => false,
        }
    })
}

/// Matches the START of an Angular control-flow opener (`@if`/`@for`/`@switch`/
/// `@defer`/`@else if`) up to and including its opening `(`. Capture groups:
///   1. the boundary char before `@` (start-of-line or a non-identifier char),
///      preserved so we never match a keyword embedded in an identifier (e.g.
///      `foo@if`),
///   2. the opener keyword plus the opening `(`.
///
/// This regex deliberately does NOT capture the control-expression body. The
/// body can contain nested parentheses — real Angular like
/// `@if (applications().length > 0) {` or `@for (a of items(); track a.id) {` —
/// which a `[^()]*` body could never span, so the old single-regex form silently
/// failed to capture the common case. Instead the matcher locates the opener's
/// `(` here, then scans forward in code (see
/// [`is_expected_angular_template_control_flow_limitation`]) with a paren-depth
/// counter to find the matching close `)`, neutralizing the relational operators
/// (`<`/`>`, covering `<`, `>`, `<=`, `>=`) within that balanced span only —
/// never elsewhere in the file — so a clean re-parse proves whether those
/// operators were the SOLE cause of the error.
static ANGULAR_CONTROL_FLOW_OPENER_RE: std::sync::LazyLock<regex::Regex> =
    std::sync::LazyLock::new(|| {
        regex::Regex::new(r"(?m)(^|[^A-Za-z0-9_$])(@(?:if|for|switch|defer|else\s+if)\s*\()")
            .expect("SF-004 Angular control-flow opener regex is a valid pattern")
    });

/// SF-004: recognize a partial parse whose ONLY cause is the known
/// `tree-sitter-html 0.23.2` grammar limitation on Angular template control-flow
/// relational operators (`@if (a > b) {`, `@for`, `@switch`, `@defer`,
/// `@else if`). `tree-sitter-html` has zero Angular rules; the `<`/`>` relational
/// operator inside a control expression is lexed as a tag delimiter, producing an
/// ERROR node even though SymForge text-scans the construct and still extracts
/// symbols.
///
/// Soundness — this validates the WHOLE file, not a single diagnostic line. The
/// previous heuristic trusted `parse_diagnostic.line` to point at the Angular
/// opener; that is UNSOUND, because tree-sitter's deepest-smallest ERROR node can
/// pin the diagnostic to a valid `@if` line even when the real defect is an
/// unclosed `<div>` or a stray `</div>` elsewhere (verified empirically against
/// tree-sitter-html 0.23.2: a stray end tag after a closed structure reports its
/// error on the `@if` opener line, masking the real defect). A no-diagnostic
/// fallback was even worse — it excused arbitrary broken HTML.
///
/// Instead we mirror SF-003: neutralize ONLY the suspected limitation and
/// re-parse the whole file. For every Angular control-flow opener we locate its
/// `(...)` control expression by scanning forward from the opening `(` with a
/// paren-depth counter (so nested calls like `applications().length` and
/// multi-clause `@for` headers are handled correctly), and replace each `<`/`>`
/// inside that balanced span with a single space (length- and token-preserving;
/// a space cannot fuse adjacent tokens), leaving the rest of the file — including
/// every ordinary HTML tag's `<`/`>` — byte-for-byte unchanged. An opener whose
/// `(` is never closed before EOF (depth never returns to zero) is skipped, not
/// neutralized: an unterminated control expression is itself a genuine defect and
/// must keep the file dirty. We return `true` iff:
///   1. the language is HTML, AND
///   2. the original source genuinely has a parse error, AND
///   3. at least one Angular control-flow opener is present (the regex matched),
///      AND
///   4. after neutralizing ONLY those openers' relational operators the file
///      parses completely clean (no ERROR/MISSING node anywhere).
///
/// Because the transform changes nothing but the relational operators inside the
/// Angular openers, a clean re-parse proves those operators were the SOLE cause of
/// the error. Any unrelated defect (unclosed `<div>`, stray `</div>`/
/// erroneous_end_tag, broken attribute anywhere) keeps the transformed parse dirty,
/// so a genuinely broken file is NOT excused. This closes both the masked-defect
/// hole and the no-diagnostic-fallback hole by construction.
///
/// Known limitation (safe direction): `tree-sitter-html` also mis-lexes `&&`
/// inside a control expression, and we do not neutralize it. A valid Angular
/// template whose control expression uses `&&` therefore stays dirty after
/// neutralization and is NOT excused — it surfaces as an unexpected partial rather
/// than being masked. Under-excusing is the safe failure mode; we never falsely
/// excuse a broken file.
pub(crate) fn is_expected_angular_template_control_flow_limitation(
    language: &LanguageId,
    content: &[u8],
) -> bool {
    if !matches!(language, LanguageId::Html) {
        return false;
    }

    // Memoized: the regex pre-gate plus up to two full re-parses below run at
    // most once per distinct content (see `expected_partial_memo`); render
    // paths calling this repeatedly with unchanged content hit the cache.
    expected_partial_memo(ExpectedPartialCheck::AngularControlFlow, content, || {
        let source = String::from_utf8_lossy(content);

        // The construct must actually be present; otherwise this limitation is not
        // what we are looking at.
        if !ANGULAR_CONTROL_FLOW_OPENER_RE.is_match(&source) {
            return false;
        }

        // Defensive: only meaningful for files that genuinely failed to parse. If the
        // original parses clean there is no limitation to excuse.
        // HTML is never TSX; the flavor flag is irrelevant for this grammar.
        let original_has_error =
            match panic::catch_unwind(|| parse_source(&source, language, false)) {
                Ok(Ok((_, has_error, _, _, _))) => has_error,
                _ => return false,
            };
        if !original_has_error {
            return false;
        }

        // Neutralize ONLY the relational operators inside each Angular control-flow
        // opener's balanced `(...)` expression, then re-parse the whole file. The
        // opener keyword, the parens, and every byte outside the matched openers
        // (including ordinary HTML tag `<`/`>`) are preserved verbatim. A clean
        // re-parse proves the relational operators were the sole cause.
        let neutralized = neutralize_angular_control_flow_operators(&source);
        match panic::catch_unwind(|| parse_source(&neutralized, language, false)) {
            Ok(Ok((_, has_error, _, _, _))) => !has_error,
            _ => false,
        }
    })
}

/// Replace the relational operators (`<`/`>`) inside every Angular control-flow
/// opener's balanced `(...)` control expression with spaces, byte-length
/// preserving, and leave the rest of `source` untouched.
///
/// For each opener matched by [`ANGULAR_CONTROL_FLOW_OPENER_RE`] we scan forward
/// from the opening `(` tracking paren depth and replacing `<`/`>` with spaces
/// until the matching close `)` (depth returns to zero). An opener whose `(` is
/// never closed before the end of input is skipped entirely — its bytes are
/// copied verbatim — so an unterminated control expression keeps the re-parse
/// dirty and is NOT excused. Bytes between openers are copied verbatim.
///
/// Soundness: the only bytes ever changed are `<`/`>` that sit at paren depth >= 1
/// inside a recognized opener span. A `<` or `>` of an ordinary HTML tag lives at
/// depth 0 (outside any opener's parens) and is never touched. Replacing with a
/// single ASCII space is length-preserving, so byte offsets — and therefore every
/// extracted symbol's span — are identical between the original and neutralized
/// source.
fn neutralize_angular_control_flow_operators(source: &str) -> String {
    let original = source.as_bytes();
    // Work on a byte buffer so we can do constant-byte overwrites without
    // touching `unsafe`. The only mutation is ASCII `<`/`>` -> ASCII space, so the
    // buffer stays valid UTF-8 and `String::from_utf8` below cannot fail.
    let mut buf = original.to_vec();

    for m in ANGULAR_CONTROL_FLOW_OPENER_RE.find_iter(source) {
        // The match ends exactly at the opener's `(`; scan the expression body
        // that follows it.
        let open_paren = m.end();
        debug_assert_eq!(original.get(open_paren - 1), Some(&b'('));

        let mut depth: usize = 1;
        let mut i = open_paren;
        let mut closed = false;
        let mut neutralized_any = false;
        while i < original.len() {
            match original[i] {
                b'(' => depth += 1,
                b')' => {
                    depth -= 1;
                    if depth == 0 {
                        closed = true;
                        break;
                    }
                }
                b'<' | b'>' => {
                    buf[i] = b' ';
                    neutralized_any = true;
                }
                _ => {}
            }
            i += 1;
        }

        // Unterminated opener (EOF before the close paren): this is a genuine
        // defect, not the excusable limitation. Roll back any neutralization we
        // performed inside this opener so the span stays byte-for-byte original
        // and the re-parse remains dirty.
        if !closed && neutralized_any {
            buf[open_paren..i].copy_from_slice(&original[open_paren..i]);
        }
    }

    // The transform only ever replaces single-byte ASCII `<`/`>` with a
    // single-byte ASCII space, so the buffer is still valid UTF-8.
    String::from_utf8(buf).unwrap_or_else(|_| source.to_string())
}

/// Extract symbol name → body-hash pairs from source code using tree-sitter.
///
/// Used by `diff_symbols` to compare symbol-level changes between git refs.
/// Falls back to `None` for unsupported or config languages so callers can
/// use the legacy regex extractor.
pub fn extract_symbols_for_diff(source: &str, path: &str) -> Option<Vec<(String, String)>> {
    let ext = path.rsplit('.').next().unwrap_or("");
    let language = LanguageId::from_extension(ext)?;
    if config_extractors::is_config_language(&language) {
        return None; // Config files don't go through tree-sitter.
    }
    let is_tsx = LanguageId::is_tsx_path(path);
    let result = panic::catch_unwind(|| parse_source(source, &language, is_tsx));
    let (symbols, ..) = match result {
        Ok(Ok(output)) => output,
        _ => return None,
    };
    let pairs: Vec<(String, String)> = symbols
        .iter()
        .map(|sym| {
            let (start, end) = sym.byte_range;
            let body = &source[start as usize..end as usize];
            (sym.name.clone(), crate::hash::digest_hex(body.as_bytes()))
        })
        .collect();
    Some(pairs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{FileOutcome, LanguageId, SymbolKind};

    // SF-STRESS-005: content classification of a `.h` header for grammar routing.
    #[test]
    fn test_classify_c_header_content_cpp_markers() {
        // Each C++-only construct must classify as Cpp.
        for src in [
            "class FlutterWindow {};",
            "namespace ns { }",
            "template <typename T> struct S {};",
            "flutter::DartProject project_;",
            "class A : public B {\n public:\n};",
        ] {
            assert_eq!(
                classify_c_header_content(src),
                CHeaderContent::Cpp,
                "expected Cpp for: {src:?}"
            );
        }
    }

    #[test]
    fn test_classify_c_header_content_plain_c() {
        // Plain C constructs carry no C++/ObjC marker.
        for src in [
            "struct point { int x; int y; };",
            "int add(int a, int b);",
            "#define MAX 10\nvoid f(void);",
            "typedef struct node node_t;",
        ] {
            assert_eq!(
                classify_c_header_content(src),
                CHeaderContent::C,
                "expected C for: {src:?}"
            );
        }
    }

    #[test]
    fn test_classify_c_header_content_objc_markers() {
        for src in [
            "@interface Foo : NSObject\n@end",
            "#import <Foundation/Foundation.h>",
            "@protocol Bar\n@end",
            "@property(nonatomic) int x;",
        ] {
            assert_eq!(
                classify_c_header_content(src),
                CHeaderContent::ObjectiveC,
                "expected ObjectiveC for: {src:?}"
            );
        }
    }

    #[test]
    fn test_classify_c_header_content_ignores_comments_and_strings() {
        // A C++ marker inside a comment or string must NOT misroute plain C.
        assert_eq!(
            classify_c_header_content("// this header defines a class\nint f(void);"),
            CHeaderContent::C,
            "`class` in a line comment must not trigger Cpp"
        );
        assert_eq!(
            classify_c_header_content("/* namespace std */\nint f(void);"),
            CHeaderContent::C,
            "`namespace` in a block comment must not trigger Cpp"
        );
        assert_eq!(
            classify_c_header_content("const char* s = \"a::b\";\nint f(void);"),
            CHeaderContent::C,
            "`::` inside a string literal must not trigger Cpp"
        );
        // `subclass`/`classify` must not match the `class` keyword.
        assert_eq!(
            classify_c_header_content("int subclass; void classify(void);"),
            CHeaderContent::C,
            "`class` as a substring of an identifier must not trigger Cpp"
        );
    }

    #[test]
    fn test_classify_c_header_content_cpp_wins_over_objc() {
        // A header with BOTH ObjC and C++ markers (Objective-C++) is routed to
        // the C++ grammar, which can parse the C++ portions.
        assert_eq!(
            classify_c_header_content("#import <F/F.h>\nclass Foo {};"),
            CHeaderContent::Cpp
        );
    }

    #[test]
    fn test_process_file_rust_extracts_function() {
        let source = b"fn hello() { }";
        let result = process_file("test.rs", source, LanguageId::Rust);
        assert_eq!(result.outcome, FileOutcome::Processed);
        assert!(!result.symbols.is_empty());
        assert_eq!(result.symbols[0].name, "hello");
        assert_eq!(result.symbols[0].kind, SymbolKind::Function);
    }

    // Regression guard: tree-sitter-rust must parse `&raw` as a borrow of a
    // variable named `raw`, not as the start of `&raw const`/`&raw mut`.
    // Originally guarded an in-tree byte-rewrite workaround (043b884); now
    // pins the upgraded parser (9f7ff32) against future grammar regressions.
    #[test]
    fn test_process_file_rust_accepts_borrowed_raw_identifier() {
        let source = b"fn main() { let raw = 1; let _x = &raw; }";
        let result = process_file("test.rs", source, LanguageId::Rust);
        assert_eq!(result.outcome, FileOutcome::Processed);
    }

    // Regression guard: tree-sitter-rust must still recognize Rust 2024
    // raw-reference syntax after the upgrade.
    #[test]
    fn test_process_file_rust_preserves_raw_borrow_syntax() {
        for source in [
            b"fn main() { let value = 1; let _ptr = &raw const value; }" as &[u8],
            b"fn main() { let mut value = 1; let _ptr = &raw mut value; }",
        ] {
            let result = process_file("test.rs", source, LanguageId::Rust);
            assert_eq!(result.outcome, FileOutcome::Processed);
        }
    }

    #[test]
    fn test_process_file_python_extracts_function() {
        let source = b"def greet():\n    pass";
        let result = process_file("test.py", source, LanguageId::Python);
        assert_eq!(result.outcome, FileOutcome::Processed);
        assert!(!result.symbols.is_empty());
        assert_eq!(result.symbols[0].name, "greet");
        assert_eq!(result.symbols[0].kind, SymbolKind::Function);
    }

    #[test]
    fn test_process_file_javascript_extracts_function() {
        let source = b"function doStuff() { }";
        let result = process_file("test.js", source, LanguageId::JavaScript);
        assert_eq!(result.outcome, FileOutcome::Processed);
        assert!(!result.symbols.is_empty());
        assert_eq!(result.symbols[0].name, "doStuff");
        assert_eq!(result.symbols[0].kind, SymbolKind::Function);
    }

    #[test]
    fn test_process_file_jsx_parses_clean_and_extracts_symbols() {
        // Regression: `.jsx` maps to LanguageId::JavaScript (src/domain/index.rs),
        // and tree-sitter-javascript 0.25 parses JSX natively — fragments
        // (`<>...</>`), self-closing components (`<Row .../>`), `prop={expr}`
        // attributes, and `.map(...)` with JSX children all parse under the plain
        // JavaScript grammar with NO `.tsx`-style flavor switch (`.jsx` is not a
        // TSX path). This pins that a realistic JSX component file parses cleanly
        // (no partial diagnostic) and the component function symbol survives.
        let source = br#"
import { Row } from './Row';

export function Table({ rows }) {
  return (
    <>
      {rows.map((r) => (
        <Row key={r.id} value={r.value} />
      ))}
    </>
  );
}
"#;
        let result = process_file("src/Table.jsx", source, LanguageId::JavaScript);
        assert_eq!(
            result.outcome,
            FileOutcome::Processed,
            "a `.jsx` file must parse cleanly under the JavaScript grammar; got {:?} / {:?}",
            result.outcome,
            result.parse_diagnostic
        );
        assert!(
            result.parse_diagnostic.is_none(),
            "clean JSX parse must not attach a diagnostic; got {:?}",
            result.parse_diagnostic
        );
        assert!(
            result.symbols.iter().any(|s| s.name == "Table"),
            "the JSX component function `Table` must be extracted; got {:?}",
            result.symbols
        );
    }

    #[test]
    fn test_process_file_typescript_extracts_interface() {
        let source = b"interface Greeter { greet(): void; }";
        let result = process_file("test.ts", source, LanguageId::TypeScript);
        assert_eq!(result.outcome, FileOutcome::Processed);
        let interface = result
            .symbols
            .iter()
            .find(|s| s.kind == SymbolKind::Interface);
        assert!(interface.is_some());
        assert_eq!(interface.unwrap().name, "Greeter");
    }

    // SF-003: tree-sitter-typescript 0.23.2 mis-parses an import-type immediately
    // followed by `[]` (valid TS). The detector must recognize this known grammar
    // limitation WITHOUT over-broadening to mask genuine syntax errors.

    #[test]
    fn test_sf003_class_field_import_type_array_is_expected_limitation() {
        // The exact reported repro shape: `private subs: import('rxjs').Subscription[] = [];`
        let source = b"class C { private subs: import('rxjs').Subscription[] = []; }";
        let result = process_file(
            "workflow-builder.component.ts",
            source,
            LanguageId::TypeScript,
        );
        // tree-sitter still flags it as a partial parse...
        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "expected grammar still reports a partial parse for the limitation case"
        );
        // ...but the SF-003 detector recognizes it as the known grammar limitation.
        assert!(
            is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "class-field import-type-array must be recognized as an expected grammar limitation"
        );
        // Symbols are still extracted (the class `C` is present).
        assert!(
            result.symbols.iter().any(|s| s.name == "C"),
            "class symbol C must still be extracted despite the partial parse"
        );
    }

    #[test]
    fn test_sf003_type_alias_import_type_array_is_expected_limitation() {
        // The variant a naive `[`-prefix detector would miss (the error node here is
        // a MISSING `;`, not the import-type itself).
        let source = b"type S = import('rxjs').Subscription[];";
        let result = process_file("types.ts", source, LanguageId::TypeScript);
        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "type-alias import-type-array still reports a partial parse"
        );
        assert!(
            is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "type-alias import-type-array must be recognized as an expected grammar limitation"
        );
    }

    #[test]
    fn test_sf003_multidim_import_type_array_is_expected_limitation() {
        let source = b"type S = import('rxjs').Subscription[][];";
        assert!(
            is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "multi-dimensional import-type-array must be recognized as an expected limitation"
        );
    }

    /// Perf regression (review finding 1, post-v7.19.0): the expensive
    /// neutralize-and-reparse classification must be computed at most once per
    /// distinct content. The second lookup's compute closure panics, so this
    /// fails loudly if the memo stops short-circuiting; distinct check kinds
    /// and distinct content must not share verdicts.
    #[test]
    fn test_expected_partial_memo_hit_skips_recompute() {
        // Unique content so parallel/preceding tests cannot pre-seed the key.
        let content = b"memo-probe :: post-v7.19.0 review fix 1 :: unique";
        assert!(expected_partial_memo(
            ExpectedPartialCheck::TsImportTypeArray,
            content,
            || true
        ));
        // Memo hit: the closure must NOT run again for the same (check, content).
        assert!(expected_partial_memo(
            ExpectedPartialCheck::TsImportTypeArray,
            content,
            || panic!("verdict must come from the memo, not a recompute"),
        ));
        // The OTHER check kind on the same content is a distinct key.
        assert!(!expected_partial_memo(
            ExpectedPartialCheck::AngularControlFlow,
            content,
            || false
        ));
        // Different content (same length as `content`) is a distinct key.
        let other = b"memo-probe :: post-v7.19.0 review fix 1 :: uniquf";
        assert_eq!(content.len(), other.len());
        assert!(!expected_partial_memo(
            ExpectedPartialCheck::TsImportTypeArray,
            other,
            || false
        ));
    }

    #[test]
    fn test_sf003_negative_control_genuinely_broken_array_stays_partial() {
        // REQUIRED negative control: a genuinely broken file that begins with the
        // SAME import-type-array prefix (byte-identical error node) MUST NOT be
        // excused. This proves the detector validates the WHOLE construct, not just
        // the error-node prefix.
        let source = b"class C { private subs: import('rxjs').Subscription[] = [ ; foo bar baz }";
        let result = process_file("broken.ts", source, LanguageId::TypeScript);
        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "the genuinely broken file is a partial parse"
        );
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "a genuinely broken file sharing the import-type-array prefix MUST stay partial"
        );
    }

    #[test]
    fn test_sf003_negative_control_identifier_glue_stays_partial() {
        // SOUNDNESS REGRESSION (glue bypass): a genuinely broken file where an
        // EMPTY `[]` sits between an import-type member and a trailing identifier
        // fragment. A token-destroying neutralization (deleting the `[]`) would
        // fuse `Sub[]scription` into the valid identifier `Subscription` and falsely
        // re-parse clean. The token-preserving (space) neutralization keeps it as
        // `Sub scription` (two tokens), so it stays partial and is NOT excused.
        let source = b"type S = import('x').Sub[]scription;";
        let result = process_file("glue-type-alias.ts", source, LanguageId::TypeScript);
        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "the genuinely broken identifier-glue file is a partial parse"
        );
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "deleting `[]` would glue `Sub[]scription` into a valid identifier; the \
             token-preserving neutralization must keep this broken file partial"
        );
    }

    #[test]
    fn test_sf003_negative_control_identifier_glue_class_field_stays_partial() {
        // Same glue bypass in a class-field position (the reported repro shape):
        // `import('rxjs').Sub[]scription` must NOT be excused.
        let source = b"class C { private a: import('rxjs').Sub[]scription = x; }";
        let result = process_file("glue-class-field.ts", source, LanguageId::TypeScript);
        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "the genuinely broken class-field identifier-glue file is a partial parse"
        );
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "a class-field import-type member glued to a trailing identifier fragment \
             must stay partial under the token-preserving neutralization"
        );
    }

    #[test]
    fn test_sf003_negative_control_unrelated_syntax_error_stays_partial() {
        // A plain malformed class with no import-type-array at all stays partial.
        let source = b"class C { private x: = ; }";
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "an unrelated syntax error must not be excused as an import-type-array limitation"
        );
    }

    #[test]
    fn test_sf003_negative_control_valid_array_plus_real_error_stays_partial() {
        // A valid import-type-array PLUS a separate real error elsewhere: neutralizing
        // the array suffix does NOT make the file clean, so it stays partial. The
        // detector never masks the real defect.
        let source = b"type S = import('rxjs').Subscription[]; class C { private x: = ; }";
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "a real error elsewhere must keep the file partial even when an import-type-array is present"
        );
    }

    #[test]
    fn test_sf003_detector_is_typescript_gated() {
        // The detector only applies to TypeScript. Other languages are never excused.
        let source = b"type S = import('rxjs').Subscription[];";
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::JavaScript,
                source,
                false
            ),
            "the import-type-array limitation detector must be TypeScript-gated"
        );
    }

    #[test]
    fn test_sf003_detector_false_on_clean_typescript() {
        // A clean TS file with no import-type-array is never flagged as the limitation.
        let source = b"type S = import('rxjs').Subscription;";
        assert!(
            !is_expected_typescript_import_type_array_limitation(
                &LanguageId::TypeScript,
                source,
                false
            ),
            "clean scalar import-type must not be classified as a limitation"
        );
    }

    // --- TSX grammar selection (.tsx uses LANGUAGE_TSX, .ts stays on TYPESCRIPT) ---

    #[test]
    fn test_tsx_jsx_component_parses_clean_and_extracts_symbols() {
        // The real-world repro: a JSX-returning function. The plain TypeScript
        // grammar cannot parse JSX and reports a partial parse ("syntax missing
        // >"); the TSX grammar (selected from the `.tsx` extension) parses it
        // cleanly and the JSX-nested function symbols survive.
        let source = br#"
export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}
"#;
        let result = process_file("src/App.tsx", source, LanguageId::TypeScript);
        assert_eq!(
            result.outcome,
            FileOutcome::Processed,
            "a `.tsx` JSX component must parse cleanly under the TSX grammar; got {:?} / {:?}",
            result.outcome,
            result.parse_diagnostic
        );
        assert!(
            result.parse_diagnostic.is_none(),
            "clean TSX parse must not attach a diagnostic; got {:?}",
            result.parse_diagnostic
        );
        assert!(
            result.symbols.iter().any(|s| s.name == "App"),
            "the JSX component function `App` must be extracted; got {:?}",
            result.symbols
        );
    }

    #[test]
    fn test_tsx_grammar_selected_by_extension_not_languageid() {
        // The same JSX source under a `.ts` extension uses the plain TypeScript
        // grammar, which cannot parse JSX — so it is a partial parse. This pins
        // the invariant that the grammar is chosen by the file extension, not by
        // the (shared) LanguageId::TypeScript.
        let source = br#"
export function App() {
  return <div>hi</div>;
}
"#;
        let as_tsx = process_file("src/App.tsx", source, LanguageId::TypeScript);
        assert_eq!(
            as_tsx.outcome,
            FileOutcome::Processed,
            "JSX under `.tsx` must parse clean"
        );
        let as_ts = process_file("src/App.ts", source, LanguageId::TypeScript);
        assert!(
            matches!(as_ts.outcome, FileOutcome::PartialParse { .. }),
            "JSX under `.ts` must remain a partial parse (plain TS grammar has no JSX); got {:?}",
            as_ts.outcome
        );
    }

    #[test]
    fn test_ts_angle_bracket_type_assertion_still_parses_clean() {
        // CRITICAL regression: the TSX grammar rejects legacy angle-bracket type
        // assertions (`<T>expr`), which are valid in plain `.ts`. A `.ts` file
        // using `<number>y` must keep parsing cleanly on LANGUAGE_TYPESCRIPT and
        // must NOT be routed to the TSX grammar.
        let source = b"const y: unknown = 1;\nconst x = <number>y;\n";
        let result = process_file("src/cast.ts", source, LanguageId::TypeScript);
        assert_eq!(
            result.outcome,
            FileOutcome::Processed,
            "a `.ts` angle-bracket cast must parse cleanly under the TypeScript grammar; got {:?} / {:?}",
            result.outcome,
            result.parse_diagnostic
        );
        assert!(
            result.parse_diagnostic.is_none(),
            "clean `.ts` cast must not attach a diagnostic; got {:?}",
            result.parse_diagnostic
        );
        assert!(
            result.symbols.iter().any(|s| s.name == "x"),
            "the cast binding `x` must be extracted; got {:?}",
            result.symbols
        );
    }

    #[test]
    fn test_is_tsx_path_classifier() {
        assert!(LanguageId::is_tsx_path("src/App.tsx"));
        assert!(LanguageId::is_tsx_path("App.TSX"));
        assert!(LanguageId::is_tsx_path(r"src\components\App.tsx"));
        assert!(!LanguageId::is_tsx_path("src/App.ts"));
        assert!(!LanguageId::is_tsx_path("src/App.jsx"));
        assert!(!LanguageId::is_tsx_path("tsx"));
        assert!(!LanguageId::is_tsx_path("dir.tsx/file.ts"));
        assert!(!LanguageId::is_tsx_path("noext"));
    }

    #[test]
    fn test_process_file_go_extracts_function() {
        let source = b"package main\nfunc main() { }";
        let result = process_file("test.go", source, LanguageId::Go);
        assert_eq!(result.outcome, FileOutcome::Processed);
        assert!(!result.symbols.is_empty());
        assert_eq!(result.symbols[0].name, "main");
        assert_eq!(result.symbols[0].kind, SymbolKind::Function);
    }

    #[test]
    fn test_process_file_partial_parse() {
        let source = b"fn broken( { }";
        let result = process_file("bad.rs", source, LanguageId::Rust);
        assert!(matches!(result.outcome, FileOutcome::PartialParse { .. }));
    }

    #[test]
    fn test_process_file_partial_parse_has_diagnostic() {
        let source = b"fn broken( { }";
        let result = process_file("bad.rs", source, LanguageId::Rust);
        assert!(matches!(result.outcome, FileOutcome::PartialParse { .. }));
        let diag = result
            .parse_diagnostic
            .expect("should have a diagnostic for partial parse");
        assert_eq!(diag.parser, "tree-sitter");
        assert!(diag.line.is_some(), "diagnostic should have a line number");
        assert!(
            diag.column.is_some(),
            "diagnostic should have a column number"
        );
        assert!(
            diag.byte_span.is_some(),
            "diagnostic should have a byte span"
        );
        assert!(
            diag.message.contains("syntax"),
            "message should describe the error"
        );
    }

    #[test]
    fn test_process_file_partial_parse_diagnostic_pins_location() {
        // Tightens the ParseDiagnostic contract that validate_file_syntax relies on:
        // a partial parse must pinpoint the actually-broken line, not just return
        // Some(1) for everything. The source below has two clean lines followed
        // by a broken one, so a regression that loses multi-line tracking or
        // hardcodes line 1 would slip past the existing is_some()-only test but
        // fail here.
        let source = b"fn foo() {}\nfn bar() {}\nfn broken( { }";
        let result = process_file("multi.rs", source, LanguageId::Rust);

        assert!(
            matches!(result.outcome, FileOutcome::PartialParse { .. }),
            "source with a line-3 syntax error should be partial-parsed; got {:?}",
            result.outcome
        );

        let diag = result
            .parse_diagnostic
            .expect("partial parse must attach a ParseDiagnostic");

        assert_eq!(diag.parser, "tree-sitter");
        assert!(
            !diag.fallback_used,
            "tree-sitter parses must not set fallback_used; that flag is reserved \
             for config extractors that recover via a secondary parser"
        );

        // Line is 1-based and must track the actual error row. Line 3 is where
        // `fn broken( { }` lives (bytes 24..).
        let line = diag.line.expect("diagnostic must carry a line number");
        assert!(
            line >= 3,
            "error is on line 3 of the source; diagnostic reported line {line}"
        );

        let column = diag.column.expect("diagnostic must carry a column number");
        assert!(column >= 1, "columns are 1-based; got {column}");

        // Byte span must be ordered and inside the source, and must land on
        // line 3 (which starts at byte 24: "fn foo() {}\n" + "fn bar() {}\n").
        // Note: tree-sitter MISSING nodes are zero-width (start == end), so we
        // allow span_start == span_end but require start <= end.
        let (span_start, span_end) = diag
            .byte_span
            .expect("diagnostic must carry a byte span for downstream editors");
        assert!(
            span_start <= span_end,
            "byte_span must be ordered; got {span_start}..{span_end}"
        );
        assert!(
            (span_end as usize) <= source.len(),
            "byte_span must fit inside source (len {}); got end {span_end}",
            source.len()
        );
        assert!(
            span_start >= 24,
            "byte_span should point at line 3 content (starts at byte 24); got {span_start}"
        );

        // location_display is what validate_file_syntax / get_file_context use
        // to render "(line X, column Y)" in tool output. Both must flow through.
        let loc = diag
            .location_display()
            .expect("location_display must render when both line and column are present");
        assert!(
            loc.contains(&format!("line {line}")),
            "location_display must include the line; got {loc}"
        );
        assert!(
            loc.contains(&format!("column {column}")),
            "location_display must include the column; got {loc}"
        );

        // summary() is what feeds into FileOutcome::PartialParse { warning } —
        // pin that the warning carries the structured location, not just the bare
        // message, so the index-health "partial files" path shows actionable info.
        let summary = diag.summary();
        assert!(
            summary.contains("tree-sitter:"),
            "summary should prefix with parser name; got {summary}"
        );
        assert!(
            summary.contains(&format!("line {line}")),
            "summary should include location for downstream display; got {summary}"
        );
    }

    #[test]
    fn test_parse_source_reports_deepest_actionable_nested_error_node() {
        let source =
            "def compute():\n    value = outer(inner(1 + 2, tail)\n    other = {'a': {'b': 1}}\n";
        let (_symbols, has_error, diagnostic, _references, _aliases) =
            parse_source(source, &LanguageId::Python, false).expect("python parse should complete");

        assert!(has_error, "fixture must contain a tree-sitter parse error");
        let diag = diagnostic.expect("partial parse must attach a diagnostic");

        assert_eq!(diag.parser, "tree-sitter");
        assert_eq!(diag.line, Some(2), "unexpected diagnostic: {diag:?}");
        assert_eq!(diag.column, Some(19), "unexpected diagnostic: {diag:?}");
        assert_eq!(
            diag.byte_span,
            Some((33, 51)),
            "diagnostic must pin the nested call error, not the outer assignment; got {diag:?}"
        );
        assert!(
            diag.message
                .contains("syntax error near `inner(1 + 2, tail)`"),
            "diagnostic should include the nested call context; got {diag:?}"
        );
    }

    #[test]
    fn test_parse_source_reports_actionable_missing_node_context() {
        let source = "fn compute() {\n    let value = outer(inner(1 + ), tail);\n}\n";
        let (_symbols, has_error, diagnostic, _references, _aliases) =
            parse_source(source, &LanguageId::Rust, false).expect("rust parse should complete");

        assert!(has_error, "fixture must contain a tree-sitter parse error");
        let diag = diagnostic.expect("partial parse must attach a diagnostic");

        assert_eq!(diag.parser, "tree-sitter");
        assert_eq!(diag.line, Some(2), "unexpected diagnostic: {diag:?}");
        assert_eq!(diag.column, Some(32), "unexpected diagnostic: {diag:?}");
        assert_eq!(
            diag.byte_span,
            Some((46, 46)),
            "diagnostic must pin the zero-width missing node; got {diag:?}"
        );
        assert!(
            diag.message.contains("syntax missing identifier"),
            "diagnostic should name the missing node kind; got {diag:?}"
        );
        assert!(
            diag.message.contains("), tail"),
            "diagnostic should include source context after the missing node; got {diag:?}"
        );
    }

    #[test]
    fn test_process_file_computes_content_hash() {
        let source = b"fn foo() {}";
        let result = process_file("hash_test.rs", source, LanguageId::Rust);
        assert!(!result.content_hash.is_empty());
        assert_eq!(result.content_hash, digest_hex(source));
    }

    #[test]
    fn test_process_file_byte_len() {
        let source = b"fn bar() {}";
        let result = process_file("len.rs", source, LanguageId::Rust);
        assert_eq!(result.byte_len, source.len() as u64);
    }

    #[test]
    fn test_process_file_preserves_relative_path() {
        let result = process_file("src/lib.rs", b"fn x() {}", LanguageId::Rust);
        assert_eq!(result.relative_path, "src/lib.rs");
    }

    #[test]
    fn test_process_file_never_panics_on_adversarial_input() {
        // Verifies the catch_unwind safety net: process_file must ALWAYS
        // return a FileProcessingResult regardless of input, never propagate a panic.
        let cases: &[(&[u8], &str, LanguageId)] = &[
            (b"\xff\xfe\x00\x01", "binary.rs", LanguageId::Rust),
            (b"", "empty.py", LanguageId::Python),
            (&[0u8; 10000], "zeros.js", LanguageId::JavaScript),
            (b"\n\n\n\n\n", "newlines.ts", LanguageId::TypeScript),
            ("\u{200b}\u{200b}".as_bytes(), "zwsp.go", LanguageId::Go),
            (
                b"\0\0\0fn main() {}\0\0",
                "null_padded.rs",
                LanguageId::Rust,
            ),
        ];

        for &(source, path, ref lang) in cases {
            let result = process_file(path, source, lang.clone());
            assert_eq!(result.relative_path, path);
            assert_eq!(result.byte_len, source.len() as u64);
            assert!(!result.content_hash.is_empty());
        }
    }

    #[test]
    fn test_process_file_ruby_extracts_method() {
        let source = b"def hello\n  puts 'hi'\nend";
        let result = process_file("app.rb", source, LanguageId::Ruby);
        assert_eq!(result.outcome, FileOutcome::Processed);
        assert!(
            !result.symbols.is_empty(),
            "should have symbols for Ruby source"
        );
    }

    #[test]
    fn test_process_file_is_idempotent_across_re_parses() {
        // Incremental re-indexing in src/watcher/ assumes `process_file` is
        // idempotent: identical bytes must yield an identical
        // `FileProcessingResult`. A subtle non-determinism (e.g. HashMap
        // iteration order leaking into the symbol/reference Vec ordering)
        // would cause phantom xref churn on every touch of an unchanged file.
        //
        // Exercises multiple languages plus a partial-parse case so both the
        // normal extractor path and the `collect_first_error_node` diagnostic
        // path are pinned.
        let rust_src: &[u8] = b"use std::collections::HashMap;\n\
            use std::fmt::Display;\n\
            \n\
            pub struct Cache<K, V> {\n\
                store: HashMap<K, V>,\n\
            }\n\
            \n\
            impl<K: std::hash::Hash + Eq, V> Cache<K, V> {\n\
                pub fn new() -> Self { Self { store: HashMap::new() } }\n\
                pub fn insert(&mut self, k: K, v: V) -> Option<V> { self.store.insert(k, v) }\n\
            }\n\
            \n\
            pub fn make() -> Cache<String, u32> { Cache::new() }\n";

        let python_src: &[u8] = b"import os\n\
            import sys\n\
            \n\
            class Greeter:\n\
                def __init__(self, name):\n\
                    self.name = name\n\
            \n\
                def greet(self):\n\
                    return f\"hello {self.name}\"\n\
            \n\
            def main():\n\
                Greeter(\"world\").greet()\n";

        let ts_src: &[u8] = b"interface Greeter { greet(): string; }\n\
            export class Hello implements Greeter {\n\
                constructor(private name: string) {}\n\
                greet() { return `hi ${this.name}`; }\n\
            }\n";

        // Syntactically broken Rust — exercises the partial-parse path so the
        // diagnostic (line/column/byte_span) must also be deterministic.
        let broken_src: &[u8] = b"fn broken( { }";

        let cases: &[(&[u8], &str, LanguageId)] = &[
            (rust_src, "cache.rs", LanguageId::Rust),
            (python_src, "greet.py", LanguageId::Python),
            (ts_src, "hello.ts", LanguageId::TypeScript),
            (broken_src, "broken.rs", LanguageId::Rust),
        ];

        for &(source, path, ref lang) in cases {
            let first = process_file(path, source, lang.clone());
            let second = process_file(path, source, lang.clone());
            let third = process_file(path, source, lang.clone());
            assert_eq!(
                first, second,
                "{path}: identical bytes must yield identical FileProcessingResult (run 1 vs 2)"
            );
            assert_eq!(
                second, third,
                "{path}: identical bytes must yield identical FileProcessingResult (run 2 vs 3)"
            );
        }
    }

    // --- Parser resilience audit (parse_source / collect_first_error_node) ---
    //
    // The adversarial `process_file` test above proves the `catch_unwind` safety
    // net holds end-to-end. The tests below probe `parse_source` directly (no
    // catch_unwind wrapper) so a panic in the parse path fails the test instead
    // of being silently downgraded to a `Failed` outcome.

    #[test]
    fn test_parse_source_zero_bytes() {
        let result = parse_source("", &LanguageId::Rust, false)
            .expect("parse_source must handle empty input without error");
        let (symbols, _has_error, _diagnostic, references, alias_map) = result;
        assert!(symbols.is_empty(), "empty source has no symbols");
        assert!(references.is_empty(), "empty source has no references");
        assert!(alias_map.is_empty(), "empty source has no aliases");
    }

    #[test]
    fn test_parse_source_null_bytes_only() {
        // 4 KiB of NUL: valid UTF-8, no valid tokens in any grammar.
        // Exercises parse_source + collect_first_error_node (if has_error) on a
        // file that tree-sitter must reject-as-syntax-error rather than crash.
        let source: String = "\0".repeat(4096);
        let result = parse_source(&source, &LanguageId::Rust, false)
            .expect("parse_source must handle null-byte input without error");
        let (_symbols, _has_error, _diagnostic, _references, _aliases) = result;
    }

    #[test]
    fn test_augment_message_with_nul_hint_appends_for_nul_source() {
        // Pure-logic coverage: the hint is grammar-independent, so test it
        // directly rather than depending on a specific tree-sitter error shape.
        let augmented = augment_message_with_nul_hint(
            "syntax error near ` `".to_string(),
            b"const a = `x\0y`;\n",
        );
        assert!(
            augmented.contains("file contains 1 NUL byte at offset(s) 12"),
            "hint must name count and offset; got: {augmented}"
        );
        assert!(
            augmented.contains("tree-sitter cannot parse NUL in source"),
            "hint must explain the real cause; got: {augmented}"
        );
        // Original message is preserved.
        assert!(augmented.starts_with("syntax error near ` `"));
    }

    #[test]
    fn test_augment_message_with_nul_hint_noop_for_clean_source() {
        let original = "syntax error near `oops`".to_string();
        let augmented = augment_message_with_nul_hint(original.clone(), b"fn main() {}\n");
        assert_eq!(augmented, original, "clean source must not gain a NUL hint");
    }

    #[test]
    fn test_parse_source_diagnostic_carries_nul_hint_when_present() {
        // A NUL smuggled into a JS template literal is valid for Node but makes
        // tree-sitter-javascript produce a partial parse. When that happens the
        // diagnostic must explain the NUL rather than the bare `near ` `` snippet.
        // Robust against grammar behavior: if no diagnostic is produced we skip
        // (the augment helper is covered directly above).
        let source = "function mint() {\n  const token = `prefix\0suffix`;\n  return token;\n}\n";
        let (_symbols, has_error, diagnostic, _refs, _aliases) =
            parse_source(source, &LanguageId::JavaScript, false)
                .expect("parse_source must not crash on a NUL-bearing template literal");
        if has_error && let Some(diag) = diagnostic {
            assert!(
                diag.message
                    .contains("tree-sitter cannot parse NUL in source"),
                "diagnostic must carry the NUL hint; got: {}",
                diag.message
            );
        }
    }

    #[test]
    fn test_parse_source_wide_multibyte_error_region_no_panic() {
        // Regression probe for collect_first_error_node:
        //   let snippet_end = node.end_byte().min(snippet_start + 40);
        //   let snippet = &source[snippet_start..snippet_end];
        //
        // If a multi-byte UTF-8 char straddles byte `snippet_start + 40`, naive
        // slicing panics ("byte index is not a char boundary"). 100 × '€'
        // (3 bytes each = 300 bytes, all non-ASCII) is not a valid Rust token
        // stream, so tree-sitter must produce one or more ERROR nodes that
        // could span > 40 bytes of multi-byte content.
        let source: String = "€".repeat(100);
        parse_source(&source, &LanguageId::Rust, false)
            .expect("parse_source must not panic on wide multi-byte error region");
    }

    #[test]
    fn test_parse_source_mixed_multibyte_error_boundary_no_panic() {
        // Similar char-boundary probe but with a grammar-level syntax error
        // interleaved with multi-byte text — guarantees an ERROR node whose
        // [start, start+40) window crosses a UTF-8 char boundary.
        let mut source = String::new();
        source.push_str("struct S { ");
        for _ in 0..14 {
            source.push('€');
        }
        source.push(' ');
        parse_source(&source, &LanguageId::Rust, false)
            .expect("parse_source must not panic when error snippet spans multi-byte chars");
    }

    #[test]
    fn test_process_file_deeply_nested_expression_no_stack_blow() {
        // Stack-blow probe: 10 000 nested parens. Per-language extractors walk
        // the AST recursively (see `walk_node` → `walk_children` → `walk_node`
        // in src/parsing/languages/rust.rs L19-50). Default Rust test-thread
        // stacks (~2 MiB) overflow around ~6 k frames, so the probe runs on a
        // dedicated thread with a 16 MiB stack to verify the parse logic itself
        // terminates. A real stack-blow on this depth would abort the test
        // process; we want to observe it here rather than in production.
        const DEPTH: usize = 10_000;
        let handle = std::thread::Builder::new()
            .stack_size(16 * 1024 * 1024)
            .spawn(|| {
                let mut source = String::with_capacity(DEPTH * 2 + 32);
                source.push_str("fn f() -> i32 { ");
                for _ in 0..DEPTH {
                    source.push('(');
                }
                source.push('1');
                for _ in 0..DEPTH {
                    source.push(')');
                }
                source.push_str(" }");
                let result = process_file("deep.rs", source.as_bytes(), LanguageId::Rust);
                assert_eq!(result.byte_len, source.len() as u64);
                assert!(!result.content_hash.is_empty());
            })
            .expect("spawn stack-blow probe thread");
        handle.join().expect("deep-nesting probe must not panic");
    }

    #[test]
    fn test_process_file_deeply_nested_expression_on_default_stack_no_panic() {
        // Companion to `test_process_file_deeply_nested_expression_no_stack_blow`
        // above — same input, but runs on the default test-thread stack (~2 MiB
        // on Linux/macOS, ~1 MiB on Windows) without the 16 MiB override. The
        // previous swarm round flagged the real risk: daemon-proxy sessions and
        // other caller threads with default-sized stacks would overflow on
        // adversarial input. The `MAX_AST_WALK_DEPTH` cap in
        // `src/parsing/languages/mod.rs` silently truncates the AST walk when
        // recursion reaches the cap, so this now returns cleanly.
        //
        // This test doesn't assert anything about the resulting symbol count —
        // depth-capped partial walks are allowed to drop inner symbols, matching
        // the rest of the parser's partial-parse philosophy. It only asserts
        // that the call returns *at all* without crashing the test process.
        const DEPTH: usize = 10_000;
        let mut source = String::with_capacity(DEPTH * 2 + 32);
        source.push_str("fn f() -> i32 { ");
        for _ in 0..DEPTH {
            source.push('(');
        }
        source.push('1');
        for _ in 0..DEPTH {
            source.push(')');
        }
        source.push_str(" }");
        let result = process_file("deep_default.rs", source.as_bytes(), LanguageId::Rust);
        assert_eq!(result.byte_len, source.len() as u64);
        assert!(!result.content_hash.is_empty());
    }
}
