1use tree_sitter::{Node, Parser};
5
6use crate::languages::Lang;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct SymbolDef {
10 pub name: String,
12 pub qualified: String,
14 pub kind: &'static str,
16 pub signature: String,
18 pub doc: Option<String>,
19 pub start_line: usize,
21 pub end_line: usize,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct CallSite {
26 pub caller: String,
28 pub callee: String,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct ImportRef {
34 pub local: String,
36 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
60fn 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 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 scope.push(name);
84 pushed = true;
85 } else if let Some(scope_name) = lang.scope_only(node, src) {
86 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
125fn 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 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 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 assert!(fx.imports.is_empty(), "{:?}", fx.imports);
496 }
497
498 #[test]
499 fn h_extension_maps_to_cpp() {
500 assert_eq!(Lang::from_path("widget.h"), Some(Lang::Cpp));
503 assert_eq!(Lang::from_path("legacy.c"), Some(Lang::C));
504
505 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 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}