//! Code parsing with tree-sitter
//!
//! Split into submodules:
//! - `types` — data structures and error types
//! - `chunk` — chunk extraction from parse trees
//! - `calls` — call site extraction for call graph
//! - `injection` — multi-grammar injection (HTML→JS/CSS via `set_included_ranges()`)
//! - `markdown` — heading-based Markdown parser with cross-reference extraction
//! - `aspx` — ASP.NET Web Forms parser (delegates to C#/VB.NET grammars)

pub mod aspx;
mod calls;
pub(crate) mod chunk;
pub(crate) mod injection;
pub mod l5x;
pub mod markdown;
pub mod types;

pub use chunk::{canonical_hash_fallback, chunk_id, chunk_id_suffixed, collapse_whitespace};

/// The current parser-logic version stamped onto every chunk this build
/// extracts (`chunk::PARSER_VERSION`). Re-exported so the binary crate's
/// staleness pre-filters can ask the store which origins carry an older
/// stamp without reaching into the `pub(crate)` `chunk` module.
pub fn parser_version() -> u32 {
    chunk::PARSER_VERSION
}
pub use types::{
    CallEdgeKind, CallSite, CandidateSite, Chunk, ChunkType, ChunkTypeRefs, FieldStyle,
    FunctionCalls, Language, ParserError, SignatureStyle, TypeEdgeKind, TypeRef,
};

use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::path::Path;
use tree_sitter::StreamingIterator;

/// Parse `source` with a wall-clock timeout (`crate::limits::parser_timeout_ms`).
///
/// tree-sitter has no internal time bound: an adversarial token stream can drive
/// superlinear error recovery and pin a parser thread for tens of seconds with
/// no cancellation, stalling the in-process index/watch/daemon parse. This wraps
/// `parse_with_options` with a progress callback that aborts (returns `None`)
/// once the budget is exceeded. A `None` return is the SAME shape an
/// unparseable/oversized file already yields at every call site (mapped to
/// `ParseFailed` / skip-with-warn), so adding the timeout is behavior-neutral
/// for legitimate input — the budget is far larger than any real parse.
///
/// When the timeout is disabled (`CQS_PARSER_TIMEOUT_MS=0`) this is exactly
/// `parser.parse(source, None)`.
pub(crate) fn parse_with_timeout(
    parser: &mut tree_sitter::Parser,
    source: &str,
) -> Option<tree_sitter::Tree> {
    let Some(budget_ms) = crate::limits::parser_timeout_ms() else {
        return parser.parse(source, None);
    };
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(budget_ms);
    let mut timed_out = false;
    let bytes = source.as_bytes();
    let len = bytes.len();
    // The progress callback fires periodically during parsing; checking a
    // monotonic clock there is cheap relative to the parse work between calls.
    let mut on_progress = |_state: &tree_sitter::ParseState| {
        if std::time::Instant::now() >= deadline {
            timed_out = true;
            std::ops::ControlFlow::Break(())
        } else {
            std::ops::ControlFlow::Continue(())
        }
    };
    let options = tree_sitter::ParseOptions::new().progress_callback(&mut on_progress);
    let tree = parser.parse_with_options(
        &mut |i, _| if i < len { &bytes[i..] } else { &[] },
        None,
        Some(options),
    );
    if timed_out {
        tracing::warn!(
            budget_ms,
            source_bytes = len,
            "tree-sitter parse exceeded CQS_PARSER_TIMEOUT_MS budget; skipping (set CQS_PARSER_TIMEOUT_MS=0 to disable, or raise it)"
        );
        return None;
    }
    tree
}

/// Shared serialization lock for tests that mutate the process-global
/// `CQS_PARSER_TIMEOUT_MS`. Every such test (in any module) must hold this for
/// the duration of its env mutation so two timeout cohorts can't clobber each
/// other's value when libtest runs them on different threads.
#[cfg(test)]
pub(crate) static TIMEOUT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Default per-file size cap for parsing (50 MiB). Files larger than this
/// are skipped at parse time. Override at runtime via
/// `CQS_PARSER_MAX_FILE_SIZE`. Distinct from `CQS_MAX_FILE_SIZE`
/// (the file-discovery gate in `lib.rs::max_file_size`) so per-stage
/// knobs stay independent — bumping one doesn't silently bump the other.
///
/// Production paths read [`crate::limits::parser_max_file_size`]; this
/// constant exists for the `tc35_max_file_size_is_50mb` test pin
/// and for downstream crates that re-export it.
#[allow(dead_code)]
pub(crate) const MAX_FILE_SIZE: u64 = crate::limits::PARSER_MAX_FILE_SIZE;

/// Combined parse result: chunks, function calls, and type references.
/// Returned by `parse_file_all()` and `parse_injected_all()` which extract
/// everything in a single file read + tree-sitter parse.
pub type ParseAllResult = (Vec<Chunk>, Vec<FunctionCalls>, Vec<ChunkTypeRefs>);

/// Combined parse result PLUS per-chunk call sites AND file-level candidates.
/// Returned by [`Parser::parse_file_all_with_chunk_calls`] and used by the
/// indexing pipeline (`pipeline::parser_stage()`) to populate the per-chunk
/// `calls` table without re-parsing each chunk's body. The fourth element
/// pairs each extracted [`CallSite`] with the originating chunk's id (using
/// the path-based id format produced by `extract_chunk`; the pipeline rewrites
/// these to relative-path ids before persisting).
///
/// The fifth element is the file's low-confidence call-graph candidates (Lane
/// 2): references the confident passes DROP (cross-file bare fn-pointer args,
/// container / `with`-module serde callbacks). They are
/// file-keyed, not chunk-keyed (the `candidate_edges` side-table is
/// callee-name + file keyed), so they ride as a flat `Vec<CandidateSite>` the
/// pipeline persists wholesale per file — never joined into the caller graph
/// (Lane 1's invariant).
pub type ParseAllWithChunkCallsResult = (
    Vec<Chunk>,
    Vec<FunctionCalls>,
    Vec<ChunkTypeRefs>,
    Vec<(String, CallSite)>,
    Vec<CandidateSite>,
);

/// Default per-chunk byte cap (100 KiB). Larger chunks are dropped at
/// parse time before windowing sees them. Override at runtime via
/// `CQS_PARSER_MAX_CHUNK_BYTES`.
///
/// Production paths read [`crate::limits::parser_max_chunk_bytes`]; this
/// constant is kept for crate-external references and test pins.
#[allow(dead_code)]
pub(crate) const MAX_CHUNK_BYTES: usize = crate::limits::PARSER_MAX_CHUNK_BYTES;

/// Code parser using tree-sitter grammars
/// Extracts functions, methods, classes, and other code elements
/// from source files in supported languages.
/// # Example
/// ```no_run
/// use cqs::Parser;
/// let parser = Parser::new()?;
/// let chunks = parser.parse_file(std::path::Path::new("src/main.rs"))?;
/// for chunk in chunks {
///     println!("{}: {} ({})", chunk.file.display(), chunk.name, chunk.chunk_type);
/// }
/// # Ok::<(), anyhow::Error>(())
/// ```
pub struct Parser {
    /// Lazily compiled queries per language (compiled on first use)
    queries: HashMap<Language, OnceCell<tree_sitter::Query>>,
    /// Lazily compiled call extraction queries per language
    call_queries: HashMap<Language, OnceCell<tree_sitter::Query>>,
    /// Lazily compiled type extraction queries per language
    type_queries: HashMap<Language, OnceCell<tree_sitter::Query>>,
}

// Note: Default impl intentionally omitted to prevent hidden panics.
// Use Parser::new() which returns Result for proper error handling.

impl Parser {
    /// Create a new parser (queries are compiled lazily on first use)
    pub fn new() -> Result<Self, ParserError> {
        let mut queries = HashMap::new();
        let mut call_queries = HashMap::new();
        let mut type_queries = HashMap::new();

        // Initialize empty OnceCells for each registered language
        // (skip grammar-less languages like Markdown — they use custom parsers)
        for def in crate::language::REGISTRY.all() {
            let lang: Language = def.name.parse().unwrap_or_else(|_| {
                panic!(
                    "Language registry/enum mismatch: '{}' is registered but has no Language variant",
                    def.name
                )
            });
            // Grammar-less languages must not define injections (they'd silently produce nothing)
            debug_assert!(
                def.grammar.is_some() || def.injections.is_empty(),
                "Language '{}' has no grammar but defines injections — injections require tree-sitter",
                def.name
            );
            if def.grammar.is_some() {
                queries.insert(lang, OnceCell::new());
                call_queries.insert(lang, OnceCell::new());
                if def.type_query.is_some() {
                    type_queries.insert(lang, OnceCell::new());
                }
            }
        }

        Ok(Self {
            queries,
            call_queries,
            type_queries,
        })
    }

    /// Get or compile the chunk extraction query for a language
    fn get_query(&self, language: Language) -> Result<&tree_sitter::Query, ParserError> {
        let cell = self.queries.get(&language).ok_or_else(|| {
            ParserError::QueryCompileFailed(language.to_string(), "not found".into())
        })?;

        cell.get_or_try_init(|| {
            let grammar = language.try_grammar().ok_or_else(|| {
                ParserError::QueryCompileFailed(
                    language.to_string(),
                    "no tree-sitter grammar".into(),
                )
            })?;
            let pattern = language.query_pattern();
            tree_sitter::Query::new(&grammar, pattern).map_err(|e| {
                ParserError::QueryCompileFailed(language.to_string(), format!("{}", e))
            })
        })
    }

    /// Get or compile the call extraction query for a language
    pub(crate) fn get_call_query(
        &self,
        language: Language,
    ) -> Result<&tree_sitter::Query, ParserError> {
        let cell = self.call_queries.get(&language).ok_or_else(|| {
            ParserError::QueryCompileFailed(format!("{}_calls", language), "not found".into())
        })?;

        cell.get_or_try_init(|| {
            let grammar = language.try_grammar().ok_or_else(|| {
                ParserError::QueryCompileFailed(
                    format!("{}_calls", language),
                    "no tree-sitter grammar".into(),
                )
            })?;
            let pattern = language.call_query_pattern();
            tree_sitter::Query::new(&grammar, pattern).map_err(|e| {
                ParserError::QueryCompileFailed(format!("{}_calls", language), format!("{}", e))
            })
        })
    }

    /// Get or compile the type extraction query for a language
    pub(crate) fn get_type_query(
        &self,
        language: Language,
    ) -> Result<&tree_sitter::Query, ParserError> {
        let cell = self.type_queries.get(&language).ok_or_else(|| {
            ParserError::QueryCompileFailed(format!("{}_types", language), "not found".into())
        })?;

        cell.get_or_try_init(|| {
            let grammar = language.try_grammar().ok_or_else(|| {
                ParserError::QueryCompileFailed(
                    format!("{}_types", language),
                    "no tree-sitter grammar".into(),
                )
            })?;
            let pattern = language.type_query_pattern();
            tree_sitter::Query::new(&grammar, pattern).map_err(|e| {
                ParserError::QueryCompileFailed(format!("{}_types", language), format!("{}", e))
            })
        })
    }

    /// Parse a source file and extract code chunks
    /// Returns an empty Vec for non-UTF8 files (with a warning logged).
    /// Returns an error for unsupported file types.
    pub fn parse_file(&self, path: &Path) -> Result<Vec<Chunk>, ParserError> {
        let _span = tracing::info_span!("parse_file", path = %path.display()).entered();

        // Cap is env-overridable (CQS_PARSER_MAX_FILE_SIZE).
        let max_file_size = crate::limits::parser_max_file_size();
        match std::fs::metadata(path) {
            Ok(meta) if meta.len() > max_file_size => {
                tracing::warn!(
                    size_mb = meta.len() / (1024 * 1024),
                    cap_mb = max_file_size / (1024 * 1024),
                    path = %path.display(),
                    "Skipping large file; bump CQS_PARSER_MAX_FILE_SIZE if needed"
                );
                return Ok(vec![]);
            }
            Ok(_) => {}
            Err(e) => return Err(e.into()),
        }

        // Gracefully handle non-UTF8 files
        let source = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
                tracing::warn!(path = %path.display(), "Skipping non-UTF8 file");
                return Ok(vec![]);
            }
            Err(e) => return Err(e.into()),
        };

        // Normalize line endings (CRLF -> LF) for consistent hashing across
        // platforms. The `contains` probe skips the allocating `replace` on
        // Unix-native repos that never had CRLF, saving one string allocation
        // per file in the common case.
        let source = if source.contains("\r\n") {
            source.replace("\r\n", "\n")
        } else {
            source
        };

        let ext_raw = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let ext = ext_raw.to_ascii_lowercase();

        // Rockwell PLC exports (.l5x/.l5k) route through their grammar-less
        // LanguageDef's custom_chunk_parser via parse_source — a single
        // declarative routing path shared with parse_file_all_inner.
        let language = Language::from_extension(&ext)
            .ok_or_else(|| ParserError::UnsupportedFileType(ext.to_string()))?;

        self.parse_source(&source, language, path)
    }

    /// Parse in-memory source code and extract code chunks.
    /// Like `parse_file`, but operates on already-read source content with a
    /// known language. The `path` is used only for chunk origin metadata
    /// (`Chunk.file` field), not for filesystem access.
    /// Used by `train_data` to parse `git show` output without writing temp files.
    pub fn parse_source(
        &self,
        source: &str,
        language: Language,
        path: &Path,
    ) -> Result<Vec<Chunk>, ParserError> {
        let _span = tracing::info_span!("parse_source", path = %path.display()).entered();

        // Grammar-less languages use custom parsers: route via LanguageDef
        // function pointers, not match arms. A new grammar-less language
        // without `custom_chunk_parser` falls through to the markdown
        // default. Routing is declarative and centralized in the language row.
        if language.def().grammar.is_none() {
            return match language.def().custom_chunk_parser {
                Some(f) => f(source, path, self),
                None => {
                    // Markdown (and any future grammar-less language
                    // that opts into the default line-based parser)
                    let mut chunks = crate::parser::markdown::parse_markdown_chunks(source, path)?;
                    let fenced = crate::parser::markdown::extract_fenced_blocks(source);
                    chunks.extend(self.parse_fenced_blocks(&fenced, source, path));
                    Ok(chunks)
                }
            };
        }

        let grammar = language.try_grammar().ok_or_else(|| {
            ParserError::ParseFailed(format!("{} has no tree-sitter grammar", language))
        })?;
        let mut parser = tree_sitter::Parser::new();
        parser
            .set_language(&grammar)
            .map_err(|e| ParserError::ParseFailed(format!("{}", e)))?;

        let tree = parse_with_timeout(&mut parser, source)
            .ok_or_else(|| ParserError::ParseFailed(path.display().to_string()))?;

        // Get or compile query (lazy initialization)
        let query = self.get_query(language)?;

        let mut cursor = tree_sitter::QueryCursor::new();
        let mut matches = cursor.matches(query, tree.root_node(), source.as_bytes());

        let mut chunks = Vec::new();
        // Env-overridable per-chunk byte cap. Track drops so we can emit a
        // single summary warn at end-of-file rather than one log line per
        // skipped chunk.
        let max_chunk_bytes = crate::limits::parser_max_chunk_bytes();
        let mut dropped_oversized = 0usize;

        while let Some(m) = matches.next() {
            match self.extract_chunk(source, m, query, language, path) {
                Ok(mut chunk) => {
                    // Chunks above the cap are dropped. The pipeline's
                    // windowing stage only sees chunks that pass here —
                    // windowing is downstream of this gate, not a mitigation
                    // for it.
                    if chunk.content.len() > max_chunk_bytes {
                        tracing::debug!(
                            "Skipping {} ({} bytes > {} max)",
                            chunk.id,
                            chunk.content.len(),
                            max_chunk_bytes
                        );
                        dropped_oversized += 1;
                        continue;
                    }
                    // Apply post-process hook (e.g., HCL block reclassification)
                    if let Some(post_process) = language.def().post_process_chunk {
                        if let Some(node) = extract_definition_node(m, query) {
                            if !post_process(&mut chunk.name, &mut chunk.chunk_type, node, source) {
                                tracing::debug!(
                                    name = %chunk.name,
                                    file = %path.display(),
                                    "post_process_chunk: discarded"
                                );
                                continue;
                            }
                        }
                    }
                    chunks.push(chunk);
                }
                Err(e) => {
                    tracing::warn!(path = %path.display(), error = %e, "Failed to extract chunk");
                }
            }
        }

        // --- Phase 2: Injection parsing (multi-grammar) ---
        let injections = language.def().injections;
        if !injections.is_empty() {
            // Release borrows on the outer tree before injection phase
            drop(matches);
            drop(cursor);

            let groups = injection::find_injection_ranges(&tree, source, injections);

            // Free outer tree/parser memory before inner parse allocations
            drop(tree);
            drop(parser);

            for group in &groups {
                match self.parse_injected_chunks(source, path, group, 0) {
                    Ok(inner_chunks) if !inner_chunks.is_empty() => {
                        let before = chunks.len();
                        // Remove outer chunks that overlap with injection containers
                        chunks.retain(|c| {
                            !injection::chunk_within_container(
                                c.line_start,
                                c.line_end,
                                &group.container_lines,
                            )
                        });
                        let removed = before - chunks.len();
                        tracing::debug!(
                            language = %group.language,
                            removed,
                            added = inner_chunks.len(),
                            "Replaced outer chunks with injection results"
                        );
                        chunks.extend(inner_chunks);
                    }
                    Ok(_) => {
                        // Zero inner chunks — keep outer chunks as-is (fallback)
                    }
                    Err(e) => {
                        tracing::warn!(
                            error = %e,
                            language = %group.language,
                            "Injection parsing failed, keeping outer chunks"
                        );
                    }
                }
            }
        }

        // Per-file summary of oversized-chunk drops, surfaced at warn level
        // so users discover CQS_PARSER_MAX_CHUNK_BYTES instead of wondering
        // why their indexed corpus has gaps.
        if dropped_oversized > 0 {
            tracing::warn!(
                path = %path.display(),
                dropped = dropped_oversized,
                cap_bytes = max_chunk_bytes,
                "Dropped oversized chunks; bump CQS_PARSER_MAX_CHUNK_BYTES if needed"
            );
        }

        Ok(chunks)
    }

    /// Parse a source file and extract chunks, calls, AND type references in one pass.
    /// Combines `parse_file()` and `parse_file_relationships()` to avoid double
    /// file read + double tree-sitter parse. Single file read, single outer parse,
    /// two query cursor passes on the same tree, single injection parse.
    /// Returns `(chunks, function_calls, chunk_type_refs)`.
    /// Used by `watch::reindex_files()` for incremental updates. The indexing
    /// pipeline calls [`Self::parse_file_all_with_chunk_calls`] instead so it
    /// populates the per-chunk `calls` table from the same Pass 2 walk.
    pub fn parse_file_all(&self, path: &Path) -> Result<ParseAllResult, ParserError> {
        let (chunks, calls, types, _chunk_calls, _candidates) =
            self.parse_file_all_inner(path, false)?;
        Ok((chunks, calls, types))
    }

    /// Like [`Self::parse_file_all`], but also returns one
    /// `(chunk_id, CallSite)` pair for every call extracted during Pass 2,
    /// so the indexing pipeline avoids a separate `extract_calls_from_chunk`
    /// pass (one extra tree-sitter parse per chunk; ~14k extra parses per
    /// cqs reindex).
    ///
    /// The chunk ids returned here use the absolute-path format produced by
    /// `extract_chunk` (`{path}:{line_start}:{hash_prefix}`). The indexing
    /// pipeline rewrites these to relative-path ids via the same id_map it
    /// builds for the chunks themselves, so the chunk_calls list and
    /// the chunks list stay in sync.
    ///
    /// The body delegates to `parse_file_all_inner` with
    /// `want_chunk_calls = true`, which also powers `parse_file_all`. Watch
    /// (`src/cli/watch.rs`) uses `parse_file_all` and runs its own
    /// `extract_calls_from_chunk` per chunk — its parser stage is
    /// rayon-parallel, so it doesn't share this Pass-2 emit path.
    ///
    /// The 5th element is the file's Lane-2 `candidate_edges` (see
    /// [`ParseAllWithChunkCallsResult`]); it is collected regardless of
    /// `want_chunk_calls` because candidates are file-keyed, not chunk-keyed.
    pub fn parse_file_all_with_chunk_calls(
        &self,
        path: &Path,
    ) -> Result<ParseAllWithChunkCallsResult, ParserError> {
        self.parse_file_all_inner(path, true)
    }

    /// Shared implementation for [`Self::parse_file_all`] and
    /// [`Self::parse_file_all_with_chunk_calls`]. The `want_chunk_calls` flag
    /// guards the per-chunk-id bookkeeping (a `(start_byte, end_byte)` →
    /// `chunk.id` map plus the per-call emit) so callers that only need the
    /// existing 3-element shape pay nothing extra. When `want_chunk_calls` is
    /// `false` the returned 4th element is always empty. The 5th element (Lane-2
    /// candidates) is collected on BOTH flag settings — it is file-level, so
    /// `parse_file_all` (Watch's path) drops it via destructure while the bulk
    /// pipeline persists it.
    fn parse_file_all_inner(
        &self,
        path: &Path,
        want_chunk_calls: bool,
    ) -> Result<ParseAllWithChunkCallsResult, ParserError> {
        let _span = tracing::info_span!("parse_file_all", path = %path.display()).entered();

        // Env-overridable cap (CQS_PARSER_MAX_FILE_SIZE).
        let max_file_size = crate::limits::parser_max_file_size();
        match std::fs::metadata(path) {
            Ok(meta) if meta.len() > max_file_size => {
                tracing::warn!(
                    size_mb = meta.len() / (1024 * 1024),
                    cap_mb = max_file_size / (1024 * 1024),
                    path = %path.display(),
                    "Skipping large file; bump CQS_PARSER_MAX_FILE_SIZE if needed"
                );
                return Ok((vec![], vec![], vec![], vec![], vec![]));
            }
            Ok(_) => {}
            Err(e) => return Err(e.into()),
        }

        // Read file once
        let source = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
                tracing::warn!(path = %path.display(), "Skipping non-UTF8 file");
                return Ok((vec![], vec![], vec![], vec![], vec![]));
            }
            Err(e) => return Err(e.into()),
        };

        // Normalize line endings (CRLF -> LF) for consistent hashing across
        // platforms. The `contains` probe skips the allocating `replace` on
        // Unix-native repos that never had CRLF, saving one string allocation
        // per file in the common case.
        let source = if source.contains("\r\n") {
            source.replace("\r\n", "\n")
        } else {
            source
        };

        let ext_raw = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let ext = ext_raw.to_ascii_lowercase();

        let language = Language::from_extension(&ext)
            .ok_or_else(|| ParserError::UnsupportedFileType(ext.to_string()))?;

        // Grammar-less languages use custom parsers: routing is declarative
        // via `custom_all_parser`, not a match arm. For these the chunk_calls
        // path uses per-chunk `extract_calls_from_chunk` because Markdown's
        // per-chunk reference scan is line-based (no tree-sitter cost) and
        // custom parsers like ASPX delegate to inner-language tree-sitter
        // parsers anyway.
        if language.def().grammar.is_none() {
            let (chunks, calls, types) = match language.def().custom_all_parser {
                Some(f) => f(&source, path, self)?,
                None => {
                    // Markdown (and any future grammar-less language
                    // that opts into the default line-based parser)
                    let mut chunks = crate::parser::markdown::parse_markdown_chunks(&source, path)?;
                    let calls = crate::parser::markdown::parse_markdown_references(&source, path)?;
                    let fenced = crate::parser::markdown::extract_fenced_blocks(&source);
                    chunks.extend(self.parse_fenced_blocks(&fenced, &source, path));
                    (chunks, calls, vec![])
                }
            };
            // Candidates are collected on BOTH flag settings (file-level), but
            // the per-chunk emit only fires when we walk chunks for chunk_calls;
            // a grammar-less file's only candidate source is a fenced Rust block,
            // and the per-chunk extractor is the path that sees those.
            let mut candidates: Vec<CandidateSite> = Vec::new();
            let chunk_calls = if want_chunk_calls {
                let mut sink: Vec<(String, CallSite)> = Vec::new();
                for chunk in &chunks {
                    let (chunk_calls, chunk_candidates) =
                        self.extract_calls_and_candidates_from_chunk(chunk);
                    for call in chunk_calls {
                        sink.push((chunk.id.clone(), call));
                    }
                    candidates.extend(chunk_candidates);
                }
                sink
            } else {
                for chunk in &chunks {
                    candidates.extend(self.extract_calls_and_candidates_from_chunk(chunk).1);
                }
                Vec::new()
            };
            return Ok((chunks, calls, types, chunk_calls, candidates));
        }

        // Single tree-sitter parse
        let grammar = language.try_grammar().ok_or_else(|| {
            ParserError::ParseFailed(format!("{} has no tree-sitter grammar", language))
        })?;
        let mut parser = tree_sitter::Parser::new();
        parser
            .set_language(&grammar)
            .map_err(|e| ParserError::ParseFailed(format!("{}", e)))?;

        let tree = parse_with_timeout(&mut parser, &source)
            .ok_or_else(|| ParserError::ParseFailed(path.display().to_string()))?;

        // Get queries (chunk query needed for both passes, call/type for pass 2)
        let chunk_query = self.get_query(language)?;
        let call_query = self.get_call_query(language)?;

        // --- Pass 1: Chunk extraction ---
        let mut cursor = tree_sitter::QueryCursor::new();
        let mut matches = cursor.matches(chunk_query, tree.root_node(), source.as_bytes());
        let mut chunks = Vec::new();
        // Map (start_byte, end_byte) -> chunk.id for non-discarded chunks.
        // Built only when chunk_calls are requested so the no-chunk-calls
        // path (Watch) pays nothing. Pass 2 looks up the chunk id by the
        // func_node's byte_range and emits (chunk_id, CallSite) pairs
        // without re-parsing the chunk body — sparing the indexing pipeline
        // a per-chunk tree-sitter re-parse.
        let mut byte_range_to_chunk_id: HashMap<(usize, usize), String> = HashMap::new();
        // Env-overridable per-chunk byte cap; track drops for a single warn
        // at end-of-file.
        let max_chunk_bytes = crate::limits::parser_max_chunk_bytes();
        let mut dropped_oversized = 0usize;

        while let Some(m) = matches.next() {
            // Capture the def node up-front so we can record its byte_range
            // alongside the chunk id once we know the chunk survived the
            // size + post_process filters. `extract_definition_node` is the
            // same helper Pass 2 (and post_process below) use, so the
            // byte_range we record is exactly what Pass 2's `func_node`
            // produces — guaranteeing a hit on lookup.
            let def_node = if want_chunk_calls {
                extract_definition_node(m, chunk_query)
            } else {
                None
            };
            match self.extract_chunk(&source, m, chunk_query, language, path) {
                Ok(mut chunk) => {
                    if chunk.content.len() > max_chunk_bytes {
                        tracing::debug!(
                            "Skipping {} ({} bytes > {} max)",
                            chunk.id,
                            chunk.content.len(),
                            max_chunk_bytes
                        );
                        dropped_oversized += 1;
                        continue;
                    }
                    if let Some(post_process) = language.def().post_process_chunk {
                        if let Some(node) = extract_definition_node(m, chunk_query) {
                            if !post_process(&mut chunk.name, &mut chunk.chunk_type, node, &source)
                            {
                                tracing::debug!(
                                    name = %chunk.name,
                                    file = %path.display(),
                                    "post_process_chunk: discarded"
                                );
                                continue;
                            }
                        }
                    }
                    if want_chunk_calls {
                        if let Some(node) = def_node {
                            let r = node.byte_range();
                            byte_range_to_chunk_id.insert((r.start, r.end), chunk.id.clone());
                        }
                    }
                    chunks.push(chunk);
                }
                Err(e) => {
                    tracing::warn!(path = %path.display(), error = %e, "Failed to extract chunk");
                }
            }
        }

        // --- Pass 2: Relationship extraction (calls + types) ---
        let mut cursor2 = tree_sitter::QueryCursor::new();
        let mut matches2 = cursor2.matches(chunk_query, tree.root_node(), source.as_bytes());

        let mut call_results = Vec::new();
        let mut type_results = Vec::new();
        // Lane-2 low-confidence call-graph candidates (file-level). See
        // `ParseAllWithChunkCallsResult`.
        let mut candidates: Vec<CandidateSite> = Vec::new();
        // Emit (chunk_id, CallSite) pairs from the same Pass 2 walk that
        // builds `call_results`, so the indexing pipeline doesn't re-parse
        // every chunk body afterwards just to populate this list.
        let mut chunk_calls: Vec<(String, CallSite)> = Vec::new();
        let mut call_cursor = tree_sitter::QueryCursor::new();
        let mut calls = Vec::new();
        let mut seen = std::collections::HashSet::new();
        let capture_names = chunk_query.capture_names();
        let name_idx = chunk_query.capture_index_for_name("name");

        // File-wide Rust function-name set for the fn-pointer-argument precision
        // filter (bare-identifier args emit an edge only when they name a
        // function defined in this file). Computed once over the whole tree so a
        // fn referenced in one chunk but defined in another still resolves. Empty
        // for non-Rust languages (the pass no-ops there).
        let known_fns = if language == Language::Rust {
            calls::collect_rust_fn_names(tree.root_node(), &source)
        } else {
            std::collections::HashSet::new()
        };

        // Whole-file serde candidate pre-pass (Lane 2): CONTAINER-level
        // `#[serde(...)]` attributes are prev-siblings OUTSIDE every item node's
        // byte range, so the per-chunk confident scan below cannot reach them —
        // they need a file-root walk. `with = "module"` inner (de)ser fns also
        // land here. ABSOLUTE line numbers (no chunk offset).
        calls::collect_serde_candidates(tree.root_node(), &source, language, path, &mut candidates);

        while let Some(m) = matches2.next() {
            let func_node = m.captures.iter().find(|c| {
                let name = capture_names.get(c.index as usize).copied().unwrap_or("");
                types::capture_name_to_chunk_type(name).is_some()
            });

            let Some(func_capture) = func_node else {
                continue;
            };

            let node = func_capture.node;

            let mut name = name_idx
                .and_then(|idx| m.captures.iter().find(|c| c.index == idx))
                .map(|c| source[c.node.byte_range()].to_string())
                .unwrap_or_else(|| "<anonymous>".to_string());

            if let Some(post_process) = language.def().post_process_chunk {
                let cap_name = capture_names
                    .get(func_capture.index as usize)
                    .copied()
                    .unwrap_or("");
                let mut ct =
                    types::capture_name_to_chunk_type(cap_name).unwrap_or(ChunkType::Function);
                if !post_process(&mut name, &mut ct, node, &source) {
                    continue;
                }
            }

            let line_start = node.start_position().row as u32 + 1;
            let byte_range = node.byte_range();

            // Call extraction
            call_cursor.set_byte_range(byte_range.clone());
            calls.clear();

            let mut call_matches =
                call_cursor.matches(call_query, tree.root_node(), source.as_bytes());

            while let Some(cm) = call_matches.next() {
                for cap in cm.captures {
                    let callee_name = source[cap.node.byte_range()].to_string();
                    let call_line = cap.node.start_position().row as u32 + 1;

                    if !calls::should_skip_callee(&callee_name) {
                        calls.push(CallSite {
                            callee_name,
                            line_number: call_line,
                            kind: CallEdgeKind::Call,
                        });
                    }
                }
            }

            // serde string-callback edges: struct/enum chunks carrying
            // `#[serde(default = "fn")]` etc. invoke those functions via the
            // derive. The tree-sitter call query can't see them (string
            // literals in attributes), so emit them explicitly here.
            //
            // Scans the chunk's own byte range, which covers FIELD-level serde
            // attributes (inside the struct/enum body — the issue's common
            // case). CONTAINER-level attributes that precede `struct`/`enum`
            // sit OUTSIDE the item node in tree-sitter-rust and are not in
            // range here; they're left to the dead-code `filter_serde_callbacks`
            // backstop. Using the node byte range (not an attribute-extended
            // slice) keeps this in exact parity with `extract_calls_from_chunk`,
            // which only has the chunk's content (also attribute-free).
            calls.extend(calls::extract_serde_callback_calls(
                &source[byte_range.clone()],
                language,
                line_start,
            ));

            // Macro token-tree call edges: calls inside `println!`/`vec!`/etc.
            // are opaque tokens the call query can't see. Walk the chunk node.
            // This loop produces ABSOLUTE line numbers (matching the call query
            // above, which does not subtract line_start), so pass line_offset =
            // 0. The relative-line conversion for `chunk_calls` below applies to
            // these edges identically. `known_fns` gates the bare-macro-arg case
            // (`m!(callback)`).
            calls.extend(calls::extract_macro_call_edges(
                node, &source, language, 0, &known_fns,
            ));

            // Fn-pointer / callback argument edges: a function passed as a VALUE
            // in argument position (`from_fn_with_state(state, touch_idle_clock)`,
            // `set_handler(on_sigterm)`, `.map(parse_line)`, `register(m::h)`).
            // Invisible to the call query (the fn is a value, not the callee).
            // ABSOLUTE line numbers like the macro pass → line_offset = 0.
            calls.extend(calls::extract_fn_pointer_arg_edges(
                node, &source, language, 0, &known_fns,
            ));

            // Cross-file bare fn-pointer-arg + macro-arg candidates (Lane 2):
            // the bare-identifier references the confident passes above DROP
            // (not in `known_fns`). ABSOLUTE line numbers → line_offset = 0.
            // Collected per-chunk so they share the Pass-2 walk; file-keyed, so
            // they ride the flat `candidates` vec rather than `chunk_calls`.
            calls::collect_fn_pointer_arg_candidates(
                node,
                &source,
                language,
                0,
                &known_fns,
                path,
                &mut candidates,
            );
            calls::collect_macro_arg_candidates(
                node,
                &source,
                language,
                0,
                &known_fns,
                path,
                &mut candidates,
            );

            seen.clear();
            calls.retain(|c| seen.insert(c.callee_name.clone()));

            if !calls.is_empty() {
                if want_chunk_calls {
                    if let Some(chunk_id) =
                        byte_range_to_chunk_id.get(&(byte_range.start, byte_range.end))
                    {
                        // chunk_calls line numbers are 1-indexed RELATIVE to
                        // the chunk (the `calls` SQLite table expects this).
                        // Pass-2 scans the whole file and produces ABSOLUTE
                        // line numbers, so subtract `line_start` (saturating,
                        // .max(1)) to recover the relative numbering. This
                        // matches `extract_calls_from_chunk`, which extracts
                        // against the chunk content alone with line_offset=0.
                        for call in &calls {
                            let rel_line = call
                                .line_number
                                .saturating_sub(line_start.saturating_sub(1))
                                .max(1);
                            chunk_calls.push((
                                chunk_id.clone(),
                                CallSite {
                                    callee_name: call.callee_name.clone(),
                                    line_number: rel_line,
                                    kind: call.kind,
                                },
                            ));
                        }
                    }
                    // No matching chunk id ⇒ Pass 1 discarded the chunk
                    // (oversize / post_process), so it has no calls to emit.
                }
                call_results.push(FunctionCalls {
                    name: name.clone(),
                    line_start,
                    calls: std::mem::take(&mut calls),
                });
            }

            // Type extraction
            let mut type_refs =
                self.extract_types(&source, &tree, language, byte_range.start, byte_range.end);
            type_refs.retain(|t| t.type_name != name);

            if !type_refs.is_empty() {
                type_results.push(ChunkTypeRefs {
                    name,
                    line_start,
                    type_refs,
                });
            }
        }

        // --- Phase 3: Injection (combined chunks + relationships) ---
        let injections = language.def().injections;
        if !injections.is_empty() {
            // Release borrows on the outer tree before injection phase
            drop(matches);
            drop(cursor);
            drop(matches2);
            drop(cursor2);

            let groups = injection::find_injection_ranges(&tree, &source, injections);

            // Free outer tree/parser memory before inner parse allocations
            drop(tree);
            drop(parser);

            for group in &groups {
                match self.parse_injected_all(&source, path, group, 0) {
                    Ok((inner_chunks, inner_calls, inner_types))
                        if !inner_chunks.is_empty()
                            || !inner_calls.is_empty()
                            || !inner_types.is_empty() =>
                    {
                        if !inner_chunks.is_empty() {
                            let before = chunks.len();
                            // Identify outer chunk ids about to be dropped
                            // so we can also drop their entries from
                            // chunk_calls. Done before retain so we don't
                            // iterate twice.
                            let drop_ids: std::collections::HashSet<String> = if want_chunk_calls {
                                chunks
                                    .iter()
                                    .filter(|c| {
                                        injection::chunk_within_container(
                                            c.line_start,
                                            c.line_end,
                                            &group.container_lines,
                                        )
                                    })
                                    .map(|c| c.id.clone())
                                    .collect()
                            } else {
                                std::collections::HashSet::new()
                            };
                            chunks.retain(|c| {
                                !injection::chunk_within_container(
                                    c.line_start,
                                    c.line_end,
                                    &group.container_lines,
                                )
                            });
                            let removed = before - chunks.len();
                            tracing::debug!(
                                language = %group.language,
                                removed,
                                added = inner_chunks.len(),
                                "Replaced outer chunks with injection results"
                            );
                            // Drop outer candidates that lived inside this
                            // injection container (their range is owned by the
                            // inner-language parse now). Candidate kinds are
                            // Rust-only, so an inner Rust injection re-emits its
                            // own via the per-chunk extractor below.
                            candidates.retain(|c| {
                                !injection::chunk_within_container(
                                    c.ref_line,
                                    c.ref_line,
                                    &group.container_lines,
                                )
                            });
                            if want_chunk_calls {
                                // Drop outer chunk_calls that lived inside
                                // this injection container (their chunks are
                                // gone) and emit per-inner-chunk calls.
                                // Inner chunks are typically small (script
                                // blocks inside HTML/ASPX) and few per file,
                                // so the cost is negligible compared to the
                                // outer hot-path saving.
                                chunk_calls.retain(|(id, _)| !drop_ids.contains(id));
                                for chunk in &inner_chunks {
                                    let (inner_chunk_calls, inner_cands) =
                                        self.extract_calls_and_candidates_from_chunk(chunk);
                                    for call in inner_chunk_calls {
                                        chunk_calls.push((chunk.id.clone(), call));
                                    }
                                    candidates.extend(inner_cands);
                                }
                            } else {
                                for chunk in &inner_chunks {
                                    candidates.extend(
                                        self.extract_calls_and_candidates_from_chunk(chunk).1,
                                    );
                                }
                            }
                            chunks.extend(inner_chunks);
                        }
                        if !inner_calls.is_empty() || !inner_types.is_empty() {
                            call_results.retain(|fc| {
                                !injection::chunk_within_container(
                                    fc.line_start,
                                    fc.line_start,
                                    &group.container_lines,
                                )
                            });
                            type_results.retain(|tr| {
                                !injection::chunk_within_container(
                                    tr.line_start,
                                    tr.line_start,
                                    &group.container_lines,
                                )
                            });
                            call_results.extend(inner_calls);
                            type_results.extend(inner_types);
                        }
                    }
                    Ok(_) => {
                        // Zero results — keep outer as-is
                    }
                    Err(e) => {
                        tracing::warn!(
                            error = %e,
                            language = %group.language,
                            "Injection parsing failed, keeping outer"
                        );
                    }
                }
            }
        }

        // Per-file summary of oversized-chunk drops, surfaced at warn level
        // so users discover CQS_PARSER_MAX_CHUNK_BYTES.
        if dropped_oversized > 0 {
            tracing::warn!(
                path = %path.display(),
                dropped = dropped_oversized,
                cap_bytes = max_chunk_bytes,
                "Dropped oversized chunks; bump CQS_PARSER_MAX_CHUNK_BYTES if needed"
            );
        }

        Ok((chunks, call_results, type_results, chunk_calls, candidates))
    }

    /// Retrieves the list of file extensions supported by the language registry.
    /// # Returns
    /// A vector of supported file extensions as static string slices (e.g., "rs", "py", "js").
    pub fn supported_extensions(&self) -> Vec<&'static str> {
        crate::language::REGISTRY.supported_extensions().collect()
    }

    /// Parse fenced code blocks from markdown into typed chunks.
    /// For each block with a recognized language, parses the content with that
    /// language's tree-sitter grammar and extracts chunks. Line numbers are
    /// adjusted to reflect their position in the original markdown file.
    fn parse_fenced_blocks(
        &self,
        blocks: &[markdown::FencedBlock],
        source: &str,
        path: &Path,
    ) -> Vec<Chunk> {
        let _span = tracing::info_span!("parse_fenced_blocks", count = blocks.len()).entered();

        // Byte offset of the start of each 1-indexed line in the markdown file.
        // `line_byte_offset[n]` = byte at which line n begins (line 1 = byte 0).
        // Used to lift each fenced-block chunk's block-relative `byte_start`
        // into a file-relative offset so chunk ids stay injective ACROSS blocks
        // (two blocks could otherwise yield chunks at the same block-relative
        // byte_start), and so the stored `byte_start` matches the file-relative
        // meaning the tree-sitter and markdown-section paths use.
        let mut line_byte_offset: Vec<usize> = Vec::with_capacity(source.len() / 32 + 2);
        line_byte_offset.push(0); // line 0 sentinel (unused; lines are 1-indexed)
        line_byte_offset.push(0); // line 1 begins at byte 0
        for (i, b) in source.bytes().enumerate() {
            if b == b'\n' {
                line_byte_offset.push(i + 1);
            }
        }
        let mut result = Vec::new();

        for block in blocks {
            let language = match block.lang.parse::<Language>() {
                Ok(lang) if lang.is_enabled() => lang,
                _ => continue,
            };

            // Skip grammar-less languages (avoid recursion for nested markdown)
            let def = language.def();
            let grammar_fn = match def.grammar {
                Some(f) => f,
                None => continue,
            };

            let grammar = grammar_fn();
            let mut parser = tree_sitter::Parser::new();
            if parser.set_language(&grammar).is_err() {
                tracing::debug!(lang = %block.lang, "Failed to set tree-sitter language for fenced block");
                continue;
            }

            let tree = match parse_with_timeout(&mut parser, &block.content) {
                Some(t) => t,
                None => {
                    tracing::debug!(lang = %block.lang, "Tree-sitter parse returned None for fenced block");
                    continue;
                }
            };

            let query = match self.get_query(language) {
                Ok(q) => q,
                Err(e) => {
                    tracing::debug!(lang = %block.lang, error = %e, "Failed to get query for fenced block");
                    continue;
                }
            };

            let mut cursor = tree_sitter::QueryCursor::new();
            let mut matches = cursor.matches(query, tree.root_node(), block.content.as_bytes());

            // Line offset: fenced block content starts on the line after the opening fence
            let line_offset = block.line_start; // fence is at line_start, content starts at line_start+1
                                                // File-relative byte offset of the block content's first byte. The
                                                // opening fence is on `block.line_start`; content begins on the next
                                                // line. Added to each chunk's block-relative `byte_start` to lift it
                                                // into file coordinates (keeps ids injective across blocks).
            let block_byte_offset = line_byte_offset
                .get(block.line_start as usize + 1)
                .copied()
                .unwrap_or(0) as u32;

            // Env-overridable per-chunk byte cap.
            let max_chunk_bytes = crate::limits::parser_max_chunk_bytes();

            while let Some(m) = matches.next() {
                match self.extract_chunk(&block.content, m, query, language, path) {
                    Ok(mut chunk) => {
                        if chunk.content.len() > max_chunk_bytes {
                            continue;
                        }
                        // Apply post-process if defined
                        if let Some(post_process) = def.post_process_chunk {
                            if let Some(node) = extract_definition_node(m, query) {
                                if !post_process(
                                    &mut chunk.name,
                                    &mut chunk.chunk_type,
                                    node,
                                    &block.content,
                                ) {
                                    continue;
                                }
                            }
                        }
                        // Adjust line numbers AND byte_start to markdown file
                        // position (extract_chunk computed them relative to the
                        // block content), then rebuild the id from the lifted
                        // coordinates via the shared `chunk_id` helper.
                        chunk.line_start += line_offset;
                        chunk.line_end += line_offset;
                        chunk.byte_start = chunk.byte_start.saturating_add(block_byte_offset);
                        chunk.id = chunk::chunk_id(
                            &path.display().to_string(),
                            chunk.line_start,
                            chunk.byte_start,
                            &chunk.content_hash,
                        );
                        result.push(chunk);
                    }
                    Err(e) => {
                        tracing::debug!(
                            error = %e,
                            language = %language,
                            "Failed to extract chunk from fenced block"
                        );
                    }
                }
            }
        }

        tracing::debug!(chunks = result.len(), "Extracted chunks from fenced blocks");
        result
    }
}

/// Find a direct child of a tree-sitter node by kind.
/// Shared helper used by injection parsing and HTML language definition.
#[allow(clippy::manual_find)]
pub(crate) fn find_child_by_kind<'a>(
    node: tree_sitter::Node<'a>,
    kind: &str,
) -> Option<tree_sitter::Node<'a>> {
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if child.kind() == kind {
            return Some(child);
        }
    }
    None
}

/// Find the definition node (function/struct/class/etc.) from a query match's captures.
pub(crate) fn extract_definition_node<'c, 't>(
    m: &tree_sitter::QueryMatch<'c, 't>,
    query: &tree_sitter::Query,
) -> Option<tree_sitter::Node<'t>> {
    // Uses ChunkType::CAPTURE_NAMES — single source of truth from define_chunk_types! macro
    ChunkType::CAPTURE_NAMES.iter().find_map(|name| {
        query
            .capture_index_for_name(name)
            .and_then(|idx| m.captures.iter().find(|c| c.index == idx))
            .map(|c| c.node)
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    /// Verifies that the Parser correctly extracts function definitions from Rust source code.
    /// This is a unit test that validates the `parse_source` method's ability to identify and parse individual functions from a source file. It tests parsing a Rust snippet containing two function definitions and asserts that both functions are extracted as separate chunks with their correct names.
    /// # Arguments
    /// None. This is a test function that creates its own test data internally.
    /// # Returns
    /// None. This function performs assertions and will panic if any assertion fails.
    /// # Panics
    /// Panics if the parser initialization fails, if source parsing fails, or if any of the assertions about extracted chunks fail (incorrect count, missing function names).

    #[test]
    fn parse_source_extracts_functions() {
        let parser = Parser::new().unwrap();
        let source = "fn hello() { println!(\"hi\"); }\nfn world() { }";
        let chunks = parser
            .parse_source(source, Language::Rust, Path::new("test.rs"))
            .unwrap();
        assert!(chunks.len() >= 2);
        assert!(chunks.iter().any(|c| c.name == "hello"));
        assert!(chunks.iter().any(|c| c.name == "world"));
    }

    #[test]
    fn parse_source_empty_string() {
        let parser = Parser::new().unwrap();
        let chunks = parser
            .parse_source("", Language::Rust, Path::new("test.rs"))
            .unwrap();
        assert!(chunks.is_empty());
    }

    #[test]
    fn parse_source_whitespace_only() {
        let parser = Parser::new().unwrap();
        let chunks = parser
            .parse_source("   \n\t\n  \n", Language::Rust, Path::new("test.rs"))
            .unwrap();
        assert!(chunks.is_empty());
    }

    #[test]
    fn parse_source_only_comments() {
        let parser = Parser::new().unwrap();
        let source = "// comment\n/* block */";
        let chunks = parser
            .parse_source(source, Language::Rust, Path::new("test.rs"))
            .unwrap();
        assert!(chunks.is_empty());
    }

    #[test]
    fn parse_source_binary_content_no_panic() {
        let parser = Parser::new().unwrap();
        // Safety: we are deliberately constructing a &str from bytes that are
        // not valid UTF-8 sequences. tree-sitter must not panic on this.
        // Using a lossy approach: embed the bytes in a string literal that
        // Rust allows by escaping them.
        let source = "\x00\x01\x02";
        // This should not panic — result may be Ok or Err, both are acceptable.
        let _ = parser.parse_source(source, Language::Rust, Path::new("binary.rs"));
    }

    #[test]
    fn parse_source_extremely_long_line() {
        let parser = Parser::new().unwrap();
        // 200 000-char line — not valid Rust, but the parser must not panic.
        let long_line = "x".repeat(200_000);
        let _ = parser.parse_source(&long_line, Language::Rust, Path::new("long.rs"));
    }

    #[test]
    fn parse_source_deeply_nested_braces() {
        let parser = Parser::new().unwrap();
        // 500 unclosed opening braces — malformed, but must not panic.
        let source = "{".repeat(500);
        let _ = parser.parse_source(&source, Language::Rust, Path::new("nested.rs"));
    }

    #[test]
    fn parse_source_wrong_language_no_panic() {
        let parser = Parser::new().unwrap();
        // Python source fed to the Rust grammar — must not panic.
        let python_source = "def foo(x):\n    return x + 1\n\nclass Bar:\n    pass\n";
        let _ = parser.parse_source(python_source, Language::Rust, Path::new("wrong.rs"));
    }

    #[test]
    fn parse_source_null_bytes_in_source() {
        let parser = Parser::new().unwrap();
        // Null byte embedded in otherwise-valid Rust — must not panic.
        let source = "fn foo() {}\0fn bar() {}";
        let _ = parser.parse_source(source, Language::Rust, Path::new("null.rs"));
    }

    #[test]
    fn parse_file_all_nonexistent_file() {
        let parser = Parser::new().unwrap();
        let result = parser.parse_file_all(Path::new("/nonexistent/file.rs"));
        assert!(result.is_err());
    }

    #[test]
    fn parse_file_all_empty_file() {
        let parser = Parser::new().unwrap();
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("empty.rs");
        std::fs::File::create(&path).unwrap();
        let result = parser.parse_file_all(&path).unwrap();
        let (chunks, calls, type_refs) = result;
        assert!(chunks.is_empty());
        assert!(calls.is_empty());
        assert!(type_refs.is_empty());
    }

    // Verify the oversized file guard constant.
    #[test]
    fn tc35_max_file_size_is_50mb() {
        assert_eq!(MAX_FILE_SIZE, 50 * 1024 * 1024);
    }

    #[test]
    fn tc35_parse_file_errors_on_missing_file() {
        let parser = Parser::new().unwrap();
        let result = parser.parse_file(std::path::Path::new("/tmp/cqs_nonexistent_tc35.rs"));
        assert!(
            result.is_err(),
            "parse_file should error on nonexistent file"
        );
    }

    /// Oversized files are silently skipped (Ok(vec![])), not surfaced as
    /// an error. This is the documented contract — surfacing the size cap as
    /// Err would break the watch loop's "parse failures are recoverable"
    /// invariant.
    #[test]
    fn parse_file_returns_empty_vec_for_oversized_file() {
        use std::sync::Mutex;
        // CQS_PARSER_MAX_FILE_SIZE is process-global; serialise.
        static PARSER_SIZE_LOCK: Mutex<()> = Mutex::new(());
        let _guard = PARSER_SIZE_LOCK.lock().unwrap();

        let parser = Parser::new().unwrap();
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("big.rs");
        // Write a 200-byte Rust file; cap is set to 100 below.
        std::fs::write(&path, vec![b'a'; 200]).unwrap();

        let prev = std::env::var("CQS_PARSER_MAX_FILE_SIZE").ok();
        std::env::set_var("CQS_PARSER_MAX_FILE_SIZE", "100");
        let result = parser.parse_file(&path);
        // Restore env immediately.
        match prev {
            Some(v) => std::env::set_var("CQS_PARSER_MAX_FILE_SIZE", v),
            None => std::env::remove_var("CQS_PARSER_MAX_FILE_SIZE"),
        }

        let chunks = result.expect("oversized file must skip silently, not error");
        assert!(
            chunks.is_empty(),
            "oversized file must produce empty Vec, got {} chunk(s)",
            chunks.len()
        );
    }

    /// Non-UTF8 file content is silently skipped (Ok(vec![])). Documented
    /// behaviour ("Returns an empty Vec for non-UTF8 files (with a warning
    /// logged)") — pinning so surfacing this as an error doesn't break the
    /// indexer.
    #[test]
    fn parse_file_returns_empty_vec_for_non_utf8_content() {
        let parser = Parser::new().unwrap();
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("bad_utf8.rs");
        // Write bytes that are not valid UTF-8 (lone continuation bytes).
        std::fs::write(&path, [0xFFu8, 0xFE, 0xFD, 0xFC]).unwrap();

        let chunks = parser
            .parse_file(&path)
            .expect("non-UTF8 file must skip silently, not error");
        assert!(
            chunks.is_empty(),
            "non-UTF8 file must produce empty Vec, got {} chunk(s)",
            chunks.len()
        );
    }

    /// Regression guards for the parse-timeout DoS bound.
    ///
    /// tree-sitter has no internal time bound: an adversarial token stream that
    /// drives superlinear error recovery (or merely a huge file under the 50 MB
    /// cap) pins a parser thread for tens of seconds with no cancellation,
    /// stalling the in-process index/watch/daemon parse. `parse_with_timeout`
    /// caps each parse at `CQS_PARSER_TIMEOUT_MS` (default 5 s) and returns
    /// `None` on the budget — the same shape an oversized/unparseable file
    /// already yields (skip-with-warn).
    mod parse_timeout {
        // `CQS_PARSER_TIMEOUT_MS` is process-global; serialise the cohort on the
        // crate-shared lock so other timeout-mutating cohorts can't interleave.
        use crate::parser::TIMEOUT_ENV_LOCK;

        fn with_timeout_env<F: FnOnce()>(value: Option<&str>, f: F) {
            let _g = TIMEOUT_ENV_LOCK.lock().unwrap();
            let prev = std::env::var("CQS_PARSER_TIMEOUT_MS").ok();
            match value {
                Some(v) => std::env::set_var("CQS_PARSER_TIMEOUT_MS", v),
                None => std::env::remove_var("CQS_PARSER_TIMEOUT_MS"),
            }
            f();
            match prev {
                Some(p) => std::env::set_var("CQS_PARSER_TIMEOUT_MS", p),
                None => std::env::remove_var("CQS_PARSER_TIMEOUT_MS"),
            }
        }

        fn rust_parser() -> tree_sitter::Parser {
            let grammar = crate::parser::types::Language::Rust
                .try_grammar()
                .expect("rust grammar");
            let mut p = tree_sitter::Parser::new();
            p.set_language(&grammar).expect("set language");
            p
        }

        /// A pathological token-storm parse must abort under a tight budget and
        /// return `None` within a wall-clock bound far below its uncapped parse
        /// time. UNFIXED (`parser.parse` with no options) ran to completion
        /// (seconds), so this is a red guard there.
        #[test]
        fn pathological_parse_aborts_under_budget() {
            // ~4 MB of bare-token error-recovery storm. Uncapped this parses in
            // multiple seconds; with a 200 ms budget it must bail well under 2 s.
            let n = (4 * 1024 * 1024) / 4;
            let src = format!("fn x(){{{}}}\n", "a b ".repeat(n));
            with_timeout_env(Some("200"), || {
                let mut p = rust_parser();
                let start = std::time::Instant::now();
                let tree = crate::parser::parse_with_timeout(&mut p, &src);
                let elapsed = start.elapsed();
                assert!(tree.is_none(), "parse must abort (None) under the budget");
                assert!(
                    elapsed < std::time::Duration::from_secs(2),
                    "timeout must fire promptly; took {elapsed:?}"
                );
            });
        }

        /// A legitimate file parses successfully under the default budget — the
        /// timeout is behavior-neutral for real input.
        #[test]
        fn normal_file_parses_within_budget() {
            with_timeout_env(None, || {
                let mut p = rust_parser();
                let src = "fn hello() -> u32 { let x = compute(1, 2); x }\n";
                let tree = crate::parser::parse_with_timeout(&mut p, src);
                let tree = tree.expect("normal source must parse within the budget");
                assert_eq!(tree.root_node().kind(), "source_file");
                assert!(!tree.root_node().has_error());
            });
        }

        /// `CQS_PARSER_TIMEOUT_MS=0` disables the timeout (pre-guard behavior),
        /// so even the pathological input parses to completion (no abort). Pins
        /// the documented opt-out so a refactor can't silently make 0 mean
        /// "default".
        #[test]
        fn zero_disables_timeout() {
            // Small-but-error-heavy input that parses fast either way; the point
            // is that disabling the timeout never returns a spurious None.
            let src = format!("fn x(){{{}}}\n", "a b ".repeat(2000));
            with_timeout_env(Some("0"), || {
                assert_eq!(crate::limits::parser_timeout_ms(), None);
                let mut p = rust_parser();
                let tree = crate::parser::parse_with_timeout(&mut p, &src);
                assert!(tree.is_some(), "disabled timeout must never abort a parse");
            });
        }
    }

    /// Property test pinning the equivalence between
    /// `parse_file_all_with_chunk_calls` (Pass-2 emit) and the per-chunk
    /// `extract_calls_from_chunk` extractor. For every
    /// (chunk_id, callee_name, line_number) triple one path emits, the other
    /// must emit the same triple against the same chunk. Run on a small Rust
    /// fixture covering multiple chunks, dedup boundaries, and the
    /// `should_skip_callee` filter.
    #[test]
    fn p2_63_chunk_calls_match_per_chunk_extraction() {
        let parser = Parser::new().unwrap();
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("p2_63_fixture.rs");
        // Three functions with: (a) plain calls, (b) duplicate callees that
        // must dedup within a chunk, (c) self/this noise that should_skip
        // strips, (d) qualified path calls.
        let source = "\
fn alpha() {
    helper();
    helper(); // dup — must collapse to one entry
    util::compute();
}

fn beta() {
    self.field();
    other.method();
    real_call();
}

struct Holder;

impl Holder {
    fn gamma(&self) {
        Self::associated();
        another_call();
    }
}
";
        std::fs::write(&path, source).unwrap();

        // Pass-2 emit path
        let (chunks_new, _calls, _types, chunk_calls_new, _candidates) =
            parser.parse_file_all_with_chunk_calls(&path).unwrap();

        // Per-chunk extraction path: parse_file_all + extract_calls_from_chunk
        let (chunks_old, _calls_old, _types_old) = parser.parse_file_all(&path).unwrap();

        // Chunks themselves must match exactly (same Pass 1 walk)
        assert_eq!(
            chunks_new.len(),
            chunks_old.len(),
            "chunk count must match between parse_file_all and parse_file_all_with_chunk_calls"
        );
        for (n, o) in chunks_new.iter().zip(chunks_old.iter()) {
            assert_eq!(n.id, o.id, "chunk id mismatch");
        }

        // Build the per-chunk shape for comparison.
        let mut chunk_calls_old: Vec<(String, String, u32)> = Vec::new();
        for chunk in &chunks_old {
            for call in parser.extract_calls_from_chunk(chunk) {
                chunk_calls_old.push((chunk.id.clone(), call.callee_name, call.line_number));
            }
        }

        let mut chunk_calls_new_shape: Vec<(String, String, u32)> = chunk_calls_new
            .iter()
            .map(|(id, c)| (id.clone(), c.callee_name.clone(), c.line_number))
            .collect();

        // Order may differ between the two paths (Pass-2 emits per-match,
        // per-chunk extraction emits per-chunk), so compare as sets.
        chunk_calls_old.sort();
        chunk_calls_new_shape.sort();
        assert_eq!(
            chunk_calls_new_shape, chunk_calls_old,
            "Pass-2 chunk_calls must equal per-chunk extract_calls_from_chunk loop\nnew: {:#?}\nold: {:#?}",
            chunk_calls_new_shape, chunk_calls_old
        );

        // Sanity: skip filter actually fires (no `self`/`Self` callees).
        for (_, name, _) in &chunk_calls_new_shape {
            assert!(
                !matches!(name.as_str(), "self" | "Self" | "this" | "super"),
                "should_skip_callee leak: {name}"
            );
        }
    }

    /// Guard 1 — registry invariant for the grammar-less dispatch split.
    ///
    /// The production index path `parse_file_all_inner` (this file, the
    /// grammar-less dispatch around line 533) honors only `custom_all_parser`
    /// before falling back to the markdown default. The test-only relationships
    /// path `parse_file_relationships_with_candidates`
    /// (`src/parser/calls.rs`, grammar-less dispatch around line 1307) honors
    /// `custom_call_parser` FIRST, then `custom_all_parser`, then markdown.
    ///
    /// So a grammar-less language that sets `custom_call_parser` but not
    /// `custom_all_parser` would be honored by the test path and silently
    /// routed to the markdown default by production. No language does this
    /// today, so this test is green; it goes red the moment one is registered,
    /// turning that latent silent-routing fault into a build failure.
    #[test]
    fn custom_call_parser_languages_are_honored_by_production() {
        for def in crate::language::REGISTRY.all() {
            if def.grammar.is_none()
                && def.custom_call_parser.is_some()
                && def.custom_all_parser.is_none()
            {
                panic!(
                    "language `{}` sets custom_call_parser but not custom_all_parser; \
                     the production grammar-less dispatch in parse_file_all_inner \
                     (src/parser/mod.rs, around the `grammar.is_none()` branch) has no \
                     custom_call_parser branch and will silently route it to the markdown \
                     default. Wire custom_call_parser into the production dispatch (plus a \
                     chunk source, since custom_call_parser is calls-only) before \
                     registering this language.",
                    def.name
                );
            }
        }
    }

    /// Guard 2 — parity over the shared `(calls, type_refs, candidates)`
    /// projection of the two file-level relationship-extraction paths.
    ///
    /// The production index entry `parse_file_all_inner(path, true)` returns a
    /// 5-tuple `(chunks, calls, types, chunk_calls, candidates)`; the test-only
    /// entry `parse_file_relationships_with_candidates(path)` returns a 3-tuple
    /// `(calls, types, candidates)`. The two bodies run independently but are
    /// meant to agree on the three shared elements: same serde-candidate
    /// pre-pass, same per-chunk call/serde-callback/macro/fn-pointer passes,
    /// same type extraction with self-filter, same injection retain/extend.
    ///
    /// This freezes that agreement. A future one-sided edit to either body —
    /// e.g. a new serde/macro pass added to one path only — diverges the
    /// projection and fails here, even though each path stays internally
    /// consistent and its own unit tests stay green.
    #[test]
    fn relationship_paths_agree_on_shared_projection() {
        let parser = Parser::new().unwrap();
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("fixture.rs");

        // A Rust fixture exercising every currently-uniform pass: ordinary
        // calls + type refs, field-level serde callbacks, a container-level
        // serde attribute (candidate pre-pass), local fns passed by value
        // (fn-pointer edges), a bare unknown fn-pointer arg (cross-file
        // candidate), and macro token-tree content.
        let source = r#"
use std::time::Duration;

fn default_weight() -> f32 { 1.0 }
fn ser_custom() {}
fn touch_state() {}
fn touch_idle_clock() {}

#[derive(serde::Deserialize)]
#[serde(default = "container_default")]
struct Config {
    #[serde(default = "default_weight")]
    weight: f32,
    #[serde(serialize_with = "ser_custom")]
    name: String,
    #[serde(with = "humantime_serde")]
    timeout: Duration,
}

fn helper(cfg: &Config) -> u32 {
    cfg.weight as u32
}

fn build() {
    let state = make_state();
    from_fn_with_state(state, touch_state, touch_idle_clock);
    register(unresolved_handler);
    println!("{}", helper(&Config::default()));
    let v = vec![compute(), compute()];
    let _ = v;
}
"#;
        std::fs::write(&path, source).unwrap();

        // Production path (private, reachable in-crate): drop chunks +
        // chunk_calls, keep the shared projection.
        let (_chunks, prod_calls, prod_types, _chunk_calls, prod_cands) =
            parser.parse_file_all_inner(&path, true).unwrap();

        // Test-only path: 3-tuple is already the shared projection.
        let (test_calls, test_types, test_cands) = parser
            .parse_file_relationships_with_candidates(&path)
            .unwrap();

        // --- Normalize each projection to a sorted Vec of stable-key tuples.
        // Ordering can differ between the two walks, so compare as sets.
        fn norm_calls(calls: &[FunctionCalls]) -> Vec<(String, u32, String, u32, &'static str)> {
            let mut out: Vec<(String, u32, String, u32, &'static str)> = calls
                .iter()
                .flat_map(|fc| {
                    fc.calls.iter().map(move |c| {
                        (
                            fc.name.clone(),
                            fc.line_start,
                            c.callee_name.clone(),
                            c.line_number,
                            c.kind.as_str(),
                        )
                    })
                })
                .collect();
            out.sort();
            out
        }
        fn norm_types(
            types: &[ChunkTypeRefs],
        ) -> Vec<(String, u32, String, u32, Option<&'static str>)> {
            let mut out: Vec<(String, u32, String, u32, Option<&'static str>)> = types
                .iter()
                .flat_map(|tr| {
                    tr.type_refs.iter().map(move |t| {
                        (
                            tr.name.clone(),
                            tr.line_start,
                            t.type_name.clone(),
                            t.line_number,
                            t.kind.map(|k| k.as_str()),
                        )
                    })
                })
                .collect();
            out.sort();
            out
        }
        fn norm_cands(cands: &[CandidateSite]) -> Vec<(String, u32, String)> {
            let mut out: Vec<(String, u32, String)> = cands
                .iter()
                .map(|c| (c.callee_name.clone(), c.ref_line, c.candidate_kind.clone()))
                .collect();
            out.sort();
            out
        }

        let prod_calls_n = norm_calls(&prod_calls);
        let test_calls_n = norm_calls(&test_calls);
        assert_eq!(
            prod_calls_n, test_calls_n,
            "calls diverge between production parse_file_all_inner and test \
             parse_file_relationships_with_candidates\nprod: {prod_calls_n:#?}\ntest: {test_calls_n:#?}"
        );

        let prod_types_n = norm_types(&prod_types);
        let test_types_n = norm_types(&test_types);
        assert_eq!(
            prod_types_n, test_types_n,
            "type_refs diverge between the two relationship paths\nprod: {prod_types_n:#?}\ntest: {test_types_n:#?}"
        );

        let prod_cands_n = norm_cands(&prod_cands);
        let test_cands_n = norm_cands(&test_cands);
        assert_eq!(
            prod_cands_n, test_cands_n,
            "candidates diverge between the two relationship paths\nprod: {prod_cands_n:#?}\ntest: {test_cands_n:#?}"
        );

        // The fixture must actually exercise the passes, otherwise the parity
        // assertion is vacuously satisfied by two empty projections. Require a
        // non-trivial extraction so a future fixture edit that silences every
        // pass can't quietly turn this guard into a no-op.
        assert!(
            !prod_calls_n.is_empty(),
            "fixture extracted no calls; the parity check would be vacuous"
        );
        assert!(
            prod_calls_n
                .iter()
                .any(|(_, _, callee, _, _)| callee == "default_weight"),
            "serde-callback pass did not fire on the fixture; got: {prod_calls_n:#?}"
        );
        assert!(
            prod_calls_n
                .iter()
                .any(|(_, _, callee, _, _)| callee == "touch_idle_clock"),
            "fn-pointer-arg pass did not fire on the fixture; got: {prod_calls_n:#?}"
        );
    }

    /// Completeness guard for the recursive-tree-walk stack-overflow DoS class.
    ///
    /// The parser's relationship-extraction passes recurse one stack frame per
    /// CST level. An adversarial indexed file with deeply-nested parentheses /
    /// macro `token_tree`s / array literals (a few KB of source, well under
    /// every size cap) drives such a walk past the worker stack and SIGABRTs the
    /// in-process index / watch / daemon. tree-sitter's own parse is iterative
    /// and tolerates the nesting cheaply; only cqs's own Rust walks overflowed.
    /// Each individual recursive walk is therefore depth-railed
    /// (`crate::limits::parser_max_walk_depth`) or iterative by construction, and
    /// each has its own per-function guard in `calls.rs` / `chunk.rs`.
    ///
    /// THIS guard is relational rather than per-function: it drives the PUBLIC
    /// parse entry points — `parse_source` (Pass-1 chunking + its node helpers +
    /// injection), `extract_calls_with_candidates` (Pass-2 call / fn-pointer /
    /// macro / serde-candidate walks), `extract_types` (the type walk), and the
    /// end-to-end `parse_file_all` (the literal indexer entry: file read + both
    /// passes) — over deeply-nested fixtures, on a thread sized to the FLOOR
    /// parse-pool stack (`PARSER_STACK_SIZE_MIN`, the worst-case stack
    /// `parser_stack_size()` can clamp down to — the stack the floor-coupling
    /// invariant promises to hold the rail on). It names no individual walk, so a
    /// NEW un-railed recursive walk wired into any of those entries is caught here
    /// (an un-railed walk recurses to the input's full nesting depth — tens of
    /// thousands of frames — and overflows the pool stack) even when no
    /// per-function guard was added
    /// for it. That is the durable property: the per-function
    /// `deep_walk_stack_safety` / `deep_tree_stack_safety` guards each pin ONE
    /// named walk; this pins "no reachable walk overflows the real entry,"
    /// whatever the set of walks becomes.
    ///
    /// The full set of recursive parser walks was swept and confirmed complete
    /// at the time this was written: every member is depth-railed (the four
    /// call-graph walk families in `calls.rs`), iterative by explicit work-stack
    /// (`collect_comment_ranges`, `find_type_identifier_recursive`,
    /// `collect_rust_fn_names`, `collect_serde_candidates`, `extract_types`),
    /// cursor-/parent-/sibling-bounded (`walk_for_containers`, `infer_chunk_type`,
    /// `extract_doc_comment`), injection-depth-bounded
    /// (`parse_injected_chunks`), or grammar-bounded-shallow
    /// (`has_function_value_lua`). No walk lacks a rail, so this guard is GREEN
    /// hardening rather than a falsifier. It is non-vacuous by construction: each
    /// fixture's parse-tree depth is asserted to exceed the walk rail, so the
    /// rail is actually exercised and a future un-railed walk would deep-recurse
    /// past the pool stack and abort the test binary.
    ///
    /// CALIBRATION (resolved). The per-function guards certify "the depth-800
    /// walk completes on a 1 MiB stack" by calling each walk at the TOP of a fresh
    /// stack. Through the REAL entry the walk runs nested under the entry's frames,
    /// which costs extra margin: a 1 MiB floor left a DEBUG build overflowing at
    /// the default rail (800) while it survived at rail <= 700 and at the 2 MiB
    /// stack. The floor (`crate::limits::PARSER_STACK_SIZE_MIN`) was therefore
    /// raised to 2 MiB — the size that holds rail-800 through the real entry with
    /// margin in both debug and release. The floor-accommodation invariant
    /// (`crate::limits::PARSER_MAX_WALK_DEPTH_CEIL`) now holds through the entry it
    /// protects, not merely standalone. This guard runs at the FLOOR stack
    /// (`PARSER_STACK_SIZE_MIN`) — the worst case the invariant promises — where it
    /// is robustly green, with ample headroom for rail-800 through the real entry
    /// even in a debug build.
    mod parser_pipeline_stack_completeness {
        use super::*;

        /// Nesting depth for every adversarial fixture. ~20x the default depth
        /// rail, so an un-railed walk would recurse far past the pool stack's
        /// capacity, while the railed / iterative walks stop or stream and the
        /// thread joins cleanly. Two source bytes per level keeps the deepest
        /// fixture (~32 KB) well under the per-chunk byte cap, so Pass-1 still
        /// emits the enclosing chunk rather than dropping it oversized.
        const NEST: usize = 16_000;

        /// Run `f` on a thread sized to the FLOOR parse-pool stack
        /// (`PARSER_STACK_SIZE_MIN`) — the smallest stack `parser_stack_size()`
        /// can clamp down to, i.e. the worst case the floor-coupling invariant
        /// promises to hold the rail on. Running at the floor (rather than the
        /// larger default) is what makes this guard actually test the invariant,
        /// and future-proofs it if the default is ever raised above the floor. A
        /// stack overflow here aborts the whole test binary (uncatchable), which
        /// is exactly the red signal a missing rail must produce; an ordinary
        /// assertion failure surfaces as a joined `Err`.
        fn run_on_parse_pool_stack<F: FnOnce() + Send + 'static>(label: &'static str, f: F) {
            let joined = std::thread::Builder::new()
                .stack_size(crate::limits::PARSER_STACK_SIZE_MIN)
                .name(format!("parse-pool-{label}"))
                .spawn(f)
                .expect("spawn parse-pool stack thread")
                .join();
            assert!(
                joined.is_ok(),
                "parse-pool-stack pipeline for {label} panicked or overflowed its stack \
                 (a new un-railed recursive parser walk?)"
            );
        }

        /// Maximum CST depth, computed iteratively so the guard's own
        /// measurement can never overflow on the very trees it is testing.
        fn max_tree_depth(root: tree_sitter::Node) -> usize {
            let mut stack: Vec<(tree_sitter::Node, usize)> = vec![(root, 1)];
            let mut max = 0usize;
            while let Some((node, d)) = stack.pop() {
                max = max.max(d);
                let mut cursor = node.walk();
                for child in node.children(&mut cursor) {
                    stack.push((child, d + 1));
                }
            }
            max
        }

        fn parse_tree(src: &str, lang: Language) -> tree_sitter::Tree {
            let grammar = lang.try_grammar().expect("grammar available");
            let mut ts = tree_sitter::Parser::new();
            ts.set_language(&grammar).expect("set language");
            ts.parse(src, None).expect("tree-sitter parse succeeds")
        }

        /// The whole public parse pipeline must survive adversarially deep
        /// nesting on the FLOOR parse-pool stack — the worst case the rail/stack
        /// coupling promises — for every grammar-bearing entry, not just the
        /// hand-enumerated relationship walks.
        #[test]
        fn pipeline_survives_adversarially_deep_nesting() {
            // Deep parens in call-argument position drive the fn-pointer walk;
            // a deep macro token-tree drives the macro walk; both also drive the
            // Pass-1 node helpers and the type walk over the same deep tree.
            let rust_call_args = format!(
                "fn deep() {{ let _ = f({}1{}); }}\n",
                "(".repeat(NEST),
                ")".repeat(NEST)
            );
            let rust_macro = format!(
                "fn deep() {{ my_macro!({}{}); }}\n",
                "(".repeat(NEST),
                ")".repeat(NEST)
            );
            // A second grammar exercises the language-agnostic Pass-1 helpers
            // (canonical hash / comment-range collection / signature) on a deep
            // tree through a non-Rust route.
            let python_parens = format!("x = {}1{}\n", "(".repeat(NEST), ")".repeat(NEST));

            run_on_parse_pool_stack("pipeline", move || {
                let parser = Parser::new().expect("parser init");

                for (src, label) in [
                    (rust_call_args.as_str(), "rust_call_args"),
                    (rust_macro.as_str(), "rust_macro"),
                ] {
                    let tree = parse_tree(src, Language::Rust);
                    let depth = max_tree_depth(tree.root_node());
                    // Non-vacuity: the fixture must reach past the rail, else a
                    // walk could pass this guard without ever being stressed.
                    assert!(
                        depth > crate::limits::PARSER_MAX_WALK_DEPTH,
                        "{label} fixture CST depth {depth} must exceed the walk rail {} \
                         for the guard to be non-vacuous",
                        crate::limits::PARSER_MAX_WALK_DEPTH
                    );

                    // Pass-1 chunking (+ its node helpers + injection).
                    let _ =
                        parser.parse_source(src, Language::Rust, std::path::Path::new("deep.rs"));
                    // Pass-2 confident edges + low-confidence candidates: the
                    // four railed call-graph walk families.
                    let _ =
                        parser.extract_calls_with_candidates(src, Language::Rust, 0, src.len(), 0);
                    // The type walk over the same deep tree.
                    let _ = parser.extract_types(src, &tree, Language::Rust, 0, src.len());
                }

                // End-to-end indexer entry: file read + Pass-1 + Pass-2.
                let dir = TempDir::new().expect("tempdir");
                let path = dir.path().join("deep.rs");
                std::fs::write(&path, rust_call_args.as_str()).expect("write fixture");
                let _ = parser.parse_file_all(&path);

                // Second-grammar Pass-1 + type walk (skipped if the grammar is
                // not compiled into this build).
                if Language::Python.try_grammar().is_some() {
                    let _ = parser.parse_source(
                        python_parens.as_str(),
                        Language::Python,
                        std::path::Path::new("deep.py"),
                    );
                    let pytree = parse_tree(python_parens.as_str(), Language::Python);
                    let _ = parser.extract_types(
                        python_parens.as_str(),
                        &pytree,
                        Language::Python,
                        0,
                        python_parens.len(),
                    );
                }
            });
        }

        /// Behavior-neutrality pin: the rail (and the whole pipeline) must not
        /// truncate legitimate shallow code. A real fn-pointer edge in a
        /// well-under-the-cap fixture must still be extracted, so a future cap
        /// value (or off-by-one) that suppresses real edges fails here.
        #[test]
        fn pipeline_shallow_real_code_still_extracts() {
            let parser = Parser::new().expect("parser init");
            let src = "fn handler() {}\nfn build() { register(handler); }\n";
            let (calls, _candidates) =
                parser.extract_calls_with_candidates(src, Language::Rust, 0, src.len(), 0);
            assert!(
                calls.iter().any(|c| c.callee_name == "handler"),
                "the depth rail must not suppress a real fn-pointer edge in shallow code: {calls:?}"
            );
        }
    }
}
