Skip to main content

mimir_syntax/
extract.rs

1//! Tree-sitter symbol/call/import extraction. No LLM, no type checker —
2//! honest static extraction with explicit confidence tiers downstream.
3
4use tree_sitter::{Node, Parser};
5
6use crate::languages::Lang;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct SymbolDef {
10    /// Bare name (resolution bucket), e.g. "resolve_ref".
11    pub name: String,
12    /// Nesting-qualified, e.g. "MatrixCache::ensure" / "ClassName.method".
13    pub qualified: String,
14    /// function | method | struct | class | trait | enum | interface | type
15    pub kind: &'static str,
16    /// Signature line(s) — what gets embedded alongside the doc comment.
17    pub signature: String,
18    pub doc: Option<String>,
19    /// 1-based, inclusive.
20    pub start_line: usize,
21    pub end_line: usize,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct CallSite {
26    /// Qualified name of the enclosing definition ("" = file top level).
27    pub caller: String,
28    /// Bare callee name as written (rightmost path segment).
29    pub callee: String,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct ImportRef {
34    /// Name bound locally (rightmost segment or alias).
35    pub local: String,
36    /// Module/path text as written ("./util", "foo::bar", "pkg.mod").
37    pub source: String,
38}
39
40#[derive(Debug, Default)]
41pub struct FileExtract {
42    pub symbols: Vec<SymbolDef>,
43    pub calls: Vec<CallSite>,
44    pub imports: Vec<ImportRef>,
45}
46
47pub fn extract(lang: Lang, source: &str) -> FileExtract {
48    let mut parser = Parser::new();
49    if parser.set_language(&lang.language()).is_err() {
50        return FileExtract::default();
51    }
52    let Some(tree) = parser.parse(source, None) else {
53        return FileExtract::default();
54    };
55    let mut out = FileExtract::default();
56    walk(lang, tree.root_node(), source, &mut Vec::new(), &mut out);
57    out
58}
59
60/// Recursive walk keeping a stack of enclosing definition names.
61fn walk(lang: Lang, node: Node, src: &str, scope: &mut Vec<String>, out: &mut FileExtract) {
62    let mut pushed = false;
63
64    if let Some((name, kind)) = lang.definition(node, src) {
65        let qualified = qualify(scope, &name);
66        // Methods: a function nested inside a type/class scope.
67        let kind = if kind == "function" && !scope.is_empty() {
68            "method"
69        } else {
70            kind
71        };
72        out.symbols.push(SymbolDef {
73            signature: signature_text(lang, node, src),
74            doc: lang.doc_comment(node, src),
75            start_line: node.start_position().row + 1,
76            end_line: node.end_position().row + 1,
77            name: qualified_tail(&name),
78            qualified: qualified.clone(),
79            kind,
80        });
81        // Push the name as returned (it may carry a `::` receiver prefix,
82        // e.g. Go methods) so scope.join("::") == qualified for children.
83        scope.push(name);
84        pushed = true;
85    } else if let Some(scope_name) = lang.scope_only(node, src) {
86        // Containers that qualify children but aren't symbols themselves
87        // (Rust impl blocks, modules).
88        scope.push(scope_name);
89        pushed = true;
90    }
91
92    if let Some(callee) = lang.call(node, src) {
93        out.calls.push(CallSite {
94            caller: scope.join(&lang.separator()),
95            callee,
96        });
97    }
98    lang.imports(node, src, &mut out.imports);
99
100    let mut cursor = node.walk();
101    for child in node.children(&mut cursor) {
102        walk(lang, child, src, scope, out);
103    }
104    if pushed {
105        scope.pop();
106    }
107}
108
109fn qualify(scope: &[String], name: &str) -> String {
110    if scope.is_empty() {
111        name.to_string()
112    } else {
113        format!("{}::{name}", scope.join("::"))
114    }
115}
116
117fn qualified_tail(qualified: &str) -> String {
118    qualified
119        .rsplit("::")
120        .next()
121        .unwrap_or(qualified)
122        .to_string()
123}
124
125/// Text from the definition start to its body — the signature.
126fn signature_text(lang: Lang, node: Node, src: &str) -> String {
127    let full = &src[node.byte_range()];
128    let cut = lang
129        .body_field()
130        .and_then(|f| node.child_by_field_name(f))
131        .map(|b| b.start_byte().saturating_sub(node.start_byte()))
132        .unwrap_or(full.len());
133    let sig: String = full[..cut].split_whitespace().collect::<Vec<_>>().join(" ");
134    // Defensive cap: pathological one-line definitions.
135    sig.chars().take(300).collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn names(fx: &FileExtract) -> Vec<(&str, &str)> {
143        fx.symbols
144            .iter()
145            .map(|s| (s.qualified.as_str(), s.kind))
146            .collect()
147    }
148
149    #[test]
150    fn rust_extraction() {
151        let src = r#"
152//! module docs
153
154/// Adds things.
155pub fn add(a: i32, b: i32) -> i32 { helper(a) + b }
156
157fn helper(x: i32) -> i32 { x }
158
159pub struct Counter { n: u64 }
160
161impl Counter {
162    /// Bump it.
163    pub fn bump(&mut self) { self.n += 1; validate(self.n); }
164}
165
166pub trait Resettable { fn reset(&mut self); }
167
168pub enum Mode { A, B }
169
170use std::collections::HashMap;
171use crate::store::resolve_ref as rr;
172"#;
173        let fx = extract(Lang::Rust, src);
174        let n = names(&fx);
175        assert!(n.contains(&("add", "function")), "{n:?}");
176        assert!(n.contains(&("helper", "function")), "{n:?}");
177        assert!(n.contains(&("Counter", "struct")), "{n:?}");
178        assert!(n.contains(&("Counter::bump", "method")), "{n:?}");
179        assert!(n.contains(&("Resettable", "trait")), "{n:?}");
180        assert!(n.contains(&("Mode", "enum")), "{n:?}");
181
182        let add = fx.symbols.iter().find(|s| s.qualified == "add").unwrap();
183        assert_eq!(add.doc.as_deref(), Some("Adds things."));
184        assert!(add.signature.contains("pub fn add(a: i32, b: i32) -> i32"));
185
186        let calls: Vec<(&str, &str)> = fx
187            .calls
188            .iter()
189            .map(|c| (c.caller.as_str(), c.callee.as_str()))
190            .collect();
191        assert!(calls.contains(&("add", "helper")), "{calls:?}");
192        assert!(calls.contains(&("Counter::bump", "validate")), "{calls:?}");
193
194        let imports: Vec<(&str, &str)> = fx
195            .imports
196            .iter()
197            .map(|i| (i.local.as_str(), i.source.as_str()))
198            .collect();
199        assert!(
200            imports.contains(&("HashMap", "std::collections::HashMap")),
201            "{imports:?}"
202        );
203        assert!(
204            imports.contains(&("rr", "crate::store::resolve_ref")),
205            "{imports:?}"
206        );
207    }
208
209    #[test]
210    fn typescript_extraction() {
211        let src = r#"
212import { fetchUser, postUser as pu } from "./api";
213import db from "../db";
214
215/** Greets. */
216export function greet(name: string): string { return hello(name); }
217
218const shout = (s: string) => s.toUpperCase();
219
220export class UserService {
221    find(id: number) { return fetchUser(id); }
222}
223
224interface Shape { area(): number; }
225"#;
226        let fx = extract(Lang::TypeScript, src);
227        let n = names(&fx);
228        assert!(n.contains(&("greet", "function")), "{n:?}");
229        assert!(n.contains(&("shout", "function")), "{n:?}");
230        assert!(n.contains(&("UserService", "class")), "{n:?}");
231        assert!(n.contains(&("UserService::find", "method")), "{n:?}");
232        assert!(n.contains(&("Shape", "interface")), "{n:?}");
233
234        let calls: Vec<(&str, &str)> = fx
235            .calls
236            .iter()
237            .map(|c| (c.caller.as_str(), c.callee.as_str()))
238            .collect();
239        assert!(calls.contains(&("greet", "hello")), "{calls:?}");
240        assert!(
241            calls.contains(&("UserService::find", "fetchUser")),
242            "{calls:?}"
243        );
244
245        let imports: Vec<(&str, &str)> = fx
246            .imports
247            .iter()
248            .map(|i| (i.local.as_str(), i.source.as_str()))
249            .collect();
250        assert!(imports.contains(&("fetchUser", "./api")), "{imports:?}");
251        assert!(imports.contains(&("pu", "./api")), "{imports:?}");
252        assert!(imports.contains(&("db", "../db")), "{imports:?}");
253    }
254
255    #[test]
256    fn python_extraction() {
257        let src = r#"
258import os
259from collections import OrderedDict as OD
260from .util import slugify
261
262def top(x):
263    """Top-level docstring."""
264    return slugify(x)
265
266class Repo:
267    def save(self, item):
268        validate(item)
269        return persist(item)
270"#;
271        let fx = extract(Lang::Python, src);
272        let n = names(&fx);
273        assert!(n.contains(&("top", "function")), "{n:?}");
274        assert!(n.contains(&("Repo", "class")), "{n:?}");
275        assert!(n.contains(&("Repo::save", "method")), "{n:?}");
276
277        let top_sym = fx.symbols.iter().find(|s| s.qualified == "top").unwrap();
278        assert_eq!(top_sym.doc.as_deref(), Some("Top-level docstring."));
279
280        let calls: Vec<(&str, &str)> = fx
281            .calls
282            .iter()
283            .map(|c| (c.caller.as_str(), c.callee.as_str()))
284            .collect();
285        assert!(calls.contains(&("top", "slugify")), "{calls:?}");
286        assert!(calls.contains(&("Repo::save", "validate")), "{calls:?}");
287
288        let imports: Vec<(&str, &str)> = fx
289            .imports
290            .iter()
291            .map(|i| (i.local.as_str(), i.source.as_str()))
292            .collect();
293        assert!(imports.contains(&("os", "os")), "{imports:?}");
294        assert!(imports.contains(&("OD", "collections")), "{imports:?}");
295        assert!(imports.contains(&("slugify", ".util")), "{imports:?}");
296    }
297
298    #[test]
299    fn go_extraction() {
300        let src = r#"
301package main
302
303import (
304    "fmt"
305    alias "net/http"
306)
307
308// Greet says hi.
309func Greet(name string) string { return fmt.Sprintf("hi %s", name) }
310
311type Server struct{ port int }
312
313func (s *Server) Start() error { return listen(s.port) }
314"#;
315        let fx = extract(Lang::Go, src);
316        let n = names(&fx);
317        assert!(n.contains(&("Greet", "function")), "{n:?}");
318        assert!(n.contains(&("Server", "struct")), "{n:?}");
319        assert!(n.contains(&("Server::Start", "method")), "{n:?}");
320
321        let greet = fx.symbols.iter().find(|s| s.qualified == "Greet").unwrap();
322        assert_eq!(greet.doc.as_deref(), Some("Greet says hi."));
323
324        let calls: Vec<(&str, &str)> = fx
325            .calls
326            .iter()
327            .map(|c| (c.caller.as_str(), c.callee.as_str()))
328            .collect();
329        assert!(calls.contains(&("Greet", "Sprintf")), "{calls:?}");
330        assert!(calls.contains(&("Server::Start", "listen")), "{calls:?}");
331
332        let imports: Vec<(&str, &str)> = fx
333            .imports
334            .iter()
335            .map(|i| (i.local.as_str(), i.source.as_str()))
336            .collect();
337        assert!(imports.contains(&("fmt", "fmt")), "{imports:?}");
338        assert!(imports.contains(&("alias", "net/http")), "{imports:?}");
339    }
340
341    #[test]
342    fn broken_source_does_not_panic() {
343        for lang in [
344            Lang::Rust,
345            Lang::TypeScript,
346            Lang::Python,
347            Lang::Go,
348            Lang::CSharp,
349            Lang::Sql,
350            Lang::Cpp,
351            Lang::Kotlin,
352            Lang::Swift,
353            Lang::Php,
354        ] {
355            extract(lang, "fn class def func ((((");
356            extract(lang, "");
357        }
358    }
359
360    #[test]
361    fn csharp_extraction() {
362        let src = r#"
363using System;
364using Data = App.Models;
365
366namespace App
367{
368    // Greets users.
369    public class Greeter
370    {
371        public string Name { get; set; }
372
373        public Greeter(string name) { Name = name; }
374
375        public string Greet() { return Format(Name); }
376
377        private string Format(string n) { return n.ToUpper(); }
378    }
379
380    public interface IRunnable { void Run(); }
381
382    public struct Point { public int X; }
383
384    public record Person(string First, string Last);
385
386    public enum Mode { On, Off }
387}
388"#;
389        let fx = extract(Lang::CSharp, src);
390        let n = names(&fx);
391        assert!(n.contains(&("App", "namespace")), "{n:?}");
392        assert!(n.contains(&("App::Greeter", "class")), "{n:?}");
393        assert!(n.contains(&("App::Greeter::Greet", "method")), "{n:?}");
394        assert!(n.contains(&("App::Greeter::Format", "method")), "{n:?}");
395        assert!(n.contains(&("App::Greeter::Name", "property")), "{n:?}");
396        assert!(
397            n.contains(&("App::Greeter::Greeter", "constructor")),
398            "{n:?}"
399        );
400        assert!(n.contains(&("App::IRunnable", "interface")), "{n:?}");
401        assert!(n.contains(&("App::Point", "struct")), "{n:?}");
402        assert!(n.contains(&("App::Person", "class")), "{n:?}");
403        assert!(n.contains(&("App::Mode", "enum")), "{n:?}");
404
405        let greeter = fx
406            .symbols
407            .iter()
408            .find(|s| s.qualified == "App::Greeter")
409            .unwrap();
410        assert_eq!(greeter.doc.as_deref(), Some("Greets users."));
411
412        let calls: Vec<(&str, &str)> = fx
413            .calls
414            .iter()
415            .map(|c| (c.caller.as_str(), c.callee.as_str()))
416            .collect();
417        assert!(
418            calls.contains(&("App::Greeter::Greet", "Format")),
419            "{calls:?}"
420        );
421        assert!(
422            calls.contains(&("App::Greeter::Format", "ToUpper")),
423            "{calls:?}"
424        );
425
426        let imports: Vec<(&str, &str)> = fx
427            .imports
428            .iter()
429            .map(|i| (i.local.as_str(), i.source.as_str()))
430            .collect();
431        assert!(imports.contains(&("System", "System")), "{imports:?}");
432        assert!(imports.contains(&("Data", "App.Models")), "{imports:?}");
433    }
434
435    #[test]
436    fn sql_extraction() {
437        let src = r#"
438-- People who use the system.
439CREATE TABLE users (
440  id INT PRIMARY KEY,
441  name NVARCHAR(50)
442);
443
444CREATE TABLE orders (
445  id INT PRIMARY KEY,
446  user_id INT REFERENCES users(id)
447);
448
449CREATE VIEW active_orders AS
450  SELECT o.id FROM orders o JOIN users u ON o.user_id = u.id;
451
452CREATE FUNCTION order_count() RETURNS INT AS
453BEGIN
454  RETURN (SELECT COUNT(*) FROM orders);
455END;
456
457CREATE PROCEDURE purge AS
458BEGIN
459  DELETE FROM orders;
460END;
461"#;
462        let fx = extract(Lang::Sql, src);
463        let n = names(&fx);
464        assert!(n.contains(&("users", "table")), "{n:?}");
465        assert!(n.contains(&("orders", "table")), "{n:?}");
466        assert!(n.contains(&("active_orders", "view")), "{n:?}");
467        assert!(n.contains(&("order_count", "function")), "{n:?}");
468        assert!(n.contains(&("purge", "procedure")), "{n:?}");
469
470        let users = fx.symbols.iter().find(|s| s.qualified == "users").unwrap();
471        assert_eq!(users.doc.as_deref(), Some("People who use the system."));
472
473        // Dependency edges ride the call edge: dependent → table.
474        let calls: Vec<(&str, &str)> = fx
475            .calls
476            .iter()
477            .map(|c| (c.caller.as_str(), c.callee.as_str()))
478            .collect();
479        assert!(calls.contains(&("orders", "users")), "FK: {calls:?}");
480        assert!(
481            calls.contains(&("active_orders", "orders")),
482            "view: {calls:?}"
483        );
484        assert!(
485            calls.contains(&("active_orders", "users")),
486            "view: {calls:?}"
487        );
488        assert!(
489            calls.contains(&("order_count", "orders")),
490            "function: {calls:?}"
491        );
492        assert!(calls.contains(&("purge", "orders")), "procedure: {calls:?}");
493
494        // SQL has no import construct.
495        assert!(fx.imports.is_empty(), "{:?}", fx.imports);
496    }
497
498    #[test]
499    fn h_extension_maps_to_cpp() {
500        // `.h` is deliberately routed to the C++ grammar (see from_path's
501        // comment); `.c` stays plain C.
502        assert_eq!(Lang::from_path("widget.h"), Some(Lang::Cpp));
503        assert_eq!(Lang::from_path("legacy.c"), Some(Lang::C));
504
505        // A `.h` header with a class (invalid in plain C) still extracts
506        // cleanly under the C++ grammar.
507        let src = r#"
508#ifndef WIDGET_H
509#define WIDGET_H
510
511class Widget {
512public:
513    int size();
514};
515
516#endif
517"#;
518        let fx = extract(Lang::from_path("widget.h").unwrap(), src);
519        let n = names(&fx);
520        assert!(n.contains(&("Widget", "class")), "{n:?}");
521    }
522
523    #[test]
524    fn cpp_extraction() {
525        let src = r#"
526#include "util.h"
527#include <vector>
528
529// Greets people.
530class Greeter {
531public:
532    std::string Format(const std::string& name);
533
534    std::string Greet(const std::string& name) {
535        return Format(name);
536    }
537};
538
539std::string Greeter::Format(const std::string& name) {
540    return normalize(name);
541}
542
543namespace app {
544    struct Point { int x; int y; };
545}
546"#;
547        let fx = extract(Lang::Cpp, src);
548        let n = names(&fx);
549        assert!(n.contains(&("Greeter", "class")), "{n:?}");
550        assert!(n.contains(&("Greeter::Greet", "method")), "{n:?}");
551        assert!(n.contains(&("Greeter::Format", "method")), "{n:?}");
552        assert!(n.contains(&("app::Point", "struct")), "{n:?}");
553
554        let greeter = fx
555            .symbols
556            .iter()
557            .find(|s| s.qualified == "Greeter")
558            .unwrap();
559        assert_eq!(greeter.doc.as_deref(), Some("Greets people."));
560
561        let calls: Vec<(&str, &str)> = fx
562            .calls
563            .iter()
564            .map(|c| (c.caller.as_str(), c.callee.as_str()))
565            .collect();
566        assert!(calls.contains(&("Greeter::Greet", "Format")), "{calls:?}");
567        assert!(
568            calls.contains(&("Greeter::Format", "normalize")),
569            "{calls:?}"
570        );
571
572        let imports: Vec<(&str, &str)> = fx
573            .imports
574            .iter()
575            .map(|i| (i.local.as_str(), i.source.as_str()))
576            .collect();
577        assert!(imports.contains(&("util", "util.h")), "{imports:?}");
578        assert!(imports.contains(&("vector", "vector")), "{imports:?}");
579    }
580
581    #[test]
582    fn kotlin_extraction() {
583        let src = r#"
584package com.example
585
586import java.util.List
587import com.example.util.Formatter as Fmt
588
589// Greets people.
590class Greeter(val name: String) {
591    fun greet(): String {
592        return format(name)
593    }
594
595    fun format(input: String): String {
596        return Fmt.upper(input)
597    }
598}
599
600enum class Mode { On, Off }
601"#;
602        let fx = extract(Lang::Kotlin, src);
603        let n = names(&fx);
604        assert!(n.contains(&("Greeter", "class")), "{n:?}");
605        assert!(n.contains(&("Greeter::greet", "method")), "{n:?}");
606        assert!(n.contains(&("Greeter::format", "method")), "{n:?}");
607        assert!(n.contains(&("Mode", "enum")), "{n:?}");
608
609        let greeter = fx
610            .symbols
611            .iter()
612            .find(|s| s.qualified == "Greeter")
613            .unwrap();
614        assert_eq!(greeter.doc.as_deref(), Some("Greets people."));
615
616        let calls: Vec<(&str, &str)> = fx
617            .calls
618            .iter()
619            .map(|c| (c.caller.as_str(), c.callee.as_str()))
620            .collect();
621        assert!(calls.contains(&("Greeter::greet", "format")), "{calls:?}");
622        assert!(calls.contains(&("Greeter::format", "upper")), "{calls:?}");
623
624        let imports: Vec<(&str, &str)> = fx
625            .imports
626            .iter()
627            .map(|i| (i.local.as_str(), i.source.as_str()))
628            .collect();
629        assert!(imports.contains(&("List", "java.util.List")), "{imports:?}");
630        assert!(
631            imports.contains(&("Fmt", "com.example.util.Formatter")),
632            "{imports:?}"
633        );
634    }
635
636    #[test]
637    fn swift_extraction() {
638        let src = r#"
639import Foundation
640
641// Greets people.
642class Greeter {
643    let name: String
644
645    init(name: String) {
646        self.name = name
647    }
648
649    func greet() -> String {
650        return format(name)
651    }
652
653    func format(_ input: String) -> String {
654        return input.uppercased()
655    }
656}
657
658protocol Runnable {
659    func run()
660}
661"#;
662        let fx = extract(Lang::Swift, src);
663        let n = names(&fx);
664        assert!(n.contains(&("Greeter", "class")), "{n:?}");
665        assert!(n.contains(&("Greeter::init", "constructor")), "{n:?}");
666        assert!(n.contains(&("Greeter::greet", "method")), "{n:?}");
667        assert!(n.contains(&("Greeter::format", "method")), "{n:?}");
668        assert!(n.contains(&("Runnable", "interface")), "{n:?}");
669
670        let greeter = fx
671            .symbols
672            .iter()
673            .find(|s| s.qualified == "Greeter")
674            .unwrap();
675        assert_eq!(greeter.doc.as_deref(), Some("Greets people."));
676
677        let calls: Vec<(&str, &str)> = fx
678            .calls
679            .iter()
680            .map(|c| (c.caller.as_str(), c.callee.as_str()))
681            .collect();
682        assert!(calls.contains(&("Greeter::greet", "format")), "{calls:?}");
683        assert!(
684            calls.contains(&("Greeter::format", "uppercased")),
685            "{calls:?}"
686        );
687
688        let imports: Vec<(&str, &str)> = fx
689            .imports
690            .iter()
691            .map(|i| (i.local.as_str(), i.source.as_str()))
692            .collect();
693        assert!(
694            imports.contains(&("Foundation", "Foundation")),
695            "{imports:?}"
696        );
697    }
698
699    #[test]
700    fn php_extraction() {
701        let src = r#"<?php
702
703namespace App {
704    use App\Util\Formatter;
705    require_once 'bootstrap.php';
706
707    // Greets people.
708    class Greeter
709    {
710        public function greet($name)
711        {
712            return $this->format($name);
713        }
714
715        private function format($name)
716        {
717            return Formatter::upper($name);
718        }
719    }
720
721    interface Runnable
722    {
723        public function run();
724    }
725}
726"#;
727        let fx = extract(Lang::Php, src);
728        let n = names(&fx);
729        assert!(n.contains(&("App", "namespace")), "{n:?}");
730        assert!(n.contains(&("App::Greeter", "class")), "{n:?}");
731        assert!(n.contains(&("App::Greeter::greet", "method")), "{n:?}");
732        assert!(n.contains(&("App::Greeter::format", "method")), "{n:?}");
733        assert!(n.contains(&("App::Runnable", "interface")), "{n:?}");
734
735        let greeter = fx
736            .symbols
737            .iter()
738            .find(|s| s.qualified == "App::Greeter")
739            .unwrap();
740        assert_eq!(greeter.doc.as_deref(), Some("Greets people."));
741
742        let calls: Vec<(&str, &str)> = fx
743            .calls
744            .iter()
745            .map(|c| (c.caller.as_str(), c.callee.as_str()))
746            .collect();
747        assert!(
748            calls.contains(&("App::Greeter::greet", "format")),
749            "{calls:?}"
750        );
751        assert!(
752            calls.contains(&("App::Greeter::format", "upper")),
753            "{calls:?}"
754        );
755
756        let imports: Vec<(&str, &str)> = fx
757            .imports
758            .iter()
759            .map(|i| (i.local.as_str(), i.source.as_str()))
760            .collect();
761        assert!(
762            imports.contains(&("Formatter", "App\\Util\\Formatter")),
763            "{imports:?}"
764        );
765        assert!(
766            imports.contains(&("bootstrap", "bootstrap.php")),
767            "{imports:?}"
768        );
769    }
770
771    #[test]
772    fn php_without_tag_extracts_nothing() {
773        // Without a `<?php` tag, the whole file parses as plain HTML/text —
774        // no symbols, calls, or imports.
775        let fx = extract(Lang::Php, "class Foo { function bar() {} }");
776        assert!(fx.symbols.is_empty(), "{:?}", fx.symbols);
777        assert!(fx.calls.is_empty(), "{:?}", fx.calls);
778        assert!(fx.imports.is_empty(), "{:?}", fx.imports);
779    }
780}