use tree_sitter::{Language, Node};
use crate::core::{Kind, Symbol};
use crate::lang::{Ctx, LanguagePlugin, extract_with_key, qualify};
const TYPESCRIPT: &str = "typescript";
const JAVASCRIPT: &str = "javascript";
type Grammar = (&'static str, Language);
pub struct TypeScript;
pub struct JavaScript;
impl LanguagePlugin for TypeScript {
fn language(&self) -> &'static str {
TYPESCRIPT
}
fn extensions(&self) -> &[&str] {
&["ts", "mts", "cts", "tsx"]
}
fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
let grammar = if is_tsx(file) { tsx() } else { ts() };
run(TYPESCRIPT, grammar, file, source)
}
}
impl LanguagePlugin for JavaScript {
fn language(&self) -> &'static str {
JAVASCRIPT
}
fn extensions(&self) -> &[&str] {
&["js", "mjs", "cjs", "jsx"]
}
fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
run(JAVASCRIPT, tsx(), file, source)
}
}
fn ts() -> Grammar {
("ts", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
}
fn tsx() -> Grammar {
("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())
}
fn run(language: &'static str, (key, grammar): Grammar, file: &str, source: &str) -> Vec<Symbol> {
extract_with_key(key, language, grammar, file, source, |ctx, root, out| {
walk(ctx, root, None, false, out)
})
}
fn is_tsx(file: &str) -> bool {
std::path::Path::new(file)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("tsx"))
}
fn walk(ctx: &Ctx, node: Node, parent: Option<&str>, exported: bool, out: &mut Vec<Symbol>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"export_statement" => walk(ctx, child, parent, true, out),
"class_declaration"
| "abstract_class_declaration"
| "interface_declaration"
| "type_alias_declaration"
| "enum_declaration"
| "internal_module" => {
if let Some(name) = ctx.field_text(child, "name") {
let kind = match child.kind() {
"interface_declaration" => Kind::Trait,
"type_alias_declaration" => Kind::Struct,
"enum_declaration" => Kind::Enum,
"internal_module" => Kind::Module,
_ => Kind::Class,
};
let vis = module_visibility(exported);
push(ctx, out, &name, kind, child, parent, vis);
let qualified = qualify(parent, &name, ".");
walk(ctx, child, Some(&qualified), false, out);
}
}
"function_declaration" | "generator_function_declaration" => {
if let Some(name) = ctx.field_text(child, "name") {
let vis = module_visibility(exported);
push(ctx, out, &name, Kind::Function, child, parent, vis);
}
}
"lexical_declaration" | "variable_declaration" => {
declared_functions(ctx, child, parent, module_visibility(exported), out);
}
"method_definition" | "abstract_method_signature" | "method_signature" => {
push_member(ctx, out, child, parent);
}
"public_field_definition" | "field_definition" => {
if is_function(child.child_by_field_name("value")) {
push_member(ctx, out, child, parent);
}
}
"arrow_function" | "function_expression" | "function" => {}
_ => walk(ctx, child, parent, exported, out),
}
}
}
fn push_member(ctx: &Ctx, out: &mut Vec<Symbol>, node: Node, parent: Option<&str>) {
if let Some(raw) = ctx.field_text(node, "name") {
let vis = member_visibility(ctx, node, &raw);
let name = raw.trim_start_matches('#');
push(ctx, out, name, Kind::Method, node, parent, vis);
}
}
fn declared_functions(
ctx: &Ctx,
node: Node,
parent: Option<&str>,
visibility: &'static str,
out: &mut Vec<Symbol>,
) {
let mut cursor = node.walk();
for d in node.children(&mut cursor) {
if d.kind() != "variable_declarator" || !is_function(d.child_by_field_name("value")) {
continue;
}
if let Some(name) = ctx.field_text(d, "name") {
push(ctx, out, &name, Kind::Function, node, parent, visibility);
}
}
}
fn is_function(value: Option<Node>) -> bool {
matches!(
value.map(|v| v.kind()),
Some("arrow_function" | "function_expression" | "function")
)
}
fn push(
ctx: &Ctx,
out: &mut Vec<Symbol>,
name: &str,
kind: Kind,
node: Node,
parent: Option<&str>,
visibility: &'static str,
) {
let mut s = ctx.symbol(name, kind, node, parent);
s.visibility = Some(visibility);
out.push(s);
}
fn module_visibility(exported: bool) -> &'static str {
if exported { "public" } else { "private" }
}
fn member_visibility(ctx: &Ctx, node: Node, name: &str) -> &'static str {
if name.starts_with('#') {
return "private";
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "accessibility_modifier" {
return match ctx.node_text(child).as_deref() {
Some("private") => "private",
Some("protected") => "protected",
_ => "public",
};
}
}
"public"
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(source: &str) -> Vec<Symbol> {
TypeScript.extract("test.ts", source)
}
fn find<'a>(syms: &'a [Symbol], name: &str) -> &'a Symbol {
syms.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("no symbol named {name} in {syms:?}"))
}
#[test]
fn extracts_types_functions_and_members() {
let src = r#"
export interface Renderer {
render(): string;
}
export type Size = { width: number };
export enum Color {
Red,
}
export class Widget implements Renderer {
render(): string {
return "";
}
private resize(n: number) {}
}
export function buildWidget(): Widget {
return new Widget();
}
export const makeWidget = () => new Widget();
"#;
let syms = extract(src);
assert_eq!(find(&syms, "Renderer").kind, Kind::Trait);
assert_eq!(find(&syms, "Size").kind, Kind::Struct);
assert_eq!(find(&syms, "Color").kind, Kind::Enum);
let widget = find(&syms, "Widget");
assert_eq!(widget.kind, Kind::Class);
assert_eq!(widget.parent, None);
assert_eq!(widget.language, "typescript");
let render = find(&syms, "render");
assert_eq!(render.kind, Kind::Method);
let renders: Vec<_> = syms.iter().filter(|s| s.name == "render").collect();
assert_eq!(renders.len(), 2, "{syms:?}");
assert!(
renders
.iter()
.any(|s| s.parent.as_deref() == Some("Widget"))
);
assert!(
renders
.iter()
.any(|s| s.parent.as_deref() == Some("Renderer"))
);
assert_eq!(find(&syms, "buildWidget").kind, Kind::Function);
assert_eq!(find(&syms, "makeWidget").kind, Kind::Function);
}
#[test]
fn an_object_type_declares_methods_like_an_interface() {
let src = "type Renderer = {\n render(): string;\n};\n";
let syms = extract(src);
assert_eq!(find(&syms, "Renderer").kind, Kind::Struct);
let render = find(&syms, "render");
assert_eq!(render.kind, Kind::Method);
assert_eq!(render.parent.as_deref(), Some("Renderer"));
}
#[test]
fn qualifies_through_namespaces() {
let src = "namespace Outer {\n export class Store {\n get() {}\n }\n}\n";
let syms = extract(src);
assert_eq!(find(&syms, "Outer").kind, Kind::Module);
assert_eq!(find(&syms, "Store").parent.as_deref(), Some("Outer"));
assert_eq!(find(&syms, "get").parent.as_deref(), Some("Outer.Store"));
}
#[test]
fn callback_locals_are_not_definitions() {
let src = "describe('widget', () => {\n const helper = () => 1;\n});\n";
assert!(extract(src).is_empty(), "{:?}", extract(src));
}
#[test]
fn empty_and_unparseable_yield_no_symbols() {
assert!(extract("").is_empty());
assert!(extract("// just a comment\n").is_empty());
}
#[test]
fn visibility_reflects_exports_and_member_modifiers() {
let src = r#"
export function open() {}
function helper() {}
export class Account {
deposit() {}
private audit() {}
protected hook() {}
#secret() {}
}
"#;
let syms = extract(src);
assert_eq!(find(&syms, "open").visibility, Some("public"));
assert_eq!(find(&syms, "helper").visibility, Some("private"));
assert_eq!(find(&syms, "deposit").visibility, Some("public"));
assert_eq!(find(&syms, "audit").visibility, Some("private"));
assert_eq!(find(&syms, "hook").visibility, Some("protected"));
assert_eq!(find(&syms, "secret").visibility, Some("private"));
}
#[test]
fn tsx_and_jsx_parse_as_their_own_languages() {
let component = "export const Widget = () => <div>hi</div>;\n";
let tsx = TypeScript.extract("Widget.tsx", component);
assert_eq!(find(&tsx, "Widget").kind, Kind::Function);
assert_eq!(find(&tsx, "Widget").language, "typescript");
let jsx = JavaScript.extract("Widget.jsx", component);
assert_eq!(find(&jsx, "Widget").language, "javascript");
let generic = TypeScript.extract("id.ts", "export const id = <T>(x: T): T => x;\n");
assert_eq!(find(&generic, "id").kind, Kind::Function);
}
#[test]
fn class_properties_holding_arrows_are_methods() {
let src = "class Widget {\n handleClick = () => {};\n size = 3;\n}\n";
let syms = extract(src);
let click = find(&syms, "handleClick");
assert_eq!(click.kind, Kind::Method);
assert_eq!(click.parent.as_deref(), Some("Widget"));
assert!(!syms.iter().any(|s| s.name == "size"), "{syms:?}");
}
}