use tree_sitter::Node;
use crate::model::{Edge, EdgeType, Language, Node as ModelNode, NodeLabel};
use crate::resolve::{FqnGenerator, ScopeContext, ScopeResolverRegistry};
use super::dedupe_qn;
use super::error::{ParseError, Result};
use super::extractor::{
AssignInfo, CallInfo, ExtractResult, Extractor, ImportInfo, ReadInfo, WriteInfo,
};
use super::parser_factory::ParserFactory;
pub struct TypeScriptExtractor {
_priv: (),
}
impl TypeScriptExtractor {
#[must_use]
pub const fn new() -> Self {
Self { _priv: () }
}
}
impl Default for TypeScriptExtractor {
fn default() -> Self {
Self::new()
}
}
impl Extractor for TypeScriptExtractor {
fn language(&self) -> Language {
Language::TypeScript
}
fn extract(&self, source: &str, file_path: &str, project: &str) -> Result<ExtractResult> {
let mut result = ExtractResult::new(file_path, Language::TypeScript);
let mut parser = ParserFactory::create_parser(Language::TypeScript)?;
let tree = parser
.parse(source, None)
.ok_or_else(|| ParseError::ParseFailed {
file_path: file_path.to_string(),
})?;
let root = tree.root_node();
let registry = ScopeResolverRegistry::new();
let ctx = VisitContext {
file_path,
project,
current_func: None,
current_parent: None,
resolver: ®istry,
};
for i in 0..root.named_child_count() as u32 {
if let Some(child) = root.named_child(i) {
visit_node(child, source, &ctx, &mut result);
}
}
Ok(result)
}
}
struct VisitContext<'a> {
file_path: &'a str,
project: &'a str,
current_func: Option<&'a str>,
current_parent: Option<&'a str>,
resolver: &'a ScopeResolverRegistry,
}
fn visit_node(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
match node.kind() {
"function_declaration" | "generator_function_declaration" => {
extract_function(node, source, ctx, result);
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::TypeScript)
.and_then(|r| r.resolve(node, &scope_ctx));
let func_name = scope.as_ref().map(|s| s.name.as_str());
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: func_name,
current_parent: ctx.current_parent,
resolver: ctx.resolver,
};
visit_children(node, source, &child_ctx, result);
}
"class_declaration" => {
extract_class(node, source, ctx.file_path, ctx.project, result);
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::TypeScript)
.and_then(|r| r.resolve(node, &scope_ctx));
let class_name = scope.as_ref().map(|s| s.name.as_str());
let parent = class_name.or(ctx.current_parent);
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: ctx.current_func,
current_parent: parent,
resolver: ctx.resolver,
};
visit_children(node, source, &child_ctx, result);
}
"method_definition" => {
extract_method(node, source, ctx, result);
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::TypeScript)
.and_then(|r| r.resolve(node, &scope_ctx));
let func_name = scope.as_ref().map(|s| s.name.as_str());
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: func_name,
current_parent: ctx.current_parent,
resolver: ctx.resolver,
};
visit_children(node, source, &child_ctx, result);
}
"interface_declaration" => {
extract_named_item(
node,
NodeLabel::Interface,
source,
ctx.file_path,
ctx.project,
result,
);
visit_children(node, source, ctx, result);
}
"enum_declaration" => {
extract_named_item(
node,
NodeLabel::Enum,
source,
ctx.file_path,
ctx.project,
result,
);
visit_children(node, source, ctx, result);
}
"type_alias_declaration" => {
extract_named_item(
node,
NodeLabel::TypeAlias,
source,
ctx.file_path,
ctx.project,
result,
);
}
"import_statement" => {
extract_import(node, source, result);
}
"export_statement" => {
if let Some(source_node) = node.child_by_field_name("source") {
let source_file = node_text(source_node, source)
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
.unwrap_or_default();
if !source_file.is_empty() {
result.imports.push(ImportInfo {
source_file,
imported_names: Vec::new(),
line: node.start_position().row as u32 + 1,
});
}
}
if let Some(value) = node.child_by_field_name("value") {
if matches!(
value.kind(),
"arrow_function" | "function_expression" | "function"
) {
let start_line = value.start_position().row as u32 + 1;
let end_line = value.end_position().row as u32 + 1;
let qn = dedupe_qn(
make_qn(ctx.file_path, "default", ctx.project, ctx.current_parent),
start_line,
result,
);
let signature = node_text(value, source).map(String::from);
build_and_push_function(
"default".to_string(),
qn,
start_line,
end_line,
signature,
true,
ctx,
result,
);
}
}
visit_children(node, source, ctx, result);
}
"call_expression" => {
extract_call(node, source, ctx, result);
visit_children(node, source, ctx, result);
}
"lexical_declaration" | "variable_declaration" => {
extract_lexical_declaration(node, source, ctx, result);
if ctx.current_func.is_some() {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "variable_declarator" {
if let Some(name_node) = child.child_by_field_name("name") {
if let Some(name) = identifier_text(name_node, source) {
if let Some(func) = ctx.current_func {
result.writes.push(WriteInfo {
writer_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: child.start_position().row as u32 + 1,
});
}
}
}
}
}
}
}
visit_children(node, source, ctx, result);
}
"variable_declarator" => {
let var_name = node
.child_by_field_name("name")
.and_then(|n| node_text(n, source).map(String::from));
let parent = var_name.as_deref().or(ctx.current_parent);
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: ctx.current_func,
current_parent: parent,
resolver: ctx.resolver,
};
visit_children(node, source, &child_ctx, result);
}
"assignment_expression" => {
extract_assignment(node, source, result);
if let Some(func) = ctx.current_func {
if let Some(left) = node.child_by_field_name("left") {
if let Some(name) = identifier_text(left, source) {
result.writes.push(WriteInfo {
writer_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: node.start_position().row as u32 + 1,
});
}
}
}
visit_children(node, source, ctx, result);
}
"update_expression" => {
if let Some(func) = ctx.current_func {
if let Some(arg) = node.child_by_field_name("argument") {
if let Some(name) = identifier_text(arg, source) {
result.writes.push(WriteInfo {
writer_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: node.start_position().row as u32 + 1,
});
}
}
}
visit_children(node, source, ctx, result);
}
"identifier" => {
if let Some(func) = ctx.current_func {
if is_ts_read_position(node) {
if let Some(name) = node_text(node, source).map(String::from) {
result.reads.push(ReadInfo {
reader_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: node.start_position().row as u32 + 1,
});
}
}
}
visit_children(node, source, ctx, result);
}
_ => {
visit_children(node, source, ctx, result);
}
}
}
fn visit_children(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
visit_node(child, source, ctx, result);
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_and_push_function(
name: String,
qn: String,
start_line: u32,
end_line: u32,
signature: Option<String>,
is_exported: bool,
ctx: &VisitContext<'_>,
result: &mut ExtractResult,
) {
let mut builder = ModelNode::builder(NodeLabel::Function, name, qn)
.file_path(ctx.file_path)
.start_line(start_line)
.end_line(end_line)
.language(Language::TypeScript)
.project(ctx.project)
.is_exported(is_exported)
.is_global(true);
if let Some(sig) = signature {
builder = builder.signature(sig);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_function(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let name = match node.child_by_field_name("name") {
Some(n) => match node_text(n, source).map(String::from) {
Some(s) => s,
None => return,
},
None => {
let is_default_export = node
.parent()
.map(|p| p.kind() == "export_statement")
.unwrap_or(false);
if !is_default_export {
return;
}
"default".to_string()
}
};
let is_exported = is_exported(node);
let signature = node_text(node, source).map(String::from);
let is_top_level = matches!(
node.parent().map(|p| p.kind()),
Some("program") | Some("export_statement") | None
);
let disambiguator = if is_top_level {
None
} else {
let line = node.start_position().row as u32 + 1;
Some(format!("L{line}"))
};
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, disambiguator.as_deref()),
node.start_position().row as u32 + 1,
result,
);
build_and_push_function(
name,
qn,
node.start_position().row as u32 + 1,
node.end_position().row as u32 + 1,
signature,
is_exported,
ctx,
result,
);
}
fn extract_class(
node: Node,
source: &str,
file_path: &str,
project: &str,
result: &mut ExtractResult,
) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Some(name) = node_text(name_node, source).map(String::from) else {
return;
};
let is_exported = is_exported(node);
let qn = make_qn(file_path, &name, project, None);
let model_node = ModelNode::builder(NodeLabel::Class, name, qn)
.file_path(file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::TypeScript)
.project(project)
.is_exported(is_exported)
.is_global(true)
.build();
add_definition_edges(file_path, project, &model_node, result);
result.push_node(model_node);
}
fn extract_method(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Some(name) = node_text(name_node, source).map(String::from) else {
return;
};
let parent = ctx.current_parent;
let disambiguator = match parent {
Some(p) => Some(p.to_string()),
None => {
let line = node.start_position().row as u32 + 1;
Some(format!("L{line}"))
}
};
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, disambiguator.as_deref()),
node.start_position().row as u32 + 1,
result,
);
let mut builder = ModelNode::builder(NodeLabel::Method, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::TypeScript)
.project(ctx.project)
.is_global(false);
if let Some(parent) = ctx.current_parent {
builder = builder.parent_qn(parent);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_named_item(
node: Node,
label: NodeLabel,
source: &str,
file_path: &str,
project: &str,
result: &mut ExtractResult,
) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Some(name) = node_text(name_node, source).map(String::from) else {
return;
};
let is_exported = is_exported(node);
let qn = make_qn(file_path, &name, project, None);
let model_node = ModelNode::builder(label, name, qn)
.file_path(file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::TypeScript)
.project(project)
.is_exported(is_exported)
.is_global(true)
.build();
add_definition_edges(file_path, project, &model_node, result);
result.push_node(model_node);
}
fn extract_import(node: Node, source: &str, result: &mut ExtractResult) {
let source_file = node
.child_by_field_name("source")
.and_then(|n| node_text(n, source).map(String::from))
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
.unwrap_or_default();
let mut imported_names = Vec::new();
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "import_clause" {
collect_imported_names(child, source, &mut imported_names);
}
}
}
result.imports.push(ImportInfo {
source_file,
imported_names,
line: node.start_position().row as u32 + 1,
});
}
fn collect_imported_names(node: Node, source: &str, names: &mut Vec<String>) {
match node.kind() {
"import_specifier" => {
if let Some(name_node) = node.child_by_field_name("name") {
if let Some(name) = node_text(name_node, source).map(String::from) {
names.push(name);
return;
}
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "identifier" {
if let Some(name) = node_text(child, source).map(String::from) {
names.push(name);
return;
}
}
}
}
}
"namespace_import" => {
if let Some(alias) = node.child_by_field_name("alias") {
if let Some(name) = node_text(alias, source).map(String::from) {
names.push(name);
return;
}
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "identifier" {
if let Some(name) = node_text(child, source).map(String::from) {
names.push(name);
return;
}
}
}
}
}
"identifier" => {
if let Some(name) = node_text(node, source).map(String::from) {
names.push(name);
}
}
_ => {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
collect_imported_names(child, source, names);
}
}
}
}
}
fn extract_call(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(func_node) = node.child_by_field_name("function") else {
return;
};
let Some(callee) = callee_name(func_node, source) else {
return;
};
let args = call_arguments(node, source);
let caller_qn = ctx
.current_func
.map(|name| make_qn(ctx.file_path, name, ctx.project, ctx.current_parent));
result.calls.push(CallInfo {
caller_qn,
callee_name: callee,
line: node.start_position().row as u32 + 1,
args,
});
}
fn extract_variable_declaration(node: Node, source: &str, result: &mut ExtractResult) {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "variable_declarator" {
extract_variable_declarator(child, source, result);
}
}
}
}
fn is_const_declaration(node: Node) -> bool {
for i in 0..node.child_count() as u32 {
if let Some(child) = node.child(i) {
if child.is_named() {
return false;
}
return child.kind() == "const";
}
}
false
}
fn is_top_level_declaration(node: Node) -> bool {
matches!(
node.parent().map(|p| p.kind()),
Some("program") | Some("export_statement") | None
)
}
fn extract_lexical_declaration(
node: Node,
source: &str,
ctx: &VisitContext<'_>,
result: &mut ExtractResult,
) {
extract_variable_declaration(node, source, result);
let is_const = is_const_declaration(node);
let is_top_level = is_top_level_declaration(node);
if !is_const {
return;
}
for i in 0..node.named_child_count() as u32 {
let Some(child) = node.named_child(i) else {
continue;
};
if child.kind() != "variable_declarator" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
if name_node.kind() != "identifier" {
continue;
}
let Some(name) = node_text(name_node, source).map(String::from) else {
continue;
};
let start_line = child.start_position().row as u32 + 1;
let end_line = child.end_position().row as u32 + 1;
let value_node = child.child_by_field_name("value");
let value_kind = value_node.map(|v| v.kind());
if matches!(
value_kind,
Some("arrow_function") | Some("function_expression")
) {
let is_exported = is_exported(node);
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, ctx.current_parent),
start_line,
result,
);
let signature = node_text(child, source).map(String::from);
let mut builder = ModelNode::builder(NodeLabel::Function, name.clone(), qn)
.file_path(ctx.file_path)
.start_line(start_line)
.end_line(end_line)
.language(Language::TypeScript)
.project(ctx.project)
.is_exported(is_exported)
.is_global(true);
if let Some(sig) = signature {
builder = builder.signature(sig);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
continue;
}
if !is_top_level {
continue;
}
let is_exported = is_exported(node);
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, ctx.current_parent),
start_line,
result,
);
let signature = value_node.and_then(|v| node_text(v, source).map(String::from));
let mut builder = ModelNode::builder(NodeLabel::Const, name, qn)
.file_path(ctx.file_path)
.start_line(start_line)
.end_line(end_line)
.language(Language::TypeScript)
.project(ctx.project)
.is_exported(is_exported)
.is_global(true);
if let Some(sig) = signature {
builder = builder.signature(sig);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
}
fn extract_variable_declarator(node: Node, source: &str, result: &mut ExtractResult) {
let Some(name_node) = node.child_by_field_name("name") else {
return;
};
let Some(target) = assignment_target_name(name_node, source) else {
return;
};
let value_node = node.child_by_field_name("value");
let (source_name, is_return_assign) = match value_node {
Some(v) => {
let is_call = v.kind() == "call_expression";
let name = if is_call {
v.child_by_field_name("function")
.and_then(|f| callee_name(f, source))
.unwrap_or_default()
} else {
callee_name(v, source).unwrap_or_default()
};
(name, is_call)
}
None => (String::new(), false),
};
result.assignments.push(AssignInfo {
target_name: target,
source_name,
line: node.start_position().row as u32 + 1,
is_return_assign,
});
}
fn extract_assignment(node: Node, source: &str, result: &mut ExtractResult) {
let Some(left_node) = node.child_by_field_name("left") else {
return;
};
let Some(target) = assignment_target_name(left_node, source) else {
return;
};
let right_node = node.child_by_field_name("right");
let (source_name, is_return_assign) = match right_node {
Some(v) => {
let is_call = v.kind() == "call_expression";
let name = if is_call {
v.child_by_field_name("function")
.and_then(|f| callee_name(f, source))
.unwrap_or_default()
} else {
callee_name(v, source).unwrap_or_default()
};
(name, is_call)
}
None => (String::new(), false),
};
result.assignments.push(AssignInfo {
target_name: target,
source_name,
line: node.start_position().row as u32 + 1,
is_return_assign,
});
}
fn is_exported(node: Node) -> bool {
let Some(parent) = node.parent() else {
return false;
};
parent.kind() == "export_statement"
}
fn callee_name(node: Node, source: &str) -> Option<String> {
match node.kind() {
"identifier" | "type_identifier" | "property_identifier" => {
node_text(node, source).map(String::from)
}
"member_expression" => {
let property = node.child_by_field_name("property")?;
node_text(property, source).map(String::from)
}
"call_expression" => {
let func = node.child_by_field_name("function")?;
callee_name(func, source)
}
"parenthesized_expression" => {
let inner = node.named_child(0)?;
callee_name(inner, source)
}
_ => None,
}
}
fn assignment_target_name(node: Node, source: &str) -> Option<String> {
match node.kind() {
"identifier" | "property_identifier" => node_text(node, source).map(String::from),
"member_expression" => {
let property = node.child_by_field_name("property")?;
node_text(property, source).map(String::from)
}
"array_pattern" | "object_pattern" => {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if let Some(name) = assignment_target_name(child, source) {
return Some(name);
}
}
}
None
}
_ => {
let text = node_text(node, source)?;
if text
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
&& text
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '_' || c == '$')
{
Some(text.to_string())
} else {
None
}
}
}
}
fn call_arguments(node: Node, source: &str) -> Vec<String> {
let Some(args_node) = node.child_by_field_name("arguments") else {
return Vec::new();
};
let mut args = Vec::new();
for i in 0..args_node.named_child_count() as u32 {
if let Some(arg) = args_node.named_child(i) {
if let Ok(text) = arg.utf8_text(source.as_bytes()) {
args.push(text.to_string());
}
}
}
args
}
fn node_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
node.utf8_text(source.as_bytes()).ok()
}
fn identifier_text(node: Node, source: &str) -> Option<String> {
if node.kind() == "identifier" {
node_text(node, source).map(String::from)
} else {
None
}
}
fn is_ts_read_position(node: Node) -> bool {
let Some(parent) = node.parent() else {
return false;
};
match parent.kind() {
"binary_expression"
| "unary_expression"
| "parenthesized_expression"
| "return_statement"
| "argument_list"
| "subscript_expression"
| "conditional_expression"
| "template_substitution"
| "await_expression" => true,
"call_expression" => !is_at_field(node, parent, "function"),
"new_expression" => !is_at_field(node, parent, "function"),
"assignment_expression" => !is_at_field(node, parent, "left"),
"member_expression" => is_at_field(node, parent, "object"),
"variable_declarator"
| "update_expression"
| "lexical_declaration"
| "variable_declaration"
| "function_declaration"
| "method_definition"
| "class_declaration"
| "import_specifier"
| "namespace_import"
| "import_clause"
| "export_statement" => false,
_ => false,
}
}
fn is_at_field(node: Node, parent: Node, field: &str) -> bool {
parent
.child_by_field_name(field)
.is_some_and(|f| f.byte_range() == node.byte_range())
}
fn make_qn(file_path: &str, name: &str, project: &str, parent: Option<&str>) -> String {
FqnGenerator::generate(project, file_path, name, Language::TypeScript, parent)
}
fn add_definition_edges(
file_path: &str,
project: &str,
node: &ModelNode,
result: &mut ExtractResult,
) {
result.edges.push(Edge::new(
file_path.to_string(),
node.id.clone(),
EdgeType::Defines,
project,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeLabel;
const TS_SOURCE: &str = r#"import { foo } from './foo';
export function add(a: number, b: number): number {
return a + b;
}
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
distance(): number {
return this.x + this.y;
}
}
const result = add(1, 2);
"#;
fn extract(source: &str) -> ExtractResult {
let ext = TypeScriptExtractor::new();
ext.extract(source, "test.ts", "proj")
.expect("extraction should succeed")
}
#[test]
fn language_returns_typescript() {
assert_eq!(TypeScriptExtractor::new().language(), Language::TypeScript);
}
#[test]
fn default_creates_extractor() {
let ext = TypeScriptExtractor::default();
assert_eq!(ext.language(), Language::TypeScript);
}
#[test]
fn extracts_import() {
let result = extract(TS_SOURCE);
assert_eq!(result.imports.len(), 1, "should extract 1 import");
assert_eq!(result.imports[0].source_file, "./foo");
assert!(
result.imports[0]
.imported_names
.contains(&"foo".to_string()),
"imported names should contain foo: {:?}",
result.imports[0].imported_names
);
}
#[test]
fn extracts_exported_function() {
let result = extract(TS_SOURCE);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
assert_eq!(funcs.len(), 1, "should extract 1 function (add)");
assert_eq!(funcs[0].name, "add");
assert!(funcs[0].is_exported, "add should be exported");
assert_eq!(funcs[0].language, Some(Language::TypeScript));
assert_eq!(funcs[0].project, "proj");
assert_eq!(funcs[0].file_path.as_deref(), Some("test.ts"));
}
#[test]
fn extracts_class() {
let result = extract(TS_SOURCE);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Point");
}
#[test]
fn extracts_methods() {
let result = extract(TS_SOURCE);
let methods: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Method)
.collect();
let names: Vec<_> = methods.iter().map(|n| n.name.as_str()).collect();
assert!(
names.contains(&"constructor"),
"should extract constructor: {:?}",
names
);
assert!(
names.contains(&"distance"),
"should extract distance method: {:?}",
names
);
}
#[test]
fn extracts_call_to_add() {
let result = extract(TS_SOURCE);
let callees: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
callees.contains(&"add"),
"should extract call to add: {:?}",
callees
);
}
#[test]
fn call_has_line_and_args() {
let result = extract(TS_SOURCE);
let call = result
.calls
.iter()
.find(|c| c.callee_name == "add")
.expect("call to add should exist");
assert_eq!(call.args.len(), 2, "add(1, 2) should have 2 args");
}
#[test]
fn extracts_assignment() {
let result = extract(TS_SOURCE);
let assign = result
.assignments
.iter()
.find(|a| a.target_name == "result")
.expect("should find `const result = add(1, 2)` assignment");
assert_eq!(assign.source_name, "add");
assert!(
assign.is_return_assign,
"assignment from function call should be return assign"
);
}
#[test]
fn creates_defines_edges() {
let result = extract(TS_SOURCE);
let defines_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Defines)
.count();
let node_count = result.nodes.len();
assert_eq!(defines_count, node_count);
let contains_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Contains)
.count();
assert_eq!(
contains_count, 0,
"B1 fix: no CONTAINS edges should be emitted"
);
}
#[test]
fn qualified_name_uses_file_path_and_name() {
let result = extract(TS_SOURCE);
let add = result.nodes.iter().find(|n| n.name == "add").unwrap();
assert_eq!(add.qualified_name, "proj.test.ts.add");
}
#[test]
fn empty_source_returns_empty_result() {
let result = extract("");
assert!(result.is_empty());
}
#[test]
fn function_has_signature() {
let result = extract(TS_SOURCE);
let add = result.nodes.iter().find(|n| n.name == "add").unwrap();
assert!(add.signature.is_some(), "function should have a signature");
assert!(add.signature.as_deref().unwrap().contains("add"));
}
#[test]
fn non_exported_function_not_marked_exported() {
let src = "function private_fn() {}";
let result = extract(src);
let func = result
.nodes
.iter()
.find(|n| n.name == "private_fn")
.unwrap();
assert!(
!func.is_exported,
"non-exported function should not be exported"
);
}
#[test]
fn extracts_interface_as_interface_node() {
let src = "interface Drawable { draw(): void; }";
let result = extract(src);
let interfaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(interfaces.len(), 1, "interface should map to Interface");
assert_eq!(interfaces[0].name, "Drawable");
let traits: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Trait)
.collect();
assert!(traits.is_empty(), "interface must not map to Trait");
}
#[test]
fn extracts_enum() {
let src = "enum Color { Red, Green, Blue }";
let result = extract(src);
let enums: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Enum)
.collect();
assert_eq!(enums.len(), 1);
assert_eq!(enums[0].name, "Color");
}
#[test]
fn extracts_type_alias() {
let src = "type Score = number;";
let result = extract(src);
let aliases: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::TypeAlias)
.collect();
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].name, "Score");
}
#[test]
fn handles_default_import() {
let src = "import foo from './mod';";
let result = extract(src);
assert_eq!(result.imports.len(), 1);
assert_eq!(result.imports[0].source_file, "./mod");
assert!(result.imports[0]
.imported_names
.contains(&"foo".to_string()));
}
#[test]
fn handles_namespace_import() {
let src = "import * as utils from './utils';";
let result = extract(src);
assert_eq!(result.imports.len(), 1);
assert_eq!(result.imports[0].source_file, "./utils");
assert!(result.imports[0]
.imported_names
.contains(&"utils".to_string()));
}
#[test]
fn handles_method_call() {
let src = "class A { foo() { this.bar(); } }";
let result = extract(src);
let callees: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(callees.contains(&"bar"), "should extract this.bar() call");
}
#[test]
fn handles_member_assignment() {
let src = "class A { foo(x: number) { this.x = x; } }";
let result = extract(src);
assert!(
result.assignments.iter().any(|a| a.target_name == "x"),
"should find this.x = x assignment"
);
}
#[test]
fn result_language_is_typescript() {
let result = extract(TS_SOURCE);
assert_eq!(result.language, Language::TypeScript);
assert_eq!(result.file_path, "test.ts");
}
#[test]
fn exported_interface_is_marked_exported() {
let src = "export interface Drawable { draw(): void; }";
let result = extract(src);
let interfaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(interfaces.len(), 1);
assert!(
interfaces[0].is_exported,
"exported interface should be marked exported"
);
}
#[test]
fn call_in_function_has_dotted_fqn_caller_qn() {
let src = "function caller(): void {\n callee();\n}\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.ts", "proj")
.expect("extraction should succeed");
let call = result
.calls
.iter()
.find(|c| c.callee_name == "callee")
.expect("should find call to callee");
assert_eq!(
call.caller_qn.as_deref(),
Some("proj.tmp.demo.main.ts.caller"),
"caller_qn should be the dotted FQN of the enclosing function"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller function node");
assert_eq!(
call.caller_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"caller_qn must match the caller function node id"
);
}
#[test]
fn top_level_call_has_none_caller_qn() {
let src = "callee();\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "main.ts", "proj")
.expect("extraction should succeed");
let call = result
.calls
.iter()
.find(|c| c.callee_name == "callee")
.expect("should find top-level call to callee");
assert!(
call.caller_qn.is_none(),
"top-level call should have None caller_qn"
);
}
#[test]
fn method_without_parent_uses_line_disambiguator() {
let src = "\
Object.defineProperty(el, 'boom', {
enumerable: true,
get() { throw new Error('a'); },
});
Object.defineProperty(el2, 'boom', {
enumerable: true,
get() { throw new Error('b'); },
});
";
let result = extract(src);
let gets: Vec<_> = result.nodes.iter().filter(|n| n.name == "get").collect();
assert_eq!(gets.len(), 2, "should extract two `get` methods");
assert_ne!(gets[0].qualified_name, gets[1].qualified_name);
for g in &gets {
assert!(
g.qualified_name.contains("#L"),
"expected line disambiguator: {}",
g.qualified_name
);
}
}
#[test]
fn nested_function_declaration_uses_line_disambiguator() {
let src = "\
export function topLevel(): void {}
describe('suite', () => {
function helper(): void {}
it('test', () => {
function helper(): void {}
});
});
";
let result = extract(src);
let toplevel = result
.nodes
.iter()
.find(|n| n.name == "topLevel")
.expect("topLevel should exist");
assert_eq!(toplevel.qualified_name, "proj.test.ts.topLevel");
let helpers: Vec<_> = result.nodes.iter().filter(|n| n.name == "helper").collect();
assert_eq!(helpers.len(), 2);
assert_ne!(helpers[0].qualified_name, helpers[1].qualified_name);
for h in &helpers {
assert!(
h.qualified_name.contains("#L"),
"expected line disambiguator: {}",
h.qualified_name
);
}
}
#[test]
fn same_name_method_in_same_parent_scope_disambiguated() {
let src = "\
function setupFirst() {
const phases = {
execute() { return 1; }
};
}
function setupSecond() {
const phases = {
execute() { return 2; }
};
}
";
let result = extract(src);
let executes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.name == "execute")
.collect();
assert_eq!(executes.len(), 2, "should extract two `execute` methods");
assert_ne!(executes[0].qualified_name, executes[1].qualified_name);
let (first, second) = (&executes[0], &executes[1]);
assert!(
first.qualified_name.contains("#phases"),
"first qn: {}",
first.qualified_name
);
assert!(
second.qualified_name.contains("#phases"),
"second qn: {}",
second.qualified_name
);
assert!(
second.qualified_name.contains("#L"),
"second should have line dedupe: {}",
second.qualified_name
);
}
#[test]
fn extracts_top_level_const_as_const_node() {
let src = "export const MAX_RETRIES = 3;";
let result = extract(src);
let consts: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Const)
.collect();
assert_eq!(consts.len(), 1, "should extract 1 Const node");
assert_eq!(consts[0].name, "MAX_RETRIES");
assert_eq!(consts[0].language, Some(Language::TypeScript));
assert!(
consts[0].is_exported,
"exported const should be marked exported"
);
assert!(consts[0].is_global, "top-level const should be global");
}
#[test]
fn does_not_extract_nested_const_as_const_node() {
let src = "function f() { const local = 1; return local; }";
let result = extract(src);
let consts: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Const)
.collect();
assert!(consts.is_empty(), "nested const must NOT be a Const node");
}
#[test]
fn does_not_extract_let_as_const_node() {
let src = "export let mutable = 1;";
let result = extract(src);
let consts: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Const)
.collect();
assert!(consts.is_empty(), "`let` must NOT produce a Const node");
}
#[test]
fn extracts_arrow_function_const_as_function_node() {
let src = "export const handler = () => { return 42; };";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "handler")
.collect();
assert_eq!(
funcs.len(),
1,
"arrow function const should be a Function node"
);
assert_eq!(funcs[0].language, Some(Language::TypeScript));
assert!(
funcs[0].is_exported,
"exported arrow function should be marked exported"
);
let consts: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Const && n.name == "handler")
.collect();
assert!(
consts.is_empty(),
"arrow function const must not double-count as Const"
);
}
#[test]
fn extracts_function_expression_const_as_function_node() {
let src = "const callback = function() { return 0; };";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "callback")
.collect();
assert_eq!(
funcs.len(),
1,
"function expression const should be a Function node"
);
}
#[test]
fn extracts_anonymous_export_default_function() {
let src = "export default function() { return 42; }";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "default")
.collect();
assert_eq!(
funcs.len(),
1,
"anonymous export default function should be a Function named 'default'"
);
assert!(
funcs[0].is_exported,
"export default function should be marked exported"
);
}
#[test]
fn extracts_anonymous_export_default_arrow_function() {
let src = "export default () => { return 42; }";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "default")
.collect();
assert_eq!(
funcs.len(),
1,
"anonymous export default arrow function should be a Function named 'default'"
);
assert!(
funcs[0].is_exported,
"export default arrow function should be marked exported"
);
}
#[test]
fn named_export_default_function_not_double_extracted() {
let src = "export default function foo() { return 42; }";
let result = extract(src);
let foo_funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "foo")
.collect();
let default_funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function && n.name == "default")
.collect();
assert_eq!(
foo_funcs.len(),
1,
"named default export should produce one Function named 'foo'"
);
assert!(
default_funcs.is_empty(),
"named default export must NOT produce a synthetic 'default' Function"
);
assert!(
foo_funcs[0].is_exported,
"named default export should be marked exported"
);
}
#[test]
fn read_in_function_has_dotted_fqn_reader_qn() {
let src = "function caller(x: number): number {\n return x;\n}\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.ts", "proj")
.expect("extraction should succeed");
let read = result
.reads
.iter()
.find(|r| r.var_name == "x")
.expect("should find a read of x");
assert_eq!(
read.reader_qn.as_deref(),
Some("proj.tmp.demo.main.ts.caller"),
"reader_qn should be the dotted FQN of the enclosing function"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller function node");
assert_eq!(
read.reader_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"reader_qn must match the caller function node id"
);
}
#[test]
fn write_in_function_let_declaration_has_dotted_fqn_writer_qn() {
let src = "function caller(x: number): number {\n let y = x + 1;\n return y;\n}\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.ts", "proj")
.expect("extraction should succeed");
let write = result
.writes
.iter()
.find(|w| w.var_name == "y")
.expect("should find a write of y");
assert_eq!(
write.writer_qn.as_deref(),
Some("proj.tmp.demo.main.ts.caller"),
"writer_qn should be the dotted FQN of the enclosing function"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller function node");
assert_eq!(
write.writer_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"writer_qn must match the caller function node id"
);
}
#[test]
fn write_in_function_assignment_has_dotted_fqn_writer_qn() {
let src = "function caller(): number {\n let y = 1;\n y = y * 2;\n return y;\n}\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.ts", "proj")
.expect("extraction should succeed");
let y_writes: Vec<_> = result.writes.iter().filter(|w| w.var_name == "y").collect();
assert!(
y_writes.len() >= 2,
"y should be written at least twice (let + assignment): {:?}",
y_writes
);
for w in y_writes {
assert_eq!(
w.writer_qn.as_deref(),
Some("proj.tmp.demo.main.ts.caller"),
"writer_qn should be the dotted FQN of the enclosing function"
);
}
}
#[test]
fn update_expression_is_write() {
let src =
"function caller(x: number): number {\n let y = x;\n y++;\n return y;\n}\n";
let ext = TypeScriptExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.ts", "proj")
.expect("extraction should succeed");
let y_writes: Vec<_> = result.writes.iter().filter(|w| w.var_name == "y").collect();
assert!(
y_writes.len() >= 2,
"y should be written at least twice (let + update): {:?}",
y_writes
);
for w in y_writes {
assert_eq!(
w.writer_qn.as_deref(),
Some("proj.tmp.demo.main.ts.caller"),
"writer_qn should be the dotted FQN of the enclosing function"
);
}
}
#[test]
fn extracts_export_named_from_reexport() {
let src = "export { foo } from './mod';\n";
let result = extract(src);
let reexports: Vec<_> = result
.imports
.iter()
.filter(|i| i.source_file == "./mod")
.collect();
assert_eq!(
reexports.len(),
1,
"export {{...}} from should produce 1 ImportInfo: {:?}",
result.imports
);
}
#[test]
fn extracts_export_star_from_reexport() {
let src = "export * from './mod';\n";
let result = extract(src);
let reexports: Vec<_> = result
.imports
.iter()
.filter(|i| i.source_file == "./mod")
.collect();
assert_eq!(
reexports.len(),
1,
"export * from should produce 1 ImportInfo: {:?}",
result.imports
);
}
#[test]
fn extracts_export_type_from_reexport() {
let src = "export type { Foo } from './mod';\n";
let result = extract(src);
let reexports: Vec<_> = result
.imports
.iter()
.filter(|i| i.source_file == "./mod")
.collect();
assert_eq!(
reexports.len(),
1,
"export type {{...}} from should produce 1 ImportInfo: {:?}",
result.imports
);
}
#[test]
fn export_from_does_not_duplicate_for_plain_export() {
let src = "const foo = 1;\nexport { foo };\n";
let result = extract(src);
assert!(
result.imports.is_empty(),
"plain export without `from` should not produce ImportInfo: {:?}",
result.imports
);
}
fn parse_ts(source: &str) -> tree_sitter::Tree {
let mut parser =
crate::parse::parser_factory::ParserFactory::create_parser(Language::TypeScript)
.expect("parser");
parser.parse(source, None).expect("parse")
}
#[test]
fn variable_declarator_without_value_has_empty_source() {
let src = "let x;";
let result = extract(src);
let assign = result.assignments.iter().find(|a| a.target_name == "x");
assert!(
assign.is_some(),
"should produce AssignInfo for `let x;`: {:?}",
result.assignments
);
let a = assign.unwrap();
assert_eq!(a.source_name, "");
assert!(!a.is_return_assign);
}
#[test]
fn assignment_from_call_in_function_covers_call_branch() {
let src = "function f() { x = foo(); }";
let result = extract(src);
let assign = result
.assignments
.iter()
.find(|a| a.target_name == "x" && a.source_name == "foo");
assert!(
assign.is_some(),
"should find `x = foo()` assignment: {:?}",
result.assignments
);
assert!(assign.unwrap().is_return_assign);
}
#[test]
fn chained_call_covers_callee_name_call_expression() {
let src = "function f() { foo()(); }";
let result = extract(src);
assert!(
result.calls.iter().any(|c| c.callee_name == "foo"),
"should extract chained call: {:?}",
result.calls
);
}
#[test]
fn parenthesized_call_covers_callee_name_parenthesized() {
let src = "function f() { (foo)(); }";
let result = extract(src);
assert!(
result.calls.iter().any(|c| c.callee_name == "foo"),
"should extract parenthesized call: {:?}",
result.calls
);
}
#[test]
fn array_destructuring_covers_assignment_target_pattern() {
let src = "const [a, b] = arr;";
let result = extract(src);
let assign = result.assignments.iter().find(|a| a.target_name == "a");
assert!(
assign.is_some(),
"should extract array destructuring target: {:?}",
result.assignments
);
assert_eq!(assign.unwrap().source_name, "arr");
}
#[test]
fn object_destructuring_shorthand_covers_fallback_alphanumeric() {
let src = "const {a, b} = obj;";
let result = extract(src);
let assign = result.assignments.iter().find(|a| a.target_name == "a");
assert!(
assign.is_some(),
"should extract object shorthand destructuring: {:?}",
result.assignments
);
assert_eq!(assign.unwrap().source_name, "obj");
}
#[test]
fn object_destructuring_pair_covers_fallback_non_alphanumeric() {
let src = "const {a: b} = obj;";
let result = extract(src);
let _ = result.assignments;
}
#[test]
fn is_exported_returns_false_for_root_node() {
let tree = parse_ts("const x = 1;");
assert!(!is_exported(tree.root_node()));
}
#[test]
fn is_ts_read_position_returns_false_for_root_node() {
let tree = parse_ts("const x = 1;");
assert!(!is_ts_read_position(tree.root_node()));
}
#[test]
fn is_const_declaration_returns_false_for_empty_program() {
let tree = parse_ts("");
assert!(!is_const_declaration(tree.root_node()));
}
#[test]
fn is_const_declaration_returns_false_when_first_child_is_named() {
let tree = parse_ts("const x = 1;");
assert!(!is_const_declaration(tree.root_node()));
}
#[test]
fn namespace_import_extracts_alias_name() {
let src = "import * as fs from 'fs';";
let result = extract(src);
assert_eq!(result.imports.len(), 1, "should extract 1 namespace import");
assert_eq!(result.imports[0].source_file, "fs");
assert!(
result.imports[0].imported_names.contains(&"fs".to_string()),
"imported names should contain fs: {:?}",
result.imports[0].imported_names
);
}
#[test]
fn default_import_extracts_name() {
let src = "import express from 'express';";
let result = extract(src);
assert_eq!(result.imports.len(), 1, "should extract 1 default import");
assert_eq!(result.imports[0].source_file, "express");
assert!(
result.imports[0]
.imported_names
.contains(&"express".to_string()),
"imported names should contain express: {:?}",
result.imports[0].imported_names
);
}
#[test]
fn export_default_anonymous_function_produces_default_node() {
let src = "export default function() { return 42; }";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
assert!(
funcs.iter().any(|f| f.name == "default"),
"should have a Function node named 'default': {:?}",
funcs.iter().map(|f| &f.name).collect::<Vec<_>>()
);
}
#[test]
fn assignment_expression_inside_function_produces_write() {
let src = "function foo() { x = 5; }";
let result = extract(src);
assert!(
result.writes.iter().any(|w| w.var_name == "x"),
"should have a WriteInfo for x: {:?}",
result.writes
);
}
#[test]
fn update_expression_inside_function_produces_write() {
let src = "function foo() { x++; }";
let result = extract(src);
assert!(
result.writes.iter().any(|w| w.var_name == "x"),
"should have a WriteInfo for x from update expression: {:?}",
result.writes
);
}
#[test]
fn decorator_on_class_does_not_break_extraction() {
let src = "@decorator\nclass Foo {}";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(classes.len(), 1, "should still extract Foo class");
assert_eq!(classes[0].name, "Foo");
}
#[test]
fn decorator_on_method_does_not_break_extraction() {
let src = "class Foo { @log bar() {} }";
let result = extract(src);
let methods: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Method)
.collect();
assert_eq!(methods.len(), 1, "should still extract bar method");
assert_eq!(methods[0].name, "bar");
}
#[test]
fn generic_constraint_does_not_break_extraction() {
let src = "function foo<T extends number>(x: T): T { return x; }";
let result = extract(src);
let func = result
.nodes
.iter()
.find(|n| n.name == "foo")
.expect("should find foo function");
assert_eq!(func.label, NodeLabel::Function);
}
#[test]
fn generic_interface_does_not_break_extraction() {
let src = "interface Repo<T> { get(): T; }";
let result = extract(src);
let ifaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(ifaces.len(), 1);
assert_eq!(ifaces[0].name, "Repo");
}
#[test]
fn comment_only_source_returns_empty_result() {
let result = extract("// just a comment\n");
assert!(
result.is_empty(),
"comment-only file should produce no nodes"
);
}
#[test]
fn abstract_class_does_not_break_extraction() {
let src = "abstract class Animal { abstract makeSound(): void; }";
let result = extract(src);
assert!(result.nodes.iter().all(|n| !n.name.is_empty()));
}
#[test]
fn multiple_interfaces_extracted() {
let src = "interface A {} interface B {} interface C {}";
let result = extract(src);
let ifaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(ifaces.len(), 3);
}
#[test]
fn enum_with_string_values() {
let src = "enum Direction { Up = 'UP', Down = 'DOWN' }";
let result = extract(src);
let enums: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Enum)
.collect();
assert_eq!(enums.len(), 1);
assert_eq!(enums[0].name, "Direction");
}
#[test]
fn const_enum_declaration() {
let src = "const enum Color { Red, Green, Blue }";
let result = extract(src);
let enums: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Enum)
.collect();
assert_eq!(enums.len(), 1);
assert_eq!(enums[0].name, "Color");
}
#[test]
fn type_alias_with_union() {
let src = "type Status = 'active' | 'inactive' | 'pending';";
let result = extract(src);
let aliases: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::TypeAlias)
.collect();
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].name, "Status");
}
#[test]
fn generator_function_declaration() {
let src = "function* counter() { yield 1; }";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
assert_eq!(funcs.len(), 1);
assert_eq!(funcs[0].name, "counter");
}
#[test]
fn class_implements_interface_does_not_break_extraction() {
let src = "interface IFoo {} class Foo implements IFoo {}";
let result = extract(src);
assert!(
result
.nodes
.iter()
.any(|n| n.label == NodeLabel::Interface && n.name == "IFoo"),
"should extract IFoo interface"
);
assert!(
result
.nodes
.iter()
.any(|n| n.label == NodeLabel::Class && n.name == "Foo"),
"should extract Foo class"
);
}
fn find_first_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
if node.kind() == kind {
return Some(node);
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if let Some(found) = find_first_by_kind(child, kind) {
return Some(found);
}
}
}
None
}
#[test]
fn call_arguments_returns_empty_for_node_without_arguments_field() {
let src = "const x = 1;";
let tree = parse_ts(src);
let root = tree.root_node();
let decl = find_first_by_kind(root, "lexical_declaration")
.expect("should find lexical_declaration");
let args = call_arguments(decl, src);
assert!(args.is_empty(), "should return empty vec for non-call node");
}
#[test]
fn collect_imported_names_namespace_import_alias_branch() {
let src = "import * as foo from 'mod';";
let tree = parse_ts(src);
let root = tree.root_node();
let ns_import = find_first_by_kind(root, "namespace_import")
.expect("should find namespace_import node");
let mut names = Vec::new();
collect_imported_names(ns_import, src, &mut names);
assert_eq!(names, vec!["foo".to_string()]);
}
#[test]
fn extract_function_anonymous_in_export_uses_default_name() {
let src = "export default function() {}";
let tree = parse_ts(src);
let root = tree.root_node();
let export_stmt =
find_first_by_kind(root, "export_statement").expect("should find export_statement");
let func_node = export_stmt
.child_by_field_name("value")
.expect("export_statement should have a value field");
let registry = ScopeResolverRegistry::new();
let ctx = VisitContext {
file_path: "test.ts",
project: "proj",
current_func: None,
current_parent: None,
resolver: ®istry,
};
let mut result = ExtractResult::new("test.ts", Language::TypeScript);
extract_function(func_node, src, &ctx, &mut result);
assert!(
result.nodes.iter().any(|n| n.name == "default"),
"should produce a Function node named 'default': {:?}",
result.nodes
);
}
#[test]
fn extract_function_anonymous_not_in_export_returns_early() {
let src = "let x = function() {};";
let tree = parse_ts(src);
let root = tree.root_node();
let func_node = find_first_by_kind(root, "function_expression")
.or_else(|| find_first_by_kind(root, "function"))
.expect("should find a function expression node");
let registry = ScopeResolverRegistry::new();
let ctx = VisitContext {
file_path: "test.ts",
project: "proj",
current_func: None,
current_parent: None,
resolver: ®istry,
};
let mut result = ExtractResult::new("test.ts", Language::TypeScript);
extract_function(func_node, src, &ctx, &mut result);
assert!(
result.nodes.is_empty(),
"should not produce any nodes for anonymous non-export function: {:?}",
result.nodes
);
}
}