Skip to main content

rto_graph/
extract.rs

1//! Extraction: turning the bytes of a source blob into a [`FactSet`].
2//!
3//! Extraction must be a deterministic pure function of `(path, blob_id, bytes)`
4//! so its output can be cached; because the facts are path-dependent (node keys
5//! are path-scoped), the cache is keyed by both path and blob id (see
6//! [`crate::sync`]). [`Registry`] dispatches by file extension to a
7//! language-aware extractor ([`RustExtractor`]), falling back to
8//! [`FileNodeExtractor`] for files with no registered language.
9//!
10//! Language extractors emit `defines`/`contains`/`imports` edges directly, and
11//! record each function's callee names in the caller node's `meta.calls`. Call
12//! *edges* are resolved later, at assembly time, once every file's symbols are
13//! known (see [`crate::sync`]) — a single blob cannot resolve cross-file calls.
14
15use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Span};
16
17/// Version of the extraction *output* (node/edge shape and captured `meta`).
18/// Bump whenever extraction changes what it produces, so the content-addressed
19/// cache (keyed by blob oid + path) does not serve stale facts for an unchanged
20/// blob — the version is folded into the cache key. See [`crate::sync`].
21///
22/// The `pdf-text`, `image-ocr`, `image-vision`, and `audio-transcribe` features
23/// change what PDFs/images/audio extract to, so each occupies a distinct version
24/// namespace: a feature build and a default build never serve each other stale
25/// (content-bearing vs content-free) facts from a shared cache. (Image/audio
26/// output also depends on *which* models are installed; that runtime state is
27/// folded into the cache key separately — see [`media_env_tag`] and
28/// [`crate::sync`].)
29// Bumped 5 → 6 for config-key nodes (ADR-0009): config files now emit
30// `config_key` nodes, so cached extraction facts must be regenerated. Bumped
31// 6 → 7 for YAML config keys + Dockerfile `image_ref` nodes (ADR-0009 derived
32// deploy-artifact extraction). Bumped 7 → 8 for struct `meta.fields` (the named
33// field list a struct declares) — the signal the config_key→struct follow bridge
34// joins on, so cached struct facts must be regenerated to carry it.
35pub(crate) const EXTRACT_VERSION: u32 = 8
36    + if cfg!(feature = "pdf-text") { 100 } else { 0 }
37    + if cfg!(feature = "image-ocr") { 200 } else { 0 }
38    + if cfg!(feature = "image-vision") {
39        400
40    } else {
41        0
42    }
43    + if cfg!(feature = "audio-transcribe") {
44        800
45    } else {
46        0
47    };
48
49/// Max characters of embeddable content (markdown body / doc-comment / PDF text)
50/// captured into a node's `meta.content`, to keep the store small while giving
51/// inference real text to embed.
52const MAX_CONTENT: usize = 1500;
53
54/// PDFs larger than this are not text-extracted — `pdf-extract` builds the full
55/// document text in memory, so cap the work a pathological file can impose.
56#[cfg(feature = "pdf-text")]
57const MAX_PDF_BYTES: usize = 20 * 1024 * 1024;
58
59/// Images larger than this (compressed bytes) are not processed (OCR/VLM).
60#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
61const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
62
63/// Images with more pixels than this are not processed — OCR/VLM time scales with
64/// pixel count, and this also guards against decompression bombs (the dimension is
65/// read from the header before the pixels are decoded).
66#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
67const MAX_IMAGE_PIXELS: u64 = 4096 * 4096;
68
69/// When OCR yields fewer than this many words, the image is treated as
70/// text-sparse (a diagram/photo rather than a text screenshot), so the vision
71/// model is run to describe it (only when `image-vision` is also enabled).
72#[cfg(feature = "image-vision")]
73const MIN_OCR_WORDS: usize = 8;
74
75/// Audio files larger than this (compressed bytes) are not transcribed — decode
76/// + inference time scales with duration, so cap the work a single clip imposes.
77#[cfg(feature = "audio-transcribe")]
78const MAX_AUDIO_BYTES: usize = 50 * 1024 * 1024;
79
80/// Turns one source blob into the nodes and edges derived from it.
81pub trait Extractor {
82    /// Extract a [`FactSet`] from a blob's `path`, git `blob_id`, and `bytes`.
83    ///
84    /// Implementations must be deterministic: identical inputs must always
85    /// produce an identical fact set.
86    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet;
87
88    /// Runtime inputs — beyond `(path, bytes)` — that change extraction output
89    /// and so must be folded into the sync cache key: the installed media-model
90    /// identity (OCR + vision + audio) and any [`IngestConfig`] toggles. The
91    /// default is the media-model tag alone; [`Registry`] additionally folds in
92    /// its ingestion config so toggling content off re-extracts affected blobs
93    /// instead of serving stale, content-bearing facts.
94    fn env_tag(&self) -> u64 {
95        media_env_tag()
96    }
97}
98
99/// Runtime ingestion toggles (ADR-0007 `[ingest]`): which blob content is
100/// extracted for embedding. Every toggle defaults to **on**, and a toggle only
101/// gates content *within a build that supports it* — turning `pdf` on cannot
102/// extract PDF text in a binary built without the `pdf-text` feature, but
103/// turning it off suppresses that content in a binary that has it.
104// Four independent content toggles: a flat bool-per-class struct is the clearest
105// representation (a state enum or bitflags would obscure, not clarify).
106#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct IngestConfig {
109    /// Embed the UTF-8 body of prose files (Markdown, plain text).
110    pub prose: bool,
111    /// Extract text from PDF documents (needs the `pdf-text` feature).
112    pub pdf: bool,
113    /// OCR literal text from images (needs the `image-ocr` feature).
114    pub ocr: bool,
115    /// Describe images with a vision model (needs the `image-vision` feature).
116    pub vision: bool,
117    /// Transcribe spoken-word audio (needs the `audio-transcribe` feature).
118    pub audio: bool,
119}
120
121impl Default for IngestConfig {
122    fn default() -> Self {
123        Self {
124            prose: true,
125            pdf: true,
126            ocr: true,
127            vision: true,
128            audio: true,
129        }
130    }
131}
132
133impl IngestConfig {
134    /// A cache-key contribution that is **`0` when every toggle is on** (the
135    /// default), so the common case leaves existing cache keys untouched. Each
136    /// disabled toggle sets a distinct bit, so turning content off changes the
137    /// key and re-extracts affected blobs.
138    fn disabled_bits(self) -> u64 {
139        u64::from(!self.prose)
140            | (u64::from(!self.pdf) << 1)
141            | (u64::from(!self.ocr) << 2)
142            | (u64::from(!self.vision) << 3)
143            | (u64::from(!self.audio) << 4)
144    }
145}
146
147/// Dispatches extraction to a language-aware extractor by file extension,
148/// falling back to a plain file node when no language is registered. After the
149/// language extractor runs, [`crate::markers`] appends any intent-debt markers
150/// (intent-debt markers) found in the blob. Carries the runtime
151/// [`IngestConfig`] applied to content extraction.
152#[derive(Debug, Clone, Copy, Default)]
153pub struct Registry {
154    /// Which blob content to extract for embedding.
155    pub ingest: IngestConfig,
156}
157
158impl Registry {
159    /// A registry with the given ingestion toggles.
160    #[must_use]
161    pub fn new(ingest: IngestConfig) -> Self {
162        Self { ingest }
163    }
164}
165
166impl Extractor for Registry {
167    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
168        let mut facts = extract_facts(path, blob_id, bytes, self.ingest);
169        crate::markers::augment(&mut facts, path, blob_id, bytes);
170        facts
171    }
172
173    fn env_tag(&self) -> u64 {
174        let media = media_env_tag();
175        let disabled = self.ingest.disabled_bits();
176        if disabled == 0 {
177            // All-on default: preserve existing cache keys exactly.
178            media
179        } else {
180            // FNV-1a fold of both components — deterministic and stable. As with
181            // any 64-bit hash a collision with the all-on key is possible but
182            // vanishingly unlikely, and a collision only costs a spurious cache
183            // hit/miss, never incorrect facts.
184            let mut h = 0xcbf2_9ce4_8422_2325u64;
185            for b in media
186                .to_le_bytes()
187                .into_iter()
188                .chain(disabled.to_le_bytes())
189            {
190                h ^= u64::from(b);
191                h = h.wrapping_mul(0x0000_0100_0000_01b3);
192            }
193            h
194        }
195    }
196}
197
198/// Shared extraction dispatch used by [`Registry`] and the standalone
199/// extractors: pick the language extractor by extension, applying `ingest` to
200/// content extraction.
201fn extract_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
202    // Config files (TOML / JSON / .env) get config-key nodes rather than a plain
203    // file node, so their keys are first-class graph nodes (ADR-0009).
204    if crate::config_keys::is_config_path(path) {
205        return config_facts(path, blob_id, bytes, ingest);
206    }
207    // Dockerfiles yield `image_ref` nodes (the base-image version pin a spoke
208    // deploys) rather than a plain file node (ADR-0009 derived facts).
209    if is_dockerfile(path) {
210        return dockerfile_facts(path, blob_id, bytes, ingest);
211    }
212    let ext = extension(path);
213    match ext.as_deref() {
214        // Rust keeps its dedicated AST walker (imports, impl scoping, richer calls).
215        Some("rs") => rust_facts(path, blob_id, bytes, ingest),
216        // Every other supported language goes through the generic tags extractor;
217        // an unhandled extension (or a query that fails to compile) falls back to
218        // a plain file node.
219        Some(ext) => tag_facts(path, blob_id, bytes, ext, ingest).unwrap_or_else(|| {
220            FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest))
221        }),
222        None => FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest)),
223    }
224}
225
226/// Lowercase file extension of `path`, if any. Lowercasing makes extension
227/// dispatch case-insensitive, so `Guide.PDF` and `README.MD` are recognised.
228fn extension(path: &str) -> Option<String> {
229    let name = path.rsplit('/').next().unwrap_or(path);
230    name.rsplit_once('.')
231        .map(|(_, ext)| ext.to_ascii_lowercase())
232}
233
234/// The natural key of the `file` node for `path`.
235fn file_key(path: &str) -> String {
236    format!("file:{path}")
237}
238
239/// Build the shared `file` node for a source blob. `ingest` gates which content
240/// is embedded (ADR-0007 `[ingest]`): a disabled class yields no content, as if
241/// the file carried none.
242fn file_node(
243    path: &str,
244    blob_id: &str,
245    bytes: &[u8],
246    lang: Option<&str>,
247    ingest: IngestConfig,
248) -> Node {
249    let name = path.rsplit('/').next().unwrap_or(path).to_owned();
250    let lines = bytes
251        .iter()
252        .fold(0usize, |n, &b| n + usize::from(b == b'\n'));
253    let end = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
254    let mut meta = serde_json::json!({ "bytes": bytes.len(), "lines": lines });
255    // Capture the (capped) body so inference embeds *meaning*, not just the
256    // filename: prose files decode as UTF-8; PDFs go through `pdf_content` (only
257    // when the `pdf-text` feature is on, otherwise it is a no-op). Each class is
258    // gated by its `ingest` toggle so a project can suppress it without a rebuild.
259    let content = if ingest.prose && is_prose(path) {
260        cap_content(&String::from_utf8_lossy(bytes))
261    } else if let Some(text) = ingest.pdf.then(|| pdf_content(path, bytes)).flatten() {
262        cap_content(&text)
263    } else if let Some(text) = image_content(path, bytes, ingest) {
264        cap_content(&text)
265    } else if let Some(text) = audio_content(path, bytes, ingest) {
266        cap_content(&text)
267    } else {
268        String::new()
269    };
270    if !content.is_empty() {
271        meta["content"] = serde_json::Value::from(content);
272    }
273    Node {
274        key: file_key(path),
275        kind: NodeKind::File,
276        name,
277        path: Some(path.to_owned()),
278        lang: lang.map(ToOwned::to_owned),
279        blob_hash: Some(blob_id.to_owned()),
280        span: Some(Span::new(0, end)),
281        provenance: Provenance::Derived,
282        meta,
283    }
284}
285
286/// Emit config-key facts for a config file (ADR-0009): the `file` node, plus a
287/// `config_key` node per flattened leaf — key `cfgkey:<path>#<dotted>`, name the
288/// dotted path, `meta` carrying the key and value — with a `contains` edge from
289/// the file. Deterministic: keys are de-duplicated (dotenv "last one wins") into
290/// a sorted map. Secret-looking values are redacted before they reach the store.
291fn config_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
292    let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
293    let file = file_key(path);
294    // A config file that repeats a key yields one node with the final value, and
295    // the emission order is deterministic regardless of parse order.
296    let mut by_key: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
297    for ck in crate::config_keys::flatten(path, bytes) {
298        by_key.insert(ck.key, ck.value);
299    }
300    for (key, value) in by_key {
301        let node_key = format!("cfgkey:{path}#{key}");
302        // Redact the value of secret-looking keys so tokens/passwords from
303        // `.env`/config files are never persisted into the (exportable) store.
304        let value = if crate::config_keys::is_secret_key(&key) {
305            "<redacted>".to_owned()
306        } else {
307            value
308        };
309        let mut node = Node::new(
310            node_key.clone(),
311            NodeKind::Other(crate::config_keys::KIND.into()),
312            key.clone(),
313        );
314        node.path = Some(path.to_owned());
315        node.blob_hash = Some(blob_id.to_owned());
316        node.meta = serde_json::json!({ "key": key, "value": value });
317        facts = facts.with_node(node).with_edge(Edge::derived(
318            file.clone(),
319            node_key,
320            EdgeKind::Contains,
321        ));
322    }
323    facts
324}
325
326/// The `NodeKind::Other` token for a container base-image reference extracted from
327/// a Dockerfile `FROM` (ADR-0009 derived deploy-artifact facts). Its `meta` carries
328/// `{image, tag, digest}` — the version pin a spoke deploys.
329pub(crate) const IMAGE_REF_KIND: &str = "image_ref";
330
331/// Whether `path` is a Dockerfile/Containerfile (by conventional name):
332/// `Dockerfile`, `Containerfile`, `Dockerfile.<x>`, or `*.dockerfile`.
333fn is_dockerfile(path: &str) -> bool {
334    let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
335    base == "dockerfile"
336        || base == "containerfile"
337        || base.starts_with("dockerfile.")
338        || base.ends_with(".dockerfile")
339}
340
341/// Extract each Dockerfile `FROM` external base image into an `image_ref` node
342/// (`imageref:<file>#<n>`, `meta {image, tag, digest}`) with a `references` edge
343/// from the file — the version pin a deployment spoke ships. Internal multi-stage
344/// references (`FROM <prior-stage>`) and `FROM scratch` are skipped.
345fn dockerfile_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
346    let mut facts = FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
347    let file = file_key(path);
348    let text = String::from_utf8_lossy(bytes);
349    let mut stages: std::collections::HashSet<String> = std::collections::HashSet::new();
350    let mut idx = 0usize;
351    for line in text.lines() {
352        let Some(rest) = strip_from_prefix(line.trim()) else {
353            continue;
354        };
355        let (image, stage) = parse_from(rest);
356        // Decide whether the image is an earlier stage against the stages seen *so
357        // far*, before recording this line's own alias — otherwise `FROM x AS x`
358        // would wrongly treat the external image `x` as an internal stage.
359        let is_internal_stage = stages.contains(&image.to_ascii_lowercase());
360        if let Some(s) = stage {
361            stages.insert(s.to_ascii_lowercase());
362        }
363        // Skip `scratch` and references to an earlier build stage — neither is an
364        // external image to pin.
365        if image.is_empty() || image.eq_ignore_ascii_case("scratch") || is_internal_stage {
366            continue;
367        }
368        let (name, tag, digest) = split_image(image);
369        let node_key = format!("imageref:{path}#{idx}");
370        idx += 1;
371        let mut node = Node::new(
372            node_key.clone(),
373            NodeKind::Other(IMAGE_REF_KIND.into()),
374            image.to_owned(),
375        );
376        node.path = Some(path.to_owned());
377        node.blob_hash = Some(blob_id.to_owned());
378        node.meta = serde_json::json!({ "image": name, "tag": tag, "digest": digest });
379        facts = facts.with_node(node).with_edge(Edge::derived(
380            file.clone(),
381            node_key,
382            EdgeKind::References,
383        ));
384    }
385    facts
386}
387
388/// The remainder of a `FROM ` line (case-insensitive prefix), or `None`.
389fn strip_from_prefix(line: &str) -> Option<&str> {
390    let b = line.as_bytes();
391    (b.len() >= 5 && b[..4].eq_ignore_ascii_case(b"from") && b[4].is_ascii_whitespace())
392        .then(|| line[5..].trim_start())
393}
394
395/// Parse a `FROM` argument list into `(image, stage-alias)`: the first non-flag
396/// token is the image (leading `--platform=…` flags skipped), and an `AS <name>`
397/// suffix names the build stage.
398fn parse_from(rest: &str) -> (&str, Option<&str>) {
399    let image = rest
400        .split_whitespace()
401        .find(|t| !t.starts_with("--"))
402        .unwrap_or("");
403    let mut toks = rest.split_whitespace();
404    let mut stage = None;
405    while let Some(t) = toks.next() {
406        if t.eq_ignore_ascii_case("as") {
407            stage = toks.next();
408            break;
409        }
410    }
411    (image, stage)
412}
413
414/// Split an image reference into `(name, tag, digest)`. A `@sha256:…` digest wins;
415/// otherwise a tag is the `:`-suffix *after the last path segment* (so a registry
416/// `host:port/` prefix is never mistaken for a tag).
417fn split_image(image: &str) -> (String, Option<String>, Option<String>) {
418    if let Some((name, digest)) = image.split_once('@') {
419        return (name.to_owned(), None, Some(digest.to_owned()));
420    }
421    let seg = image.rfind('/').map_or(0, |i| i + 1);
422    if let Some(colon) = image[seg..].find(':') {
423        let at = seg + colon;
424        return (
425            image[..at].to_owned(),
426            Some(image[at + 1..].to_owned()),
427            None,
428        );
429    }
430    (image.to_owned(), None, None)
431}
432
433/// Strip doc-comment markers from a comment, returning its body — or `None` if
434/// it is not a doc comment. Recognises `///` (but not `////`), `//!`, `/** */`,
435/// and `/*! */`; a plain `//` or `/* */` comment returns `None`.
436fn doc_comment_body(raw: &str) -> Option<String> {
437    let t = raw.trim();
438    if t.starts_with("//!") || (t.starts_with("///") && !t.starts_with("////")) {
439        return Some(t[3..].trim().to_owned());
440    }
441    if (t.starts_with("/**") || t.starts_with("/*!")) && t.ends_with("*/") {
442        // Content lies between the 3-char opener (`/**`/`/*!`) and the 2-char
443        // closer (`*/`). Guard the overlap on tiny comments like `/**/`, where
444        // the opener and closer share a `*` — those have no body.
445        let end = t.len() - 2;
446        let inner = if end >= 3 { &t[3..end] } else { "" };
447        let cleaned: Vec<&str> = inner
448            .lines()
449            .map(|l| l.trim().trim_start_matches('*').trim())
450            .filter(|l| !l.is_empty())
451            .collect();
452        return Some(cleaned.join(" "));
453    }
454    None
455}
456
457/// Extract the text of a PDF blob for embedding, or `None` when `path` is not a
458/// PDF, the `pdf-text` feature is off, the file is too large, or extraction
459/// yields no usable text.
460///
461/// `pdf-extract` handles fonts/CMaps internally but can panic on some malformed
462/// documents; the call is panic-guarded so a bad PDF degrades to a plain file
463/// node rather than aborting the whole sync.
464#[cfg(feature = "pdf-text")]
465fn pdf_content(path: &str, bytes: &[u8]) -> Option<String> {
466    if extension(path).as_deref() != Some("pdf") || bytes.len() > MAX_PDF_BYTES {
467        return None;
468    }
469    let owned = bytes.to_vec();
470    let text = std::panic::catch_unwind(move || pdf_extract::extract_text_from_mem(&owned).ok())
471        .ok()
472        .flatten()?;
473    (!text.trim().is_empty()).then_some(text)
474}
475
476/// No-op when the `pdf-text` feature is off: PDFs become plain file nodes.
477#[cfg(not(feature = "pdf-text"))]
478fn pdf_content(_path: &str, _bytes: &[u8]) -> Option<String> {
479    None
480}
481
482/// Embeddable content for an image blob, composing OCR text and an optional
483/// vision-model description (see [`ocr_content`]/[`vlm_content`]), or `None` when
484/// `path` is not an image, the image is too large, no image model is installed,
485/// or nothing is produced.
486///
487/// Both extractors read the *installed* image models — that runtime dependency is
488/// reflected in the cache key via [`media_env_tag`], so installing/upgrading a
489/// model re-extracts affected images instead of serving stale (content-free)
490/// facts.
491#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
492fn image_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
493    if !is_image(path) || bytes.len() > MAX_IMAGE_BYTES {
494        return None;
495    }
496    // OCR reads literal text (cheap, accurate); the vision model *describes* the
497    // image (slow). Smart composition (ADR-0005): always OCR; run the VLM only
498    // when OCR text is sparse — a diagram/photo rather than a text screenshot —
499    // and store both when both fire. Each stage is additionally gated by its
500    // `ingest` toggle so a project can disable OCR and/or vision at runtime.
501    let ocr = if ingest.ocr { ocr_content(bytes) } else { None };
502    let sparse = ocr
503        .as_deref()
504        .is_none_or(|t| t.split_whitespace().count() < min_ocr_words());
505    let vision = if ingest.vision && sparse {
506        vlm_content(bytes)
507    } else {
508        None
509    };
510    match (ocr, vision) {
511        (Some(o), Some(v)) => Some(format!("{o}\n\n{v}")),
512        (Some(o), None) => Some(o),
513        (None, Some(v)) => Some(v),
514        (None, None) => None,
515    }
516}
517
518/// No-op when neither image feature is on: images become plain file nodes.
519#[cfg(not(any(feature = "image-ocr", feature = "image-vision")))]
520fn image_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
521    None
522}
523
524/// The word count below which OCR output is "sparse" enough to invoke the VLM.
525/// `usize::MAX` when `image-vision` is off, so the VLM is never triggered.
526#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
527fn min_ocr_words() -> usize {
528    #[cfg(feature = "image-vision")]
529    {
530        MIN_OCR_WORDS
531    }
532    #[cfg(not(feature = "image-vision"))]
533    {
534        usize::MAX
535    }
536}
537
538/// Whether `path` is an image OCR/vision can read.
539#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
540fn is_image(path: &str) -> bool {
541    matches!(extension(path).as_deref(), Some("png" | "jpg" | "jpeg"))
542}
543
544/// Embeddable content for an audio blob: a transcript of its spoken words, or
545/// `None` when `path` is not audio, the clip is too large, the `audio` toggle is
546/// off, the `audio-transcribe` feature is off, or no model is installed.
547///
548/// Like the image extractors, this reads the *installed* audio model — that
549/// runtime dependency is reflected in the cache key via [`media_env_tag`], so
550/// installing/upgrading the model re-transcribes affected clips instead of serving
551/// stale (content-free) facts.
552#[cfg(feature = "audio-transcribe")]
553fn audio_content(path: &str, bytes: &[u8], ingest: IngestConfig) -> Option<String> {
554    if !ingest.audio || !is_audio(path) || bytes.len() > MAX_AUDIO_BYTES {
555        return None;
556    }
557    asr_content(bytes)
558}
559
560/// No-op when the `audio-transcribe` feature is off: audio files become plain
561/// file nodes.
562#[cfg(not(feature = "audio-transcribe"))]
563fn audio_content(_path: &str, _bytes: &[u8], _ingest: IngestConfig) -> Option<String> {
564    None
565}
566
567/// Whether `path` is an audio file the projector's miniaudio decoder can read
568/// (WAV/MP3/FLAC — the formats llama.cpp bundles support for).
569#[cfg(feature = "audio-transcribe")]
570fn is_audio(path: &str) -> bool {
571    matches!(extension(path).as_deref(), Some("wav" | "mp3" | "flac"))
572}
573
574/// Transcribe spoken-word audio with the GGUF audio model (`ASR_MODEL`) through
575/// the shared llama.cpp engine (`rto-llama`) — the raw file bytes are decoded and
576/// resampled by llama.cpp's bundled miniaudio, so no separate audio-decoding crate
577/// is needed. `None` when the model is not installed or generation yields nothing.
578#[cfg(feature = "audio-transcribe")]
579fn asr_content(bytes: &[u8]) -> Option<String> {
580    use rto_llama::Engine as _;
581
582    let engine = asr_engine()?;
583    let completion = engine
584        .chat(&rto_llama::ChatRequest {
585            model: ASR_MODEL.to_owned(),
586            messages: vec![rto_llama::Message {
587                role: "user".to_owned(),
588                content: "Transcribe this audio recording. Output only the spoken words, verbatim."
589                    .to_owned(),
590            }],
591            images: Vec::new(),
592            audio: vec![bytes.to_vec()],
593            temperature: 0.0,
594            max_tokens: 512,
595        })
596        .ok()?;
597    let text = completion.content.trim();
598    (!text.is_empty()).then(|| text.to_owned())
599}
600
601/// The GGUF audio model backing `audio-transcribe`.
602#[cfg(feature = "audio-transcribe")]
603const ASR_MODEL: &str = "voxtral-mini-3b";
604
605/// The process-wide audio engine, built lazily from the installed `ASR_MODEL`
606/// (`model.gguf` + audio `mmproj.gguf`). `None` when the model is not installed —
607/// transcription is then inert (run `roteiro model pull voxtral-mini-3b`).
608#[cfg(feature = "audio-transcribe")]
609fn asr_engine() -> Option<&'static rto_llama::llama::LlamaEngine> {
610    use std::sync::OnceLock;
611    static ENGINE: OnceLock<Option<rto_llama::llama::LlamaEngine>> = OnceLock::new();
612    ENGINE
613        .get_or_init(|| {
614            let dir = crate::models::model_dir(ASR_MODEL);
615            let (gguf, mmproj) = (dir.join("model.gguf"), dir.join("mmproj.gguf"));
616            if !gguf.exists() || !mmproj.exists() {
617                return None;
618            }
619            rto_llama::llama::LlamaEngine::new(
620                vec![rto_llama::llama::Served {
621                    name: ASR_MODEL.to_owned(),
622                    path: gguf,
623                    mmproj: Some(mmproj),
624                }],
625                0,
626            )
627            .ok()
628        })
629        .as_ref()
630}
631
632/// Whether the image's pixel dimensions (read from its header, without decoding
633/// the pixels — so a decompression bomb is rejected cheaply) are within
634/// [`MAX_IMAGE_PIXELS`]. `false` if the header cannot be parsed or the limit is
635/// exceeded.
636#[cfg(any(feature = "image-ocr", feature = "image-vision"))]
637fn image_dimensions_ok(bytes: &[u8]) -> bool {
638    let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()
639    else {
640        return false;
641    };
642    match reader.into_dimensions() {
643        Ok((w, h)) => u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS,
644        Err(_) => false,
645    }
646}
647
648/// OCR an image's text (or `None` when `image-ocr` is off, the models are not
649/// installed, the image is too large, or extraction yields nothing). The `ocrs`
650/// engine can panic on some inputs, so the call is panic-guarded.
651#[cfg(feature = "image-ocr")]
652fn ocr_content(bytes: &[u8]) -> Option<String> {
653    let dir = crate::models::model_dir("ocrs-text");
654    let detection = dir.join("text-detection.rten");
655    let recognition = dir.join("text-recognition.rten");
656    if !detection.exists() || !recognition.exists() || !image_dimensions_ok(bytes) {
657        // Models not installed → OCR is inert (run `roteiro model pull ocrs-text`).
658        return None;
659    }
660    // Borrow `bytes` into the guarded closure — no need to clone the (up to
661    // 20 MiB) image. `&[u8]`/`&Path` are unwind-safe, so no `AssertUnwindSafe`.
662    let text = std::panic::catch_unwind(|| run_ocr(&detection, &recognition, bytes))
663        .ok()
664        .flatten()?;
665    (!text.trim().is_empty()).then_some(text)
666}
667
668// Only the `any(image-ocr, image-vision)` version of `image_content` calls this,
669// so the no-op stub is needed only when that caller is compiled with image-ocr
670// off — i.e. image-vision on. Without this narrower gate it would be dead code in
671// an audio-only (no image feature) build.
672#[cfg(all(feature = "image-vision", not(feature = "image-ocr")))]
673fn ocr_content(_bytes: &[u8]) -> Option<String> {
674    None
675}
676
677/// Run detection + recognition over an image's bytes, returning its text.
678/// Fallible steps collapse to `None` (a bad image yields no content).
679#[cfg(feature = "image-ocr")]
680fn run_ocr(
681    detection: &std::path::Path,
682    recognition: &std::path::Path,
683    bytes: &[u8],
684) -> Option<String> {
685    use ocrs::{ImageSource, OcrEngine, OcrEngineParams};
686
687    let detection_model = rten::Model::load_file(detection).ok()?;
688    let recognition_model = rten::Model::load_file(recognition).ok()?;
689    let engine = OcrEngine::new(OcrEngineParams {
690        detection_model: Some(detection_model),
691        recognition_model: Some(recognition_model),
692        ..Default::default()
693    })
694    .ok()?;
695
696    let img = image::load_from_memory(bytes).ok()?.into_rgb8();
697    let source = ImageSource::from_bytes(img.as_raw(), img.dimensions()).ok()?;
698    let input = engine.prepare_input(source).ok()?;
699    engine.get_text(&input).ok()
700}
701
702/// Describe an image with the GGUF vision-language model (`smolvlm-500m-gguf`)
703/// through the shared llama.cpp engine (`rto-llama`, ADR-0003 v1.2) — no candle.
704/// Returns `None` when `image-vision` is off, the model is not installed, the
705/// image is too large, or generation yields nothing. The engine (model +
706/// `mmproj`) is loaded once per process and reused across images (a fresh
707/// context per call keeps KV cache from carrying over).
708#[cfg(feature = "image-vision")]
709fn vlm_content(bytes: &[u8]) -> Option<String> {
710    use rto_llama::Engine as _;
711
712    if !image_dimensions_ok(bytes) {
713        return None;
714    }
715    let engine = vlm_engine()?;
716    let completion = engine
717        .chat(&rto_llama::ChatRequest {
718            model: VLM_MODEL.to_owned(),
719            messages: vec![rto_llama::Message {
720                role: "user".to_owned(),
721                content: "Describe this image in one or two sentences.".to_owned(),
722            }],
723            images: vec![bytes.to_vec()],
724            audio: Vec::new(),
725            temperature: 0.0,
726            max_tokens: 128,
727        })
728        .ok()?;
729    let text = completion.content.trim();
730    (!text.is_empty()).then(|| text.to_owned())
731}
732
733/// The GGUF vision-language model backing `image-vision`.
734#[cfg(feature = "image-vision")]
735const VLM_MODEL: &str = "smolvlm-500m-gguf";
736
737/// The process-wide vision engine, built lazily from the installed
738/// `smolvlm-500m-gguf` (`model.gguf` + `mmproj.gguf`). `None` when the model is
739/// not installed — vision is then inert (run `roteiro model pull smolvlm-500m-gguf`).
740#[cfg(feature = "image-vision")]
741fn vlm_engine() -> Option<&'static rto_llama::llama::LlamaEngine> {
742    use std::sync::OnceLock;
743    static ENGINE: OnceLock<Option<rto_llama::llama::LlamaEngine>> = OnceLock::new();
744    ENGINE
745        .get_or_init(|| {
746            let dir = crate::models::model_dir(VLM_MODEL);
747            let (gguf, mmproj) = (dir.join("model.gguf"), dir.join("mmproj.gguf"));
748            if !gguf.exists() || !mmproj.exists() {
749                return None;
750            }
751            rto_llama::llama::LlamaEngine::new(
752                vec![rto_llama::llama::Served {
753                    name: VLM_MODEL.to_owned(),
754                    path: gguf,
755                    mmproj: Some(mmproj),
756                }],
757                0,
758            )
759            .ok()
760        })
761        .as_ref()
762}
763
764// Mirror of the `ocr_content` stub: needed only when the image path is compiled
765// (image-ocr on) with image-vision off, not in an audio-only build.
766#[cfg(all(feature = "image-ocr", not(feature = "image-vision")))]
767fn vlm_content(_bytes: &[u8]) -> Option<String> {
768    None
769}
770
771/// A cache-key component reflecting the media extractors' runtime environment:
772/// `0` when no media feature is on or no models are installed, else a hash of the
773/// installed OCR/vision/audio model identities. Folded into the sync cache key so
774/// installing/upgrading a model re-extracts affected images/audio instead of
775/// serving stale facts (media output is not a pure function of the blob alone).
776/// See [`crate::sync`].
777///
778/// The audio fold is `#[cfg]`-gated on `audio-transcribe`, so an image-only build
779/// produces exactly the same tag it did before audio existed — no cache churn.
780#[cfg(any(
781    feature = "image-ocr",
782    feature = "image-vision",
783    feature = "audio-transcribe"
784))]
785pub(crate) fn media_env_tag() -> u64 {
786    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
787    let mut any = false;
788    #[cfg(feature = "image-ocr")]
789    {
790        any |= fold_installed_model(&mut hash, "ocrs-text");
791    }
792    #[cfg(feature = "image-vision")]
793    {
794        any |= fold_installed_model(&mut hash, "smolvlm-500m-gguf");
795    }
796    #[cfg(feature = "audio-transcribe")]
797    {
798        any |= fold_installed_model(&mut hash, "voxtral-mini-3b");
799    }
800    if any { hash | 1 } else { 0 }
801}
802
803/// If model `name` is fully installed, fold its host-variant checksums into
804/// `hash` and return `true`. Only the host-selected variant is hashed, so an
805/// unrelated platform variant does not perturb this host's tag.
806#[cfg(any(
807    feature = "image-ocr",
808    feature = "image-vision",
809    feature = "audio-transcribe"
810))]
811fn fold_installed_model(hash: &mut u64, name: &str) -> bool {
812    let Some(variant) = crate::models::find(name)
813        .and_then(|spec| spec.variant_for(crate::models::Platform::host()))
814    else {
815        return false;
816    };
817    let dir = crate::models::model_dir(name);
818    if !variant.files.iter().all(|f| dir.join(f.name).exists()) {
819        return false;
820    }
821    for file in variant.files {
822        for b in file.sha256.bytes() {
823            *hash ^= u64::from(b);
824            *hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
825        }
826    }
827    true
828}
829
830/// `0` whenever no media feature is compiled in.
831#[cfg(not(any(
832    feature = "image-ocr",
833    feature = "image-vision",
834    feature = "audio-transcribe"
835)))]
836pub(crate) fn media_env_tag() -> u64 {
837    0
838}
839
840/// Whether `path` is a prose file whose body is worth embedding.
841fn is_prose(path: &str) -> bool {
842    matches!(
843        extension(path).as_deref(),
844        Some("md" | "markdown" | "txt" | "rst" | "adoc")
845    )
846}
847
848/// Trim and cap `text` to [`MAX_CONTENT`] characters (whitespace-collapsed), so
849/// stored content stays small and deterministic.
850fn cap_content(text: &str) -> String {
851    let mut out = String::with_capacity(text.len().min(MAX_CONTENT));
852    // Track the character count incrementally — `out.chars().count()` per
853    // iteration would make this O(n²) on long inputs.
854    let mut chars = 0usize;
855    let mut last_was_space = true;
856    for c in text.chars() {
857        if chars >= MAX_CONTENT {
858            break;
859        }
860        if c.is_whitespace() {
861            if !last_was_space {
862                out.push(' ');
863                chars += 1;
864                last_was_space = true;
865            }
866        } else {
867            out.push(c);
868            chars += 1;
869            last_was_space = false;
870        }
871    }
872    out.trim().to_owned()
873}
874
875/// Fallback extractor: emits a single `file` node per blob, tagged with its blob
876/// hash and basic size metadata. Produces no edges. Used for files with no
877/// registered language.
878#[derive(Debug, Clone, Copy, Default)]
879pub struct FileNodeExtractor;
880
881impl Extractor for FileNodeExtractor {
882    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
883        FactSet::new().with_node(file_node(
884            path,
885            blob_id,
886            bytes,
887            None,
888            IngestConfig::default(),
889        ))
890    }
891}
892
893/// Derived extractor for Rust source, backed by tree-sitter. Emits a `file`
894/// node, one symbol node per `fn`/`struct`/`enum`/`trait`/`mod` (and a few
895/// others) with `defines`/`contains` edges reflecting lexical nesting, and
896/// `imports` edges for `use` declarations. Each function records the (optionally
897/// scope-qualified) names it calls in `meta.calls` for later cross-file
898/// resolution — see [`RustWalk::callee_name`].
899#[derive(Debug, Clone, Copy, Default)]
900pub struct RustExtractor;
901
902impl Extractor for RustExtractor {
903    fn extract(&self, path: &str, blob_id: &str, bytes: &[u8]) -> FactSet {
904        rust_facts(path, blob_id, bytes, IngestConfig::default())
905    }
906}
907
908/// Extract Rust facts, applying `ingest` to the file node's embedded content.
909/// Shared by [`RustExtractor`] (default toggles) and [`Registry`] (its config).
910fn rust_facts(path: &str, blob_id: &str, bytes: &[u8], ingest: IngestConfig) -> FactSet {
911    let mut parser = tree_sitter::Parser::new();
912    // The Rust grammar is compiled in, so this only fails on a version
913    // mismatch — a build-time invariant, not a runtime input error.
914    if parser
915        .set_language(&tree_sitter_rust::LANGUAGE.into())
916        .is_err()
917    {
918        return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
919    }
920    let Some(tree) = parser.parse(bytes, None) else {
921        return FactSet::new().with_node(file_node(path, blob_id, bytes, None, ingest));
922    };
923
924    let mut walk = RustWalk {
925        path,
926        blob_id,
927        src: bytes,
928        nodes: vec![file_node(path, blob_id, bytes, Some("rust"), ingest)],
929        edges: Vec::new(),
930    };
931    let root = tree.root_node();
932    let mut cursor = root.walk();
933    let children: Vec<_> = root.children(&mut cursor).collect();
934    for child in children {
935        walk.visit(child, &[]);
936    }
937
938    // Deterministic ordering so the cached fact set is byte-stable regardless of
939    // traversal incidentals.
940    walk.nodes.sort_by(|a, b| a.key.cmp(&b.key));
941    walk.edges
942        .sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
943    FactSet {
944        nodes: walk.nodes,
945        edges: walk.edges,
946    }
947}
948
949/// One entry on the lexical scope stack: a name segment and, when the scope is
950/// itself an emitted symbol, that symbol's key (impl blocks contribute a segment
951/// but no node, so their `key` is `None`).
952struct Scope {
953    seg: String,
954    key: Option<String>,
955}
956
957/// Accumulating state for a single Rust file walk.
958struct RustWalk<'a> {
959    path: &'a str,
960    blob_id: &'a str,
961    src: &'a [u8],
962    nodes: Vec<Node>,
963    edges: Vec<Edge>,
964}
965
966impl RustWalk<'_> {
967    /// Visit one AST node under the given lexical scope stack.
968    fn visit(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
969        match node.kind() {
970            "function_item" => self.visit_symbol(node, scope, NodeKind::Fn, true),
971            "struct_item" | "union_item" => self.visit_symbol(node, scope, NodeKind::Struct, false),
972            "enum_item" => self.visit_symbol(node, scope, NodeKind::Enum, false),
973            "trait_item" => self.visit_symbol(node, scope, NodeKind::Trait, false),
974            "mod_item" => self.visit_symbol(node, scope, NodeKind::Module, false),
975            "type_item" => self.visit_symbol(node, scope, NodeKind::Other("type".into()), false),
976            "macro_definition" => {
977                self.visit_symbol(node, scope, NodeKind::Other("macro".into()), false);
978            }
979            "impl_item" => self.visit_impl(node, scope),
980            "use_declaration" => self.visit_use(node),
981            // Recurse through unnamed structural wrappers (e.g. the top-level
982            // `declaration_list` of a module handled in `visit_symbol`).
983            _ => self.visit_children(node, scope),
984        }
985    }
986
987    /// Visit every named child of `node` under the same scope.
988    fn visit_children(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
989        let mut cursor = node.walk();
990        let children: Vec<_> = node.named_children(&mut cursor).collect();
991        for child in children {
992            self.visit(child, scope);
993        }
994    }
995
996    /// Emit a symbol node for a named definition, link it to its containing
997    /// scope, and recurse into its body for nested definitions.
998    fn visit_symbol(
999        &mut self,
1000        node: tree_sitter::Node,
1001        scope: &[Scope],
1002        kind: NodeKind,
1003        collect_calls: bool,
1004    ) {
1005        let Some(name) = self.field_text(node, "name") else {
1006            return self.visit_children(node, scope);
1007        };
1008        let qualified = qualify(scope, &name);
1009        let key = format!("sym:rust:{}#{qualified}", self.path);
1010
1011        let mut meta = serde_json::Map::new();
1012        if collect_calls {
1013            let mut calls = Vec::new();
1014            self.collect_calls(node, &mut calls);
1015            calls.sort();
1016            calls.dedup();
1017            if !calls.is_empty() {
1018                meta.insert("calls".into(), serde_json::Value::from(calls));
1019            }
1020        }
1021        // Capture the item's doc-comment so inference embeds what it *means*.
1022        if let Some(doc) = self.doc_comment(node) {
1023            meta.insert("content".into(), serde_json::Value::from(doc));
1024        }
1025        // A struct/union records its NAMED field identifiers in `meta.fields` — the
1026        // signal the config_key→struct follow bridge joins on (a dotted config key's
1027        // leaf, e.g. `serve.addr`'s `addr`, must be a real field of the matched
1028        // struct before we bridge to it). Tuple/unit structs have no named fields
1029        // and add nothing; the key is omitted rather than emitted empty.
1030        if matches!(node.kind(), "struct_item" | "union_item") {
1031            let fields = self.struct_field_names(node);
1032            if !fields.is_empty() {
1033                meta.insert("fields".into(), serde_json::Value::from(fields));
1034            }
1035        }
1036
1037        self.nodes.push(Node {
1038            key: key.clone(),
1039            kind,
1040            name,
1041            path: Some(self.path.to_owned()),
1042            lang: Some("rust".to_owned()),
1043            blob_hash: Some(self.blob_id.to_owned()),
1044            span: Some(span(node)),
1045            provenance: Provenance::Derived,
1046            meta: serde_json::Value::Object(meta),
1047        });
1048        self.link_parent(&key, scope);
1049
1050        // Recurse into the body so nested items (a fn in a mod, etc.) are found,
1051        // pushing this symbol onto the scope stack.
1052        let child_scope = extend(scope, &self.simple(node, "name"), Some(key));
1053        self.recurse_body(node, &child_scope);
1054    }
1055
1056    /// The doc-comment (`///` / `//!` / `/** … */`) immediately preceding `node`,
1057    /// concatenated, or `None`. Attributes between the comment and the item are
1058    /// skipped; a non-doc comment (or any other node) ends the block.
1059    fn doc_comment(&self, node: tree_sitter::Node) -> Option<String> {
1060        let mut parts: Vec<String> = Vec::new();
1061        let mut prev = node.prev_sibling();
1062        while let Some(n) = prev {
1063            match n.kind() {
1064                "line_comment" | "block_comment" => match doc_comment_body(self.text(n)) {
1065                    Some(body) => {
1066                        parts.push(body);
1067                        prev = n.prev_sibling();
1068                    }
1069                    None => break,
1070                },
1071                "attribute_item" => prev = n.prev_sibling(),
1072                _ => break,
1073            }
1074        }
1075        if parts.is_empty() {
1076            return None;
1077        }
1078        parts.reverse();
1079        let joined = cap_content(&parts.join(" "));
1080        (!joined.is_empty()).then_some(joined)
1081    }
1082
1083    /// An `impl` block emits no node but contributes its type name as a scope
1084    /// segment, so methods qualify as `Type::method`.
1085    fn visit_impl(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1086        let type_name = self
1087            .field_text(node, "type")
1088            .unwrap_or_else(|| "impl".to_owned());
1089        let child_scope = extend(scope, &type_name, None);
1090        self.recurse_body(node, &child_scope);
1091    }
1092
1093    /// Record a `use` declaration as an `imports` edge from the file to an
1094    /// import-target node keyed by the (whitespace-normalised) import path.
1095    fn visit_use(&mut self, node: tree_sitter::Node) {
1096        let Some(arg) = node.child_by_field_name("argument") else {
1097            return;
1098        };
1099        let text: String = self
1100            .text(arg)
1101            .chars()
1102            .filter(|c| !c.is_whitespace())
1103            .collect();
1104        if text.is_empty() {
1105            return;
1106        }
1107        let key = format!("import:rust:{text}");
1108        self.nodes.push(Node {
1109            key: key.clone(),
1110            kind: NodeKind::Other("import".into()),
1111            name: text,
1112            path: None,
1113            lang: Some("rust".to_owned()),
1114            blob_hash: None,
1115            span: None,
1116            provenance: Provenance::Derived,
1117            meta: serde_json::Value::Null,
1118        });
1119        self.edges
1120            .push(Edge::derived(file_key(self.path), key, EdgeKind::Imports));
1121    }
1122
1123    /// Link a freshly-emitted symbol to its nearest enclosing emitted scope:
1124    /// `contains` from that symbol, or `defines` from the file at top level.
1125    fn link_parent(&mut self, key: &str, scope: &[Scope]) {
1126        if let Some(parent) = scope.iter().rev().find_map(|s| s.key.as_deref()) {
1127            self.edges.push(Edge::derived(
1128                parent.to_owned(),
1129                key.to_owned(),
1130                EdgeKind::Contains,
1131            ));
1132        } else {
1133            self.edges.push(Edge::derived(
1134                file_key(self.path),
1135                key.to_owned(),
1136                EdgeKind::Defines,
1137            ));
1138        }
1139    }
1140
1141    /// The NAMED field identifiers a struct/union declares, in source order — its
1142    /// `field_declaration_list`'s `field_declaration` names. Tuple structs use an
1143    /// ordered (positional) field list whose entries carry no `name`, so they
1144    /// contribute nothing; a unit struct has no field list at all.
1145    fn struct_field_names(&self, node: tree_sitter::Node) -> Vec<String> {
1146        let mut out = Vec::new();
1147        let mut cursor = node.walk();
1148        for child in node.named_children(&mut cursor) {
1149            if child.kind() == "field_declaration_list" {
1150                let mut inner = child.walk();
1151                for field in child.named_children(&mut inner) {
1152                    if field.kind() == "field_declaration"
1153                        && let Some(name) = field.child_by_field_name("name")
1154                    {
1155                        out.push(self.text(name).to_owned());
1156                    }
1157                }
1158            }
1159        }
1160        out
1161    }
1162
1163    /// Recurse into the `declaration_list` / body of a definition.
1164    fn recurse_body(&mut self, node: tree_sitter::Node, scope: &[Scope]) {
1165        let mut cursor = node.walk();
1166        let children: Vec<_> = node.named_children(&mut cursor).collect();
1167        for child in children {
1168            match child.kind() {
1169                "declaration_list" | "field_declaration_list" | "trait_body" => {
1170                    self.visit_children(child, scope);
1171                }
1172                _ => {}
1173            }
1174        }
1175    }
1176
1177    /// Collect the simple names of functions called anywhere within `node`'s
1178    /// subtree (used for later call resolution).
1179    fn collect_calls(&self, node: tree_sitter::Node, out: &mut Vec<String>) {
1180        let mut cursor = node.walk();
1181        for child in node.named_children(&mut cursor) {
1182            if child.kind() == "call_expression"
1183                && let Some(func) = child.child_by_field_name("function")
1184                && let Some(name) = self.callee_name(func)
1185            {
1186                out.push(name);
1187            }
1188            self.collect_calls(child, out);
1189        }
1190    }
1191
1192    /// A callee descriptor for a `call_expression`'s function child, keeping the
1193    /// *immediate* qualifier when the syntax supplies one so [`crate::sync`] can
1194    /// resolve scope-aware (not just by unique simple name):
1195    /// - `foo()` → `foo` (unqualified)
1196    /// - `a::b::foo()` → `b::foo` (immediate module/type qualifier)
1197    /// - `Type::assoc()` → `Type::assoc`
1198    /// - `self.foo()` / `Self::foo()` → `Self::foo` (a same-impl method call,
1199    ///   resolved via the caller's own type)
1200    /// - `x.foo()` on a non-`self` receiver → `foo` (the receiver's type is
1201    ///   unknown without type inference, so no qualifier is claimed)
1202    fn callee_name(&self, func: tree_sitter::Node) -> Option<String> {
1203        match func.kind() {
1204            "identifier" => Some(self.text(func).to_owned()),
1205            "scoped_identifier" => {
1206                let name = func.child_by_field_name("name")?;
1207                // The immediate qualifier is the last segment of the `path` child
1208                // (`a::b` → `b`), which most closely scopes the call.
1209                let qualifier = func
1210                    .child_by_field_name("path")
1211                    .and_then(|p| self.text(p).rsplit("::").next().map(str::to_owned));
1212                Some(qualify_callee(qualifier.as_deref(), self.text(name)))
1213            }
1214            "field_expression" => {
1215                let name = func.child_by_field_name("field")?;
1216                // A call on the `self` receiver targets a method of the caller's
1217                // own impl type; mark it `Self` so the resolver can bind it.
1218                let on_self = func
1219                    .child_by_field_name("value")
1220                    .is_some_and(|v| self.text(v) == "self");
1221                Some(qualify_callee(on_self.then_some("Self"), self.text(name)))
1222            }
1223            _ => None,
1224        }
1225    }
1226
1227    fn text(&self, node: tree_sitter::Node) -> &str {
1228        node.utf8_text(self.src).unwrap_or("")
1229    }
1230
1231    fn field_text(&self, node: tree_sitter::Node, field: &str) -> Option<String> {
1232        node.child_by_field_name(field)
1233            .map(|n| self.text(n).to_owned())
1234    }
1235
1236    fn simple(&self, node: tree_sitter::Node, field: &str) -> String {
1237        self.field_text(node, field).unwrap_or_default()
1238    }
1239}
1240
1241// ======================= Generic tags-query extraction =======================
1242//
1243// One extractor drives every non-Rust language through its tree-sitter `tags.scm`
1244// query (the `@definition.*` / `@reference.*` capture convention). It emits the
1245// same fact shape as the Rust walker — a `file` node, one symbol node per
1246// definition with `defines`/`contains` edges reflecting byte-range nesting, and
1247// each function's callee simple-names in `meta.calls` — so cross-file (and
1248// cross-language) call resolution in `crate::sync` works uniformly. Where the
1249// language has an import query (`import_query_for`), it also emits `imports`
1250// edges (`file → import` target), as the Rust walker does for `use`. A new
1251// language is a row in `tag_lang_for` (and optionally `import_query_for`), not
1252// new code.
1253
1254/// A language dispatched to the generic tags extractor: its label, grammar, and
1255/// `tags.scm` source (from the grammar crate, or vendored under `src/queries/`).
1256struct TagLang {
1257    /// Canonical label — the node `lang` and the `sym:<lang>:` key namespace.
1258    lang: &'static str,
1259    /// Cache key identifying the *grammar* (not just the label): one `lang` can
1260    /// map to more than one grammar — OCaml `.ml` and `.mli` are both `"ocaml"`
1261    /// but use distinct grammars — so the config cache must key on this, not
1262    /// `lang`, to avoid parsing one grammar's blobs with another's parser.
1263    grammar_key: &'static str,
1264    /// The tree-sitter grammar.
1265    language: tree_sitter::Language,
1266    /// The `tags.scm` query source. Usually borrowed from the grammar crate's
1267    /// const; owned when it is assembled (TypeScript's query `inherits` the
1268    /// JavaScript one, which the crate's `TAGS_QUERY` const does not concatenate).
1269    query: std::borrow::Cow<'static, str>,
1270}
1271
1272/// Resolve a lowercase file extension to its tags-extractor language, or `None`
1273/// when no generic extractor handles it (the caller then falls back to a plain
1274/// file node). Rust is intentionally absent — it keeps its richer AST walker.
1275// A flat extension→grammar dispatch table; length is inherent to the breadth.
1276#[allow(clippy::too_many_lines)]
1277fn tag_lang_for(ext: &str) -> Option<TagLang> {
1278    use std::borrow::Cow;
1279    // TypeScript's tags query `inherits` JavaScript's; the crate const ships only
1280    // the TS-specific supplement, so concatenate the two. The JavaScript patterns
1281    // match against the TypeScript superset grammar.
1282    let ts_query = || -> Cow<'static, str> {
1283        Cow::Owned(format!(
1284            "{}\n{}",
1285            tree_sitter_javascript::TAGS_QUERY,
1286            tree_sitter_typescript::TAGS_QUERY
1287        ))
1288    };
1289    let borrowed = |q: &'static str| -> Cow<'static, str> { Cow::Borrowed(q) };
1290
1291    let (lang, language, query): (&str, tree_sitter::Language, Cow<'static, str>) = match ext {
1292        "py" | "pyi" => (
1293            "python",
1294            tree_sitter_python::LANGUAGE.into(),
1295            borrowed(tree_sitter_python::TAGS_QUERY),
1296        ),
1297        "js" | "jsx" | "mjs" | "cjs" => (
1298            "javascript",
1299            tree_sitter_javascript::LANGUAGE.into(),
1300            borrowed(tree_sitter_javascript::TAGS_QUERY),
1301        ),
1302        "ts" | "mts" | "cts" => (
1303            "typescript",
1304            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1305            ts_query(),
1306        ),
1307        "tsx" => (
1308            "tsx",
1309            tree_sitter_typescript::LANGUAGE_TSX.into(),
1310            ts_query(),
1311        ),
1312        "go" => (
1313            "go",
1314            tree_sitter_go::LANGUAGE.into(),
1315            borrowed(tree_sitter_go::TAGS_QUERY),
1316        ),
1317        "rb" => (
1318            "ruby",
1319            tree_sitter_ruby::LANGUAGE.into(),
1320            borrowed(tree_sitter_ruby::TAGS_QUERY),
1321        ),
1322        "java" => (
1323            "java",
1324            tree_sitter_java::LANGUAGE.into(),
1325            borrowed(tree_sitter_java::TAGS_QUERY),
1326        ),
1327        "c" | "h" => (
1328            "c",
1329            tree_sitter_c::LANGUAGE.into(),
1330            borrowed(tree_sitter_c::TAGS_QUERY),
1331        ),
1332        "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => (
1333            "cpp",
1334            tree_sitter_cpp::LANGUAGE.into(),
1335            borrowed(tree_sitter_cpp::TAGS_QUERY),
1336        ),
1337        // The crate's TAGS_QUERY has a stray `@module` capture that
1338        // `tree-sitter-tags` rejects, so a corrected copy is vendored.
1339        "cs" => (
1340            "csharp",
1341            tree_sitter_c_sharp::LANGUAGE.into(),
1342            borrowed(include_str!("queries/csharp/tags.scm")),
1343        ),
1344        "php" => (
1345            "php",
1346            tree_sitter_php::LANGUAGE_PHP.into(),
1347            borrowed(tree_sitter_php::TAGS_QUERY),
1348        ),
1349        // Scala's crate bundles a tags.scm but exposes no const, so it is vendored.
1350        "scala" | "sc" => (
1351            "scala",
1352            tree_sitter_scala::LANGUAGE.into(),
1353            borrowed(include_str!("queries/scala/tags.scm")),
1354        ),
1355        "ml" => (
1356            "ocaml",
1357            tree_sitter_ocaml::LANGUAGE_OCAML.into(),
1358            borrowed(tree_sitter_ocaml::TAGS_QUERY),
1359        ),
1360        "mli" => (
1361            "ocaml",
1362            tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE.into(),
1363            borrowed(tree_sitter_ocaml::TAGS_QUERY),
1364        ),
1365        "ex" | "exs" => (
1366            "elixir",
1367            tree_sitter_elixir::LANGUAGE.into(),
1368            borrowed(tree_sitter_elixir::TAGS_QUERY),
1369        ),
1370        // Bash ships no tags query at all, so one is vendored.
1371        "sh" | "bash" => (
1372            "bash",
1373            tree_sitter_bash::LANGUAGE.into(),
1374            borrowed(include_str!("queries/bash/tags.scm")),
1375        ),
1376        // SQL (tree-sitter-sequel) ships no tags query, so one is vendored.
1377        "sql" => (
1378            "sql",
1379            tree_sitter_sequel::LANGUAGE.into(),
1380            borrowed(include_str!("queries/sql/tags.scm")),
1381        ),
1382        _ => return None,
1383    };
1384    // Distinguish grammars that share a `lang` label: `.ml` and `.mli` are both
1385    // "ocaml" but parse with different grammars, so they must cache separately.
1386    let grammar_key = match ext {
1387        "mli" => "ocaml-interface",
1388        _ => lang,
1389    };
1390    Some(TagLang {
1391        lang,
1392        grammar_key,
1393        language,
1394        query,
1395    })
1396}
1397
1398/// A compiled tags configuration, shared across the blobs of one language.
1399type TagConfig = std::sync::Arc<tree_sitter_tags::TagsConfiguration>;
1400
1401/// Cache of compiled tags configurations, keyed by [`TagLang::grammar_key`] (not
1402/// the `lang` label, since one label can back multiple grammars). Compiling a
1403/// `tags.scm` query is not free, and `sync` extracts many blobs, so each
1404/// grammar's configuration is built once. A grammar whose query fails to compile
1405/// (a grammar/query mismatch — a build-time invariant, not a runtime input)
1406/// caches `None` so it is not retried per file.
1407static TAG_CONFIGS: std::sync::LazyLock<
1408    std::sync::Mutex<std::collections::HashMap<&'static str, Option<TagConfig>>>,
1409> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1410
1411/// The compiled tags configuration for a language, building and caching it on
1412/// first use. `None` if the query does not compile against the grammar.
1413fn tag_config(def: &TagLang) -> Option<TagConfig> {
1414    let mut cache = TAG_CONFIGS
1415        .lock()
1416        .unwrap_or_else(std::sync::PoisonError::into_inner);
1417    cache
1418        .entry(def.grammar_key)
1419        .or_insert_with(|| {
1420            tree_sitter_tags::TagsConfiguration::new(def.language.clone(), &def.query, "")
1421                .ok()
1422                .map(std::sync::Arc::new)
1423        })
1424        .clone()
1425}
1426
1427/// A per-language tree-sitter query capturing import/include targets as `@path`.
1428/// Run alongside the tags extraction so the generic languages emit `imports`
1429/// edges (`file → import` node) the way the Rust walker does for `use`. `None`
1430/// for a language whose imports we do not yet capture (it simply emits none).
1431///
1432/// Node names are grammar-specific; a query that fails to compile against its
1433/// grammar is cached as absent (see [`import_query`]) rather than retried.
1434fn import_query_for(lang: &str) -> Option<&'static str> {
1435    Some(match lang {
1436        // `import a.b.c`, `import a.b as d`, `from a.b import x`, `from . import x`.
1437        "python" => {
1438            "(import_statement name: (dotted_name) @path)\n\
1439             (import_statement name: (aliased_import name: (dotted_name) @path))\n\
1440             (import_from_statement module_name: (dotted_name) @path)\n\
1441             (import_from_statement module_name: (relative_import) @path)"
1442        }
1443        // `import x from \"mod\"`, `export … from \"mod\"` — the module string.
1444        "javascript" | "typescript" | "tsx" => {
1445            "(import_statement source: (string (string_fragment) @path))\n\
1446             (export_statement source: (string (string_fragment) @path))"
1447        }
1448        // Each spec's quoted path inside an `import ( … )` block or single import.
1449        "go" => "(import_spec path: (interpreted_string_literal) @path)",
1450        // `import a.b.C;` / `import static a.b.C;`.
1451        "java" => {
1452            "(import_declaration (scoped_identifier) @path)\n\
1453             (import_declaration (identifier) @path)"
1454        }
1455        // `#include \"x.h\"` and `#include <x>` (C and, by inheritance, C++).
1456        "c" | "cpp" => {
1457            "(preproc_include path: (string_literal) @path)\n\
1458             (preproc_include path: (system_lib_string) @path)"
1459        }
1460        _ => return None,
1461    })
1462}
1463
1464/// A compiled import query, shared across the blobs of one grammar.
1465type ImportQuery = std::sync::Arc<tree_sitter::Query>;
1466
1467/// Cache of compiled import queries, keyed by [`TagLang::grammar_key`] (as with
1468/// [`TAG_CONFIGS`]). `None` when the language has no import query or it does not
1469/// compile against the grammar, so it is not retried per file.
1470static IMPORT_QUERIES: std::sync::LazyLock<
1471    std::sync::Mutex<std::collections::HashMap<&'static str, Option<ImportQuery>>>,
1472> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1473
1474/// The compiled import query for a language, building and caching it on first use.
1475fn import_query(def: &TagLang) -> Option<ImportQuery> {
1476    let mut cache = IMPORT_QUERIES
1477        .lock()
1478        .unwrap_or_else(std::sync::PoisonError::into_inner);
1479    cache
1480        .entry(def.grammar_key)
1481        .or_insert_with(|| {
1482            let src = import_query_for(def.lang)?;
1483            tree_sitter::Query::new(&def.language, src)
1484                .ok()
1485                .map(std::sync::Arc::new)
1486        })
1487        .clone()
1488}
1489
1490/// Normalise a captured import target to a bare module string: strip surrounding
1491/// quotes (`"…"`), C system-header brackets (`<…>`), and whitespace.
1492fn normalize_import(raw: &str) -> String {
1493    raw.trim()
1494        .trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>')
1495        .trim()
1496        .to_owned()
1497}
1498
1499/// Append `imports` edges for a blob by running its language's import query.
1500/// Emits one `import:<lang>:<module>` node (deduped) and a `file → import`
1501/// `Imports` edge per distinct target, mirroring the Rust walker's `use` handling.
1502fn append_import_facts(
1503    path: &str,
1504    def: &TagLang,
1505    bytes: &[u8],
1506    nodes: &mut Vec<Node>,
1507    edges: &mut Vec<Edge>,
1508) {
1509    use streaming_iterator::StreamingIterator as _;
1510
1511    let Some(query) = import_query(def) else {
1512        return;
1513    };
1514    let mut parser = tree_sitter::Parser::new();
1515    if parser.set_language(&def.language).is_err() {
1516        return;
1517    }
1518    let Some(tree) = parser.parse(bytes, None) else {
1519        return;
1520    };
1521    let mut cursor = tree_sitter::QueryCursor::new();
1522    let mut seen = std::collections::BTreeSet::new();
1523    let mut matches = cursor.matches(&query, tree.root_node(), bytes);
1524    while let Some(m) = matches.next() {
1525        for cap in m.captures {
1526            let Ok(raw) = cap.node.utf8_text(bytes) else {
1527                continue;
1528            };
1529            let module = normalize_import(raw);
1530            if module.is_empty() {
1531                continue;
1532            }
1533            let key = format!("import:{}:{module}", def.lang);
1534            if seen.insert(key.clone()) {
1535                nodes.push(Node {
1536                    key: key.clone(),
1537                    kind: NodeKind::Other("import".into()),
1538                    name: module,
1539                    // The import *target* is not owned by any one file (its key is
1540                    // global): leave `path` unset, as the Rust walker does, so two
1541                    // files importing the same module dedup to one stable node.
1542                    path: None,
1543                    lang: Some(def.lang.to_owned()),
1544                    blob_hash: None,
1545                    span: None,
1546                    provenance: Provenance::Derived,
1547                    meta: serde_json::Value::Null,
1548                });
1549                edges.push(Edge::derived(file_key(path), key, EdgeKind::Imports));
1550            }
1551        }
1552    }
1553}
1554
1555/// Map a `tags.scm` syntax type (the tail of a `@definition.X` capture) to a
1556/// graph node kind. Unrecognised kinds are kept verbatim under `Other`.
1557fn tag_node_kind(syntax_type: &str) -> NodeKind {
1558    match syntax_type {
1559        "function" | "method" | "constructor" => NodeKind::Fn,
1560        "class" | "struct" => NodeKind::Struct,
1561        "interface" | "trait" | "protocol" => NodeKind::Trait,
1562        "enum" => NodeKind::Enum,
1563        // A Scala/Kotlin `object` is a singleton namespace; group it with modules.
1564        "module" | "namespace" | "object" => NodeKind::Module,
1565        other => NodeKind::Other(other.to_owned()),
1566    }
1567}
1568
1569/// A definition captured from a `tags.scm` run, before nesting is resolved.
1570struct TagDef {
1571    name: String,
1572    kind: NodeKind,
1573    range: std::ops::Range<usize>,
1574    docs: Option<String>,
1575}
1576
1577/// Extract facts from a source blob via its language's tags query. Returns `None`
1578/// when the extension has no generic extractor or the query cannot compile, so
1579/// the caller falls back to a plain file node.
1580fn tag_facts(
1581    path: &str,
1582    blob_id: &str,
1583    bytes: &[u8],
1584    ext: &str,
1585    ingest: IngestConfig,
1586) -> Option<FactSet> {
1587    let def = tag_lang_for(ext)?;
1588    let lang = def.lang;
1589    let config = tag_config(&def)?;
1590
1591    let mut ctx = tree_sitter_tags::TagsContext::new();
1592    let (tags, _had_error) = ctx.generate_tags(&config, bytes, None).ok()?;
1593
1594    let mut defs: Vec<TagDef> = Vec::new();
1595    // Call references, as (byte offset of the call, callee simple-name), attached
1596    // later to whichever function definition encloses them.
1597    let mut calls: Vec<(usize, String)> = Vec::new();
1598    for tag in tags {
1599        let Ok(tag) = tag else { continue };
1600        let Some(name) = bytes
1601            .get(tag.name_range.clone())
1602            .and_then(|b| std::str::from_utf8(b).ok())
1603        else {
1604            continue;
1605        };
1606        let syntax = config.syntax_type_name(tag.syntax_type_id);
1607        if tag.is_definition {
1608            defs.push(TagDef {
1609                name: name.to_owned(),
1610                kind: tag_node_kind(syntax),
1611                range: tag.range.clone(),
1612                // The tags machinery already resolves a definition's doc comment.
1613                docs: tag.docs.clone(),
1614            });
1615        } else if syntax == "call" || syntax == "send" {
1616            // `send` is Ruby's message-send; both mean "invokes a name".
1617            calls.push((tag.range.start, name.to_owned()));
1618        }
1619    }
1620
1621    // Resolve nesting purely by byte-range containment: a definition's parent is
1622    // the smallest other definition whose range strictly encloses it. This yields
1623    // `contains` edges (parent→child) and qualified, collision-resistant keys
1624    // without any language-specific scope rules.
1625    let parents: Vec<Option<usize>> = (0..defs.len())
1626        .map(|i| smallest_enclosing(&defs, defs[i].range.clone(), Some(i)))
1627        .collect();
1628
1629    let keys: Vec<String> = (0..defs.len())
1630        .map(|i| {
1631            let qualified = qualified_name(&defs, &parents, i);
1632            format!("sym:{lang}:{path}#{qualified}")
1633        })
1634        .collect();
1635
1636    let mut nodes = vec![file_node(path, blob_id, bytes, Some(lang), ingest)];
1637    let mut edges: Vec<Edge> = Vec::new();
1638
1639    for (i, d) in defs.iter().enumerate() {
1640        let mut meta = serde_json::Map::new();
1641        if let Some(doc) = &d.docs {
1642            let content = cap_content(doc);
1643            if !content.is_empty() {
1644                meta.insert("content".into(), serde_json::Value::from(content));
1645            }
1646        }
1647        // Attach the calls this definition encloses — but only for functions, the
1648        // only kind `crate::sync::resolve_calls` links.
1649        if d.kind == NodeKind::Fn {
1650            let mut names: Vec<String> = calls
1651                .iter()
1652                .filter(|(off, _)| d.range.contains(off))
1653                .filter(|(off, _)| smallest_enclosing_off(&defs, *off) == Some(i))
1654                .map(|(_, name)| name.clone())
1655                .collect();
1656            names.sort();
1657            names.dedup();
1658            if !names.is_empty() {
1659                meta.insert("calls".into(), serde_json::Value::from(names));
1660            }
1661        }
1662
1663        let start = u32::try_from(d.range.start).unwrap_or(u32::MAX);
1664        let end = u32::try_from(d.range.end).unwrap_or(u32::MAX);
1665        nodes.push(Node {
1666            key: keys[i].clone(),
1667            kind: d.kind.clone(),
1668            name: d.name.clone(),
1669            path: Some(path.to_owned()),
1670            lang: Some(lang.to_owned()),
1671            blob_hash: Some(blob_id.to_owned()),
1672            span: Some(Span::new(start, end)),
1673            provenance: Provenance::Derived,
1674            meta: serde_json::Value::Object(meta),
1675        });
1676
1677        match parents[i] {
1678            Some(p) => edges.push(Edge::derived(
1679                keys[p].clone(),
1680                keys[i].clone(),
1681                EdgeKind::Contains,
1682            )),
1683            None => edges.push(Edge::derived(
1684                file_key(path),
1685                keys[i].clone(),
1686                EdgeKind::Defines,
1687            )),
1688        }
1689    }
1690
1691    // Import/include edges (file → import target), where the language has a query.
1692    append_import_facts(path, &def, bytes, &mut nodes, &mut edges);
1693
1694    // Deterministic, duplicate-free output (two query patterns can capture the
1695    // same definition, and distinct symbols can share a qualified name).
1696    nodes.sort_by(|a, b| a.key.cmp(&b.key));
1697    nodes.dedup_by(|a, b| a.key == b.key);
1698    edges.sort_by(|a, b| (a.kind.as_str(), &a.src, &a.dst).cmp(&(b.kind.as_str(), &b.src, &b.dst)));
1699    edges.dedup();
1700    Some(FactSet { nodes, edges })
1701}
1702
1703/// Index of the smallest definition (other than `skip`) whose range strictly
1704/// encloses `range`, or `None` if `range` is top-level.
1705fn smallest_enclosing(
1706    defs: &[TagDef],
1707    range: std::ops::Range<usize>,
1708    skip: Option<usize>,
1709) -> Option<usize> {
1710    let mut best: Option<usize> = None;
1711    for (j, c) in defs.iter().enumerate() {
1712        if Some(j) == skip {
1713            continue;
1714        }
1715        // Strictly encloses: contains both ends and is a larger span.
1716        let encloses = c.range.start <= range.start
1717            && c.range.end >= range.end
1718            && (c.range.end - c.range.start) > (range.end - range.start);
1719        if encloses
1720            && best.is_none_or(|b| {
1721                defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
1722            })
1723        {
1724            best = Some(j);
1725        }
1726    }
1727    best
1728}
1729
1730/// Index of the smallest definition enclosing byte offset `off`.
1731fn smallest_enclosing_off(defs: &[TagDef], off: usize) -> Option<usize> {
1732    let mut best: Option<usize> = None;
1733    for (j, c) in defs.iter().enumerate() {
1734        if c.range.contains(&off)
1735            && best.is_none_or(|b| {
1736                defs[b].range.end - defs[b].range.start > c.range.end - c.range.start
1737            })
1738        {
1739            best = Some(j);
1740        }
1741    }
1742    best
1743}
1744
1745/// A definition's qualified name: its ancestors' names (root→leaf) joined to its
1746/// own by `::`, so nested symbols get distinct, stable keys.
1747fn qualified_name(defs: &[TagDef], parents: &[Option<usize>], i: usize) -> String {
1748    let mut chain: Vec<&str> = vec![defs[i].name.as_str()];
1749    let mut cur = parents[i];
1750    // Bound the walk by the number of definitions — parents form a DAG toward
1751    // smaller-or-equal spans, but guard against any pathological cycle.
1752    let mut guard = defs.len();
1753    while let Some(p) = cur {
1754        if guard == 0 {
1755            break;
1756        }
1757        guard -= 1;
1758        chain.push(defs[p].name.as_str());
1759        cur = parents[p];
1760    }
1761    chain.reverse();
1762    chain.join("::")
1763}
1764
1765/// Byte span of an AST node, clamped to `u32`.
1766fn span(node: tree_sitter::Node) -> Span {
1767    let start = u32::try_from(node.start_byte()).unwrap_or(u32::MAX);
1768    let end = u32::try_from(node.end_byte()).unwrap_or(u32::MAX);
1769    Span::new(start, end)
1770}
1771
1772/// Qualified name for a new symbol: all enclosing scope segments plus `name`.
1773fn qualify(scope: &[Scope], name: &str) -> String {
1774    let mut parts: Vec<&str> = scope.iter().map(|s| s.seg.as_str()).collect();
1775    parts.push(name);
1776    parts.join("::")
1777}
1778
1779/// Combine an optional immediate qualifier with a callee `name` into the stored
1780/// `meta.calls` descriptor. Path-relative qualifiers (`self`/`crate`/`super`) and
1781/// an empty qualifier collapse to the bare name, since they don't scope a
1782/// cross-file target; `Self` is preserved as the marker for a same-impl call.
1783fn qualify_callee(qualifier: Option<&str>, name: &str) -> String {
1784    match qualifier {
1785        Some(q) if !q.is_empty() && !matches!(q, "self" | "crate" | "super") => {
1786            format!("{q}::{name}")
1787        }
1788        _ => name.to_owned(),
1789    }
1790}
1791
1792/// Push a scope entry, returning the extended stack.
1793fn extend(scope: &[Scope], seg: &str, key: Option<String>) -> Vec<Scope> {
1794    let mut next: Vec<Scope> = scope
1795        .iter()
1796        .map(|s| Scope {
1797            seg: s.seg.clone(),
1798            key: s.key.clone(),
1799        })
1800        .collect();
1801    next.push(Scope {
1802        seg: seg.to_owned(),
1803        key,
1804    });
1805    next
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810    use super::{Extractor, FileNodeExtractor, Registry, RustExtractor};
1811    use crate::{EdgeKind, Node, NodeKind};
1812
1813    #[test]
1814    fn file_node_extractor_is_deterministic_and_tagged() {
1815        let ex = FileNodeExtractor;
1816        let a = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
1817        let b = ex.extract("src/lib.rs", "abc123", b"one\ntwo\n");
1818        assert_eq!(a, b, "extraction must be deterministic");
1819
1820        assert_eq!(a.nodes.len(), 1);
1821        assert!(a.edges.is_empty());
1822        let node = &a.nodes[0];
1823        assert_eq!(node.key, "file:src/lib.rs");
1824        assert_eq!(node.kind, NodeKind::File);
1825        assert_eq!(node.name, "lib.rs");
1826        assert_eq!(node.blob_hash.as_deref(), Some("abc123"));
1827        assert_eq!(node.meta["lines"], 2);
1828        assert_eq!(node.meta["bytes"], 8);
1829    }
1830
1831    #[test]
1832    fn config_files_emit_config_key_nodes() {
1833        let reg = Registry::new(crate::IngestConfig::default());
1834        let toml = b"[serve]\naddr = \"0.0.0.0:8443\"\ntools = false\n";
1835        let a = reg.extract("config.toml", "cfg1", toml);
1836        let b = reg.extract("config.toml", "cfg1", toml);
1837        assert_eq!(a, b, "config extraction must be deterministic");
1838
1839        // The file node plus a config_key node per leaf.
1840        assert!(a.nodes.iter().any(|n| n.key == "file:config.toml"));
1841        let addr = a
1842            .nodes
1843            .iter()
1844            .find(|n| n.key == "cfgkey:config.toml#serve.addr")
1845            .expect("serve.addr config_key node");
1846        assert_eq!(addr.kind, NodeKind::Other("config_key".into()));
1847        assert_eq!(addr.name, "serve.addr");
1848        assert_eq!(addr.meta["value"], "0.0.0.0:8443"); // unquoted
1849        // A `contains` edge from the file to each config key.
1850        assert!(a.edges.iter().any(|e| {
1851            e.src == "file:config.toml"
1852                && e.dst == "cfgkey:config.toml#serve.addr"
1853                && e.kind == EdgeKind::Contains
1854        }));
1855
1856        // A `.env` (no extension) is recognised by name; a repeated key yields one
1857        // node with the last value; a secret value is redacted.
1858        let env = reg.extract(".env", "env1", b"PORT=8080\nPORT=9090\nAPI_TOKEN=s3cr3t\n");
1859        let port = env
1860            .nodes
1861            .iter()
1862            .find(|n| n.key == "cfgkey:.env#PORT")
1863            .expect("PORT node");
1864        assert_eq!(port.meta["value"], "9090", "dotenv last-one-wins");
1865        assert_eq!(
1866            env.nodes
1867                .iter()
1868                .filter(|n| n.key == "cfgkey:.env#PORT")
1869                .count(),
1870            1
1871        );
1872        let token = env
1873            .nodes
1874            .iter()
1875            .find(|n| n.key == "cfgkey:.env#API_TOKEN")
1876            .expect("API_TOKEN node");
1877        assert_eq!(token.meta["value"], "<redacted>", "secret not persisted");
1878        // A source file is unaffected.
1879        let rs = reg.extract("src/lib.rs", "x", b"pub fn f() {}\n");
1880        assert!(
1881            rs.nodes
1882                .iter()
1883                .all(|n| n.kind != NodeKind::Other("config_key".into()))
1884        );
1885    }
1886
1887    #[test]
1888    fn dockerfile_emits_image_ref_nodes_and_skips_internal_stages() {
1889        let reg = Registry::new(crate::IngestConfig::default());
1890        // Multi-stage: a builder stage (external), an internal `FROM builder`
1891        // (skipped), and a runtime external base pinned by digest.
1892        let df = b"FROM --platform=linux/amd64 rust:1.90 AS builder\nRUN cargo build\n\
1893                   FROM builder AS test\nFROM registry.io/app:1.2@sha256:abc AS run\nFROM scratch\n";
1894        let a = reg.extract("Dockerfile", "d1", df);
1895        let b = reg.extract("Dockerfile", "d1", df);
1896        assert_eq!(a, b, "dockerfile extraction must be deterministic");
1897
1898        let refs: Vec<&Node> = a
1899            .nodes
1900            .iter()
1901            .filter(|n| n.kind == NodeKind::Other("image_ref".into()))
1902            .collect();
1903        // Two external images: rust:1.90 and the app digest. `FROM builder` and
1904        // `FROM scratch` are not pins.
1905        assert_eq!(refs.len(), 2, "got: {refs:?}");
1906        let rust = refs
1907            .iter()
1908            .find(|n| n.meta["image"] == "rust")
1909            .expect("rust");
1910        assert_eq!(rust.meta["tag"], "1.90");
1911        let app = refs
1912            .iter()
1913            .find(|n| n.meta["image"] == "registry.io/app:1.2")
1914            .expect("app digest");
1915        assert_eq!(app.meta["digest"], "sha256:abc");
1916        // A `references` edge from the file to each image_ref.
1917        assert!(
1918            a.edges
1919                .iter()
1920                .any(|e| { e.src == "file:Dockerfile" && e.kind == EdgeKind::References })
1921        );
1922        // `Dockerfile.prod` is recognised too; a plain source file is not.
1923        assert!(
1924            reg.extract("Dockerfile.prod", "d2", b"FROM alpine:3\n")
1925                .nodes
1926                .iter()
1927                .any(|n| n.kind == NodeKind::Other("image_ref".into()))
1928        );
1929
1930        // A stage alias equal to the image name (`FROM alpine AS alpine`) must not
1931        // make the external `alpine` look like an internal stage — it is still a pin.
1932        let c = reg.extract("Dockerfile", "d3", b"FROM alpine AS alpine\n");
1933        assert!(
1934            c.nodes
1935                .iter()
1936                .any(|n| n.kind == NodeKind::Other("image_ref".into())
1937                    && n.meta["image"] == "alpine"),
1938            "FROM x AS x is an external pin, got: {:?}",
1939            c.nodes
1940        );
1941    }
1942
1943    const SAMPLE: &str = r"
1944use std::path::Path;
1945
1946pub struct Store;
1947
1948impl Store {
1949    pub fn open() -> Store {
1950        helper();
1951        Store
1952    }
1953}
1954
1955fn helper() {}
1956
1957mod inner {
1958    pub fn nested() {}
1959}
1960";
1961
1962    fn keys(fs: &crate::FactSet) -> Vec<String> {
1963        let mut k: Vec<_> = fs.nodes.iter().map(|n| n.key.clone()).collect();
1964        k.sort();
1965        k
1966    }
1967
1968    #[test]
1969    fn rust_extractor_emits_symbols_and_edges() {
1970        let fs = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
1971        let ks = keys(&fs);
1972        assert!(ks.contains(&"file:src/lib.rs".to_owned()));
1973        assert!(ks.contains(&"sym:rust:src/lib.rs#Store".to_owned()));
1974        assert!(ks.contains(&"sym:rust:src/lib.rs#Store::open".to_owned()));
1975        assert!(ks.contains(&"sym:rust:src/lib.rs#helper".to_owned()));
1976        assert!(ks.contains(&"sym:rust:src/lib.rs#inner".to_owned()));
1977        assert!(ks.contains(&"sym:rust:src/lib.rs#inner::nested".to_owned()));
1978
1979        // `open` records that it calls `helper`.
1980        let open = fs
1981            .nodes
1982            .iter()
1983            .find(|n| n.key == "sym:rust:src/lib.rs#Store::open")
1984            .expect("open node");
1985        assert_eq!(open.meta["calls"], serde_json::json!(["helper"]));
1986
1987        // file defines top-level items; a module contains its nested fn.
1988        let defines: Vec<_> = fs
1989            .edges
1990            .iter()
1991            .filter(|e| e.kind == EdgeKind::Defines && e.dst == "sym:rust:src/lib.rs#helper")
1992            .collect();
1993        assert_eq!(defines.len(), 1);
1994        assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Contains
1995            && e.src == "sym:rust:src/lib.rs#inner"
1996            && e.dst == "sym:rust:src/lib.rs#inner::nested"));
1997
1998        // the `use` becomes an imports edge.
1999        assert!(fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2000            && e.src == "file:src/lib.rs"
2001            && e.dst == "import:rust:std::path::Path"));
2002    }
2003
2004    #[test]
2005    fn rust_extractor_records_struct_field_names() {
2006        // A struct with named fields records them in `meta.fields` (the follow
2007        // bridge's join signal); a tuple struct and a unit struct carry none.
2008        let src = "pub struct ServeConfig {\n\
2009                   \x20   pub addr: Option<String>,\n\
2010                   \x20   pub tls_cert: Option<String>,\n\
2011                   }\n\
2012                   pub struct Pair(u8, u8);\n\
2013                   pub struct Marker;\n";
2014        let fs = RustExtractor.extract("src/config.rs", "b", src.as_bytes());
2015        let fields = |key: &str| {
2016            fs.nodes
2017                .iter()
2018                .find(|n| n.key == key)
2019                .and_then(|n| n.meta.get("fields").cloned())
2020        };
2021        assert_eq!(
2022            fields("sym:rust:src/config.rs#ServeConfig"),
2023            Some(serde_json::json!(["addr", "tls_cert"])),
2024            "named fields captured in source order"
2025        );
2026        // Positional (tuple) and unit structs declare no named fields → no key.
2027        assert_eq!(fields("sym:rust:src/config.rs#Pair"), None);
2028        assert_eq!(fields("sym:rust:src/config.rs#Marker"), None);
2029    }
2030
2031    #[test]
2032    fn rust_extraction_is_deterministic() {
2033        let a = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2034        let b = RustExtractor.extract("src/lib.rs", "blob1", SAMPLE.as_bytes());
2035        assert_eq!(a, b);
2036    }
2037
2038    #[test]
2039    fn rust_extractor_captures_doc_comments() {
2040        let src = "/// The central store.\n\
2041                   pub struct Store;\n\n\
2042                   /// Opens it.\n\
2043                   /// Reads the config.\n\
2044                   pub fn open() {}\n\n\
2045                   // not a doc comment\n\
2046                   pub fn plain() {}\n";
2047        let fs = RustExtractor.extract("src/lib.rs", "b", src.as_bytes());
2048        let content = |key: &str| {
2049            fs.nodes
2050                .iter()
2051                .find(|n| n.key == key)
2052                .and_then(|n| n.meta.get("content"))
2053                .and_then(|v| v.as_str())
2054                .map(ToOwned::to_owned)
2055        };
2056        assert_eq!(
2057            content("sym:rust:src/lib.rs#Store").as_deref(),
2058            Some("The central store.")
2059        );
2060        assert_eq!(
2061            content("sym:rust:src/lib.rs#open").as_deref(),
2062            Some("Opens it. Reads the config.")
2063        );
2064        // A plain `//` comment is not captured.
2065        assert_eq!(content("sym:rust:src/lib.rs#plain"), None);
2066    }
2067
2068    #[test]
2069    fn prose_file_captures_capped_body() {
2070        let md = FileNodeExtractor.extract("docs/x.md", "b", b"# Title\n\nSome prose   here.\n");
2071        assert_eq!(md.nodes[0].meta["content"], "# Title Some prose here.");
2072        // A non-prose file gets no content.
2073        let rs = FileNodeExtractor.extract("notes.bin", "b", b"\x00\x01binary");
2074        assert!(rs.nodes[0].meta.get("content").is_none());
2075        // Extension matching is case-insensitive: `README.MD` is prose too.
2076        let upper = FileNodeExtractor.extract("README.MD", "b", b"# Hi\n");
2077        assert_eq!(upper.nodes[0].meta["content"], "# Hi");
2078    }
2079
2080    /// Build a one-page PDF with a single Helvetica text run, computing exact
2081    /// byte offsets for the xref table so `pdf-extract` can parse it.
2082    #[cfg(feature = "pdf-text")]
2083    fn minimal_pdf(text: &str) -> Vec<u8> {
2084        let content = format!("BT /F1 24 Tf 72 720 Td ({text}) Tj ET");
2085        let objects = [
2086            "<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
2087            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
2088            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_owned(),
2089            format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
2090            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(),
2091        ];
2092        let mut pdf = Vec::new();
2093        pdf.extend_from_slice(b"%PDF-1.4\n");
2094        let mut offsets = Vec::new();
2095        for (i, obj) in objects.iter().enumerate() {
2096            offsets.push(pdf.len());
2097            pdf.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
2098        }
2099        let xref_start = pdf.len();
2100        pdf.extend_from_slice(
2101            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
2102        );
2103        for off in &offsets {
2104            pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2105        }
2106        pdf.extend_from_slice(
2107            format!(
2108                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n",
2109                objects.len() + 1
2110            )
2111            .as_bytes(),
2112        );
2113        pdf
2114    }
2115
2116    #[cfg(feature = "pdf-text")]
2117    #[test]
2118    fn pdf_file_captures_text_content() {
2119        let pdf = minimal_pdf("Hello Roteiro");
2120        let facts = FileNodeExtractor.extract("docs/guide.pdf", "b", &pdf);
2121        let content = facts.nodes[0].meta["content"].as_str().unwrap();
2122        assert!(content.contains("Hello Roteiro"), "got: {content:?}");
2123        // Extension matching is case-insensitive: `Guide.PDF` extracts too.
2124        let upper = FileNodeExtractor.extract("docs/Guide.PDF", "b", &pdf);
2125        assert!(upper.nodes[0].meta.get("content").is_some());
2126        // A malformed PDF degrades to a plain file node — no panic, no content.
2127        let bad = FileNodeExtractor.extract("docs/bad.pdf", "b", b"%PDF-1.4\ngarbage");
2128        assert!(bad.nodes[0].meta.get("content").is_none());
2129    }
2130
2131    #[cfg(any(feature = "image-ocr", feature = "image-vision"))]
2132    #[test]
2133    fn image_content_guards_before_touching_models() {
2134        // Case-insensitive image detection.
2135        assert!(super::is_image("shot.PNG"));
2136        assert!(super::is_image("b.jpeg"));
2137        assert!(super::is_image("c.jpg"));
2138        assert!(!super::is_image("d.gif"));
2139        // A non-image path returns None without ever looking for models.
2140        assert!(
2141            super::image_content("notes.txt", b"hello", super::IngestConfig::default()).is_none()
2142        );
2143        // An oversized image is rejected by the size guard, before model lookup.
2144        let big = vec![0u8; super::MAX_IMAGE_BYTES + 1];
2145        assert!(super::image_content("shot.png", &big, super::IngestConfig::default()).is_none());
2146    }
2147
2148    #[test]
2149    fn doc_comment_body_recognises_doc_markers() {
2150        assert_eq!(super::doc_comment_body("/// hi").as_deref(), Some("hi"));
2151        assert_eq!(
2152            super::doc_comment_body("//! mod doc").as_deref(),
2153            Some("mod doc")
2154        );
2155        assert_eq!(
2156            super::doc_comment_body("/** block */").as_deref(),
2157            Some("block")
2158        );
2159        // Plain and `////` comments are not docs.
2160        assert_eq!(super::doc_comment_body("// plain"), None);
2161        assert_eq!(super::doc_comment_body("//// header"), None);
2162        // Degenerate block comments have an empty body, never garbage like "/".
2163        assert_eq!(super::doc_comment_body("/**/").as_deref(), Some(""));
2164        assert_eq!(super::doc_comment_body("/*!*/").as_deref(), Some(""));
2165    }
2166
2167    #[test]
2168    fn registry_dispatches_by_extension() {
2169        let rs = Registry::default().extract("src/lib.rs", "b", SAMPLE.as_bytes());
2170        assert!(rs.nodes.len() > 1, "rust file yields symbols");
2171        let txt = Registry::default().extract("notes.txt", "b", b"hello\n");
2172        assert_eq!(
2173            txt.nodes.len(),
2174            1,
2175            "non-code file falls back to a file node"
2176        );
2177        assert_eq!(txt.nodes[0].kind, NodeKind::File);
2178    }
2179
2180    #[test]
2181    fn tags_extracts_python_symbols_calls_and_nesting() {
2182        let src = "def helper():\n    pass\n\nclass Thing:\n    def run(self):\n        helper()\n";
2183        let fs = Registry::default().extract("app.py", "b", src.as_bytes());
2184
2185        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2186        assert!(names.contains(&"helper"), "top-level function");
2187        assert!(names.contains(&"Thing"), "class");
2188        assert!(names.contains(&"run"), "method");
2189
2190        // Every symbol is language-tagged.
2191        assert_eq!(
2192            fs.nodes
2193                .iter()
2194                .find(|n| n.name == "helper")
2195                .and_then(|n| n.lang.as_deref()),
2196            Some("python")
2197        );
2198
2199        // The method is nested in the class: a `contains` edge to `Thing::run`.
2200        assert!(
2201            fs.edges
2202                .iter()
2203                .any(|e| e.kind == EdgeKind::Contains && e.dst.ends_with("#Thing::run")),
2204            "method nested under class via containment"
2205        );
2206
2207        // The method's body calls `helper`, recorded for later resolution.
2208        let run = fs.nodes.iter().find(|n| n.name == "run").unwrap();
2209        let calls = run.meta.get("calls").and_then(|v| v.as_array()).unwrap();
2210        assert!(
2211            calls.iter().any(|c| c.as_str() == Some("helper")),
2212            "enclosed call captured in meta.calls"
2213        );
2214    }
2215
2216    #[test]
2217    fn tags_extraction_is_deterministic() {
2218        let src = b"package main\nfunc Add(a int) int { return a }\n";
2219        let a = Registry::default().extract("m.go", "b", src);
2220        let b = Registry::default().extract("m.go", "b", src);
2221        assert_eq!(a, b, "tags extraction must be deterministic");
2222        assert!(
2223            a.nodes
2224                .iter()
2225                .any(|n| n.name == "Add" && n.kind == NodeKind::Fn)
2226        );
2227    }
2228
2229    #[test]
2230    fn tags_extracts_typescript() {
2231        let ts = Registry::default().extract("svc.ts", "b", b"export class Svc {\n  run() {}\n}\n");
2232        assert!(ts.nodes.iter().any(|n| n.name == "Svc"), "class");
2233        assert!(ts.nodes.iter().any(|n| n.name == "run"), "method");
2234        assert_eq!(
2235            ts.nodes
2236                .iter()
2237                .find(|n| n.name == "Svc")
2238                .and_then(|n| n.lang.as_deref()),
2239            Some("typescript")
2240        );
2241    }
2242
2243    // Extract `src` as `path` and collect the `import:<…>` targets it emits.
2244    // Every import node's key is global, so — like the Rust walker's — it must
2245    // carry no `path`, keeping the node stable when several files import it.
2246    fn import_targets(path: &str, src: &[u8]) -> Vec<String> {
2247        Registry::default()
2248            .extract(path, "b", src)
2249            .nodes
2250            .iter()
2251            .filter(|n| n.kind == NodeKind::Other("import".into()))
2252            .inspect(|n| {
2253                assert!(
2254                    n.path.is_none(),
2255                    "import node must not be file-scoped: {}",
2256                    n.key
2257                );
2258            })
2259            .map(|n| n.key.clone())
2260            .collect()
2261    }
2262
2263    #[test]
2264    fn extracts_imports_edges_per_language() {
2265        // Each case: a file with import statements → the expected `import:` nodes,
2266        // plus a `file → import` Imports edge.
2267        let cases: &[(&str, &[u8], &[&str])] = &[
2268            (
2269                "app.py",
2270                b"import os\nfrom a.b import c\nimport x.y as z\n",
2271                &["import:python:os", "import:python:a.b", "import:python:x.y"],
2272            ),
2273            (
2274                "m.js",
2275                b"import foo from \"./mod.js\";\nexport { y } from \"./y.js\";\n",
2276                &["import:javascript:./mod.js", "import:javascript:./y.js"],
2277            ),
2278            (
2279                "svc.ts",
2280                b"import { A } from \"./a\";\n",
2281                &["import:typescript:./a"],
2282            ),
2283            (
2284                "m.go",
2285                b"package main\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n",
2286                &["import:go:fmt", "import:go:os"],
2287            ),
2288            (
2289                "M.java",
2290                b"import java.util.List;\nimport static a.B.c;\n",
2291                &["import:java:java.util.List", "import:java:a.B.c"],
2292            ),
2293            (
2294                "m.c",
2295                b"#include <stdio.h>\n#include \"local.h\"\n",
2296                &["import:c:stdio.h", "import:c:local.h"],
2297            ),
2298            ("m.cpp", b"#include <vector>\n", &["import:cpp:vector"]),
2299        ];
2300        for (path, src, expected) in cases {
2301            let got = import_targets(path, src);
2302            for want in *expected {
2303                assert!(
2304                    got.iter().any(|k| k == want),
2305                    "{path}: expected import node {want}, got {got:?}"
2306                );
2307            }
2308            // The corresponding file → import edge is derived.
2309            let fs = Registry::default().extract(path, "b", src);
2310            for want in *expected {
2311                assert!(
2312                    fs.edges.iter().any(|e| e.kind == EdgeKind::Imports
2313                        && e.src == format!("file:{path}")
2314                        && &e.dst == want),
2315                    "{path}: expected Imports edge to {want}"
2316                );
2317            }
2318        }
2319    }
2320
2321    #[test]
2322    fn every_registered_language_query_compiles() {
2323        // A grammar/query mismatch (e.g. a future grammar bump) would make a
2324        // language silently fall back to a plain file node; assert each query
2325        // compiles against its grammar so that regression surfaces here instead.
2326        for ext in [
2327            "py", "js", "ts", "tsx", "go", "rb", "java", "c", "cpp", "cs", "php", "scala", "ml",
2328            "mli", "ex", "sh", "sql",
2329        ] {
2330            let def = super::tag_lang_for(ext).unwrap_or_else(|| panic!("no language for .{ext}"));
2331            let lang = def.lang;
2332            assert!(
2333                super::tag_config(&def).is_some(),
2334                "tags query for .{ext} ({lang}) must compile against its grammar"
2335            );
2336        }
2337    }
2338
2339    #[test]
2340    fn ocaml_impl_and_interface_cache_under_distinct_grammars() {
2341        // `.ml` and `.mli` share the `ocaml` label but use different grammars, so
2342        // their config-cache keys must differ or one would parse with the other's
2343        // grammar (see the config cache keyed on `grammar_key`, not `lang`).
2344        let ml = super::tag_lang_for("ml").unwrap();
2345        let mli = super::tag_lang_for("mli").unwrap();
2346        assert_eq!(ml.lang, "ocaml");
2347        assert_eq!(mli.lang, "ocaml");
2348        assert_ne!(
2349            ml.grammar_key, mli.grammar_key,
2350            "distinct grammars must cache separately"
2351        );
2352    }
2353
2354    #[test]
2355    fn tags_extracts_vendored_bash_query() {
2356        let src = "greet() {\n  echo hi\n}\nmain() {\n  greet\n}\n";
2357        let fs = Registry::default().extract("run.sh", "b", src.as_bytes());
2358        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2359        assert!(names.contains(&"greet"), "shell function greet");
2360        assert!(names.contains(&"main"), "shell function main");
2361
2362        // `main` invokes `greet` — a command reference captured as a call.
2363        let main = fs.nodes.iter().find(|n| n.name == "main").unwrap();
2364        assert!(
2365            main.meta
2366                .get("calls")
2367                .and_then(|v| v.as_array())
2368                .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("greet"))),
2369            "internal command invocation captured"
2370        );
2371    }
2372
2373    #[test]
2374    fn tags_extracts_vendored_sql_query() {
2375        let src = "CREATE TABLE users (id int);\n\
2376                   CREATE FUNCTION recent() RETURNS int AS $$ SELECT total(id) FROM users $$ LANGUAGE sql;\n";
2377        let fs = Registry::default().extract("schema.sql", "b", src.as_bytes());
2378        let names: Vec<&str> = fs.nodes.iter().map(|n| n.name.as_str()).collect();
2379        assert!(names.contains(&"users"), "table definition");
2380        assert!(names.contains(&"recent"), "function definition");
2381
2382        // The table maps to a non-function kind; the function to `Fn`.
2383        assert_eq!(
2384            fs.nodes.iter().find(|n| n.name == "users").map(|n| &n.kind),
2385            Some(&NodeKind::Other("table".to_owned()))
2386        );
2387        // The function body invokes `total`, captured for resolution.
2388        let f = fs.nodes.iter().find(|n| n.name == "recent").unwrap();
2389        assert!(
2390            f.meta
2391                .get("calls")
2392                .and_then(|v| v.as_array())
2393                .is_some_and(|c| c.iter().any(|x| x.as_str() == Some("total"))),
2394            "invocation inside function captured in meta.calls"
2395        );
2396        assert_eq!(
2397            fs.nodes
2398                .iter()
2399                .find(|n| n.name == "users")
2400                .and_then(|n| n.lang.as_deref()),
2401            Some("sql")
2402        );
2403    }
2404
2405    #[test]
2406    fn ingest_prose_toggle_gates_embedded_content() {
2407        use super::IngestConfig;
2408
2409        let content = |ingest: IngestConfig| {
2410            Registry::new(ingest)
2411                .extract("notes.md", "b", b"# Title\n\nBody text.\n")
2412                .nodes[0]
2413                .meta
2414                .get("content")
2415                .and_then(|v| v.as_str())
2416                .map(str::to_owned)
2417        };
2418
2419        // Default (prose on) embeds the markdown body; disabling prose drops it.
2420        assert!(
2421            content(IngestConfig::default()).is_some_and(|c| c.contains("Body text")),
2422            "prose content embedded by default"
2423        );
2424        assert_eq!(
2425            content(IngestConfig {
2426                prose: false,
2427                ..IngestConfig::default()
2428            }),
2429            None,
2430            "disabling prose suppresses the embedded body"
2431        );
2432    }
2433
2434    #[test]
2435    fn env_tag_stable_by_default_and_shifts_when_gated() {
2436        use super::IngestConfig;
2437
2438        // All-on is the default: its tag must equal a plain `Registry` so existing
2439        // caches are untouched.
2440        let all_on = Registry::new(IngestConfig::default()).env_tag();
2441        assert_eq!(all_on, Registry::default().env_tag());
2442
2443        // Each disabled toggle changes the tag (forcing re-extraction), and
2444        // distinct disabled sets produce distinct tags.
2445        let no_prose = Registry::new(IngestConfig {
2446            prose: false,
2447            ..IngestConfig::default()
2448        })
2449        .env_tag();
2450        let no_pdf = Registry::new(IngestConfig {
2451            pdf: false,
2452            ..IngestConfig::default()
2453        })
2454        .env_tag();
2455        let no_audio = Registry::new(IngestConfig {
2456            audio: false,
2457            ..IngestConfig::default()
2458        })
2459        .env_tag();
2460        assert_ne!(no_prose, all_on);
2461        assert_ne!(no_pdf, all_on);
2462        assert_ne!(no_audio, all_on);
2463        assert_ne!(no_prose, no_pdf);
2464        assert_ne!(no_audio, no_prose);
2465        assert_ne!(no_audio, no_pdf);
2466    }
2467}