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(crate) struct TypeScript;
pub(crate) struct JavaScript;
impl LanguagePlugin for TypeScript {
fn language(&self) -> &'static str {
TYPESCRIPT
}
fn extensions(&self) -> &[&str] {
&["ts", "mts", "cts", "tsx"]
}
fn constructor(&self) -> Option<&'static str> {
Some("constructor")
}
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 constructor(&self) -> Option<&'static str> {
Some("constructor")
}
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" => {
declarations(ctx, child, parent, module_visibility(exported), out);
}
"method_definition" | "abstract_method_signature" | "method_signature" => {
push_member(ctx, out, child, Kind::Method, parent);
}
"public_field_definition" | "field_definition" => {
if is_function(child.child_by_field_name("value")) {
push_member(ctx, out, child, Kind::Method, parent);
} else if has_token(child, "static") && has_token(child, "readonly") {
push_member(ctx, out, child, Kind::Constant, parent);
}
}
"arrow_function" | "function_expression" | "function" => {}
_ => walk(ctx, child, parent, exported, out),
}
}
}
fn push_member(ctx: &Ctx, out: &mut Vec<Symbol>, node: Node, kind: Kind, 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, node, parent, vis);
}
}
fn declarations(
ctx: &Ctx,
node: Node,
parent: Option<&str>,
visibility: &'static str,
out: &mut Vec<Symbol>,
) {
let constants = node.child(0).is_some_and(|k| k.kind() == "const") && at_module_level(node);
let mut cursor = node.walk();
for d in node.children(&mut cursor) {
if d.kind() != "variable_declarator" {
continue;
}
let value = d.child_by_field_name("value");
let kind = if is_function(value) {
Kind::Function
} else if constants && !is_require(ctx, value) {
Kind::Constant
} else {
continue;
};
if let Some(name) = d.child_by_field_name("name")
&& name.kind() == "identifier"
&& let Some(name) = ctx.node_text(name)
{
push(ctx, out, &name, kind, node, parent, visibility);
}
}
}
fn at_module_level(stmt: Node) -> bool {
let mut up = stmt.parent();
if up.is_some_and(|n| n.kind() == "export_statement") {
up = up.and_then(|n| n.parent());
}
up.is_some_and(|n| match n.kind() {
"program" => true,
"statement_block" => n.parent().is_some_and(|m| m.kind() == "internal_module"),
_ => false,
})
}
fn is_require(ctx: &Ctx, mut value: Option<Node>) -> bool {
while let Some(v) = value {
match v.kind() {
"member_expression" => value = v.child_by_field_name("object"),
"call_expression" => {
return v
.child_by_field_name("function")
.and_then(|f| ctx.node_text(f))
.is_some_and(|f| f == "require");
}
_ => return false,
}
}
false
}
fn has_token(node: Node, kw: &str) -> bool {
let mut cursor = node.walk();
node.children(&mut cursor).any(|c| c.kind() == kw)
}
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::*;
use crate::lang::testing::find;
fn extract(source: &str) -> Vec<Symbol> {
TypeScript.extract("test.ts", source)
}
#[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 module_level_consts_are_constants() {
let src = r#"
export const MAX_RETRIES = 3;
const router = createRouter();
export const handler = () => 1;
const fs = require("fs");
const { Widget } = require("./widget");
const Gadget = require("./gadget").Gadget;
const { width, height } = defaults;
let counter = 0;
var legacy = 1;
namespace Limits {
export const CEILING = 9;
}
function build() {
const localLimit = 5;
}
if (ready) {
const inBlock = 1;
}
describe("widget", () => {
const inCallback = 1;
});
"#;
let syms = extract(src);
let max = find(&syms, "MAX_RETRIES");
assert_eq!(max.kind, Kind::Constant);
assert_eq!(max.parent, None);
assert_eq!(max.visibility, Some("public"));
let router = find(&syms, "router");
assert_eq!(router.kind, Kind::Constant);
assert_eq!(router.visibility, Some("private"));
let ceiling = find(&syms, "CEILING");
assert_eq!(ceiling.kind, Kind::Constant);
assert_eq!(ceiling.parent.as_deref(), Some("Limits"));
let handlers: Vec<_> = syms.iter().filter(|s| s.name == "handler").collect();
assert_eq!(handlers.len(), 1, "{syms:?}");
assert_eq!(handlers[0].kind, Kind::Function);
for absent in [
"fs",
"Widget",
"Gadget",
"width",
"counter",
"legacy",
"localLimit",
"inBlock",
"inCallback",
] {
assert!(!syms.iter().any(|s| s.name == absent), "{absent}: {syms:?}");
}
}
#[test]
fn static_readonly_fields_are_class_constants() {
let src = r#"
enum Color {
Red,
}
class Widget {
static readonly DEFAULT_SIZE = 3;
private static readonly SECRET = "x";
static count = 0;
readonly id = 1;
}
"#;
let syms = extract(src);
let size = find(&syms, "DEFAULT_SIZE");
assert_eq!(size.kind, Kind::Constant);
assert_eq!(size.parent.as_deref(), Some("Widget"));
assert_eq!(size.visibility, Some("public"));
assert_eq!(find(&syms, "SECRET").visibility, Some("private"));
for absent in ["count", "id", "Red"] {
assert!(!syms.iter().any(|s| s.name == absent), "{absent}: {syms:?}");
}
}
#[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:?}");
}
}