use tree_sitter::{Node, Parser};
use crate::error::{CodegraphError, Result};
use crate::graph::types::{
Binding, BindingKind, ByteSpan, EntryPoint, FileFacts, RefRole, Reference, Scope, ScopeId,
ScopeKind, Symbol, SymbolKind, TypeRefContext, Visibility,
};
use crate::lang::Language;
use crate::symbol::Descriptor;
use super::{
ExtractCtx, Extractor, MIN_REF_LEN, attach_reference_scopes, child_text,
collect_call_references, definition_bindings, field_text, import_bindings, innermost_scope,
make_symbol, node_span, node_text, one_line_signature, push_binding, push_ref, push_scope,
push_type_ref,
};
const CALL_QUERY: &str = r#"
[
(call_expression (identifier) @callee)
(call_expression (navigation_expression (_) @qualifier (identifier) @callee))
]
"#;
pub struct KotlinExtractor;
impl Extractor for KotlinExtractor {
fn lang(&self) -> Language {
Language::Kotlin
}
fn extract(&self, source: &str, file: &str) -> Result<FileFacts> {
let ts_language = crate::grammar::kotlin();
let mut parser = Parser::new();
parser
.set_language(&ts_language)
.map_err(|_| CodegraphError::Parse {
path: file.to_owned(),
})?;
let tree = parser
.parse(source, None)
.ok_or_else(|| CodegraphError::Parse {
path: file.to_owned(),
})?;
let root = tree.root_node();
let bytes = source.as_bytes();
let ctx = ExtractCtx {
bytes,
file,
lang: Language::Kotlin,
};
let ns_strings = kotlin_namespaces(&root, bytes, file);
let ns_descriptors: Vec<Descriptor> = ns_strings
.iter()
.cloned()
.map(Descriptor::Namespace)
.collect();
let mut defs = Vec::new();
collect_decls(root, &ns_descriptors, false, &ctx, &mut defs);
let def_bindings = definition_bindings(&defs);
let mut symbols = defs;
let mod_sym = super::module_symbol(Language::Kotlin, &ns_strings, file, source.len());
let module_id = mod_sym.id.to_scip_string();
symbols.push(mod_sym);
let mut references = collect_call_references(
&root,
&ts_language,
CALL_QUERY,
Language::Kotlin,
bytes,
file,
)?;
collect_inheritance(&root, bytes, file, &mut references);
collect_imports(&root, bytes, file, &mut references, &module_id);
collect_type_references(&root, bytes, file, &mut references);
collect_read_references(&root, bytes, file, &mut references);
collect_write_references(&root, bytes, file, &mut references);
let scopes = collect_scopes(&root, source.len());
attach_reference_scopes(&mut references, &scopes);
let mut bindings = collect_bindings(&root, bytes, &scopes);
bindings.extend(def_bindings);
bindings.extend(import_bindings(&references, &scopes));
Ok(FileFacts {
file: file.to_owned(),
lang: Language::Kotlin.as_str().to_owned(),
symbols,
references,
scopes,
bindings,
ffi_exports: Vec::new(),
})
}
}
fn kotlin_namespaces(root: &Node, bytes: &[u8], file: &str) -> Vec<String> {
for child in root.children(&mut root.walk()) {
if child.kind() != "package_header" {
continue;
}
for pkg_child in child.children(&mut child.walk()) {
if pkg_child.kind() == "qualified_identifier" || pkg_child.kind() == "identifier" {
let text = node_text(&pkg_child, bytes);
return text
.split('.')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
}
}
}
let p = file
.strip_suffix(".kts")
.or_else(|| file.strip_suffix(".kt"))
.unwrap_or(file);
let p = p.strip_prefix("src/").unwrap_or(p);
p.split('/')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn read_visibility(node: &Node, bytes: &[u8]) -> Visibility {
for child in node.children(&mut node.walk()) {
if child.kind() != "modifiers" {
continue;
}
for modifier in child.children(&mut child.walk()) {
if modifier.kind() == "visibility_modifier" {
return match node_text(&modifier, bytes) {
"public" => Visibility::Public,
"internal" => Visibility::Internal,
"protected" => Visibility::Protected,
"private" => Visibility::Private,
_ => Visibility::Public,
};
}
}
return Visibility::Public;
}
Visibility::Public
}
fn push_symbol(
out: &mut Vec<Symbol>,
ctx: &ExtractCtx,
node: &Node,
name: String,
kind: SymbolKind,
visibility: Visibility,
descriptors: Vec<Descriptor>,
) {
let signature = one_line_signature(node_text(node, ctx.bytes), &['{', '\n']);
out.push(make_symbol(
ctx,
node,
name,
kind,
visibility,
descriptors,
signature,
));
}
fn emit_type_and_body(
out: &mut Vec<Symbol>,
ctx: &ExtractCtx,
node: Node,
type_name: String,
kind: SymbolKind,
visibility: Visibility,
prefix: &[Descriptor],
) {
let mut type_descriptors = prefix.to_vec();
type_descriptors.push(Descriptor::Type(type_name.clone()));
push_symbol(
out,
ctx,
&node,
type_name,
kind,
visibility,
type_descriptors.clone(),
);
let mut body_cursor = node.walk();
for body_child in node.children(&mut body_cursor) {
if matches!(body_child.kind(), "class_body" | "enum_class_body") {
collect_decls(body_child, &type_descriptors, true, ctx, out);
}
}
}
fn collect_decls(
container: Node,
prefix: &[Descriptor],
inside_type: bool,
ctx: &ExtractCtx,
out: &mut Vec<Symbol>,
) {
let mut cursor = container.walk();
for child in container.children(&mut cursor) {
match child.kind() {
"class_declaration" => handle_class(child, prefix, ctx, out),
"object_declaration" => handle_object(child, prefix, ctx, out),
"companion_object" => handle_companion(child, prefix, ctx, out),
"function_declaration" => handle_function(child, prefix, inside_type, ctx, out),
"property_declaration" => handle_property(child, prefix, ctx, out),
"type_alias" => handle_typealias(child, prefix, ctx, out),
"enum_entry" => handle_enum_entry(child, prefix, ctx, out),
"secondary_constructor" => handle_secondary_constructor(child, prefix, ctx, out),
_ => {}
}
}
}
fn handle_class(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let name_node = match node.child_by_field_name("name") {
Some(n) => n,
None => return,
};
let type_name = node_text(&name_node, ctx.bytes).to_owned();
let sym_kind = if node
.children(&mut node.walk())
.any(|c| c.kind() == "enum_class_body")
{
SymbolKind::Enum
} else {
let prefix_text =
std::str::from_utf8(&ctx.bytes[node.start_byte()..name_node.start_byte()])
.unwrap_or_default();
if prefix_text.split_whitespace().any(|w| w == "interface") {
SymbolKind::Interface
} else {
SymbolKind::Class
}
};
let vis = read_visibility(&node, ctx.bytes);
emit_type_and_body(out, ctx, node, type_name, sym_kind, vis, prefix);
}
fn handle_object(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let type_name = match field_text(&node, "name", ctx.bytes) {
Some(n) => n,
None => return,
};
let vis = read_visibility(&node, ctx.bytes);
emit_type_and_body(out, ctx, node, type_name, SymbolKind::Class, vis, prefix);
}
fn handle_companion(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let type_name = field_text(&node, "name", ctx.bytes).unwrap_or_else(|| "Companion".to_owned());
let vis = read_visibility(&node, ctx.bytes);
emit_type_and_body(out, ctx, node, type_name, SymbolKind::Class, vis, prefix);
}
fn handle_function(
node: Node,
prefix: &[Descriptor],
inside_type: bool,
ctx: &ExtractCtx,
out: &mut Vec<Symbol>,
) {
let name = match field_text(&node, "name", ctx.bytes) {
Some(n) => n,
None => return,
};
let kind = if inside_type {
SymbolKind::Method
} else {
SymbolKind::Function
};
let vis = read_visibility(&node, ctx.bytes);
let is_main = !inside_type && name == "main";
let mut descriptors = prefix.to_vec();
descriptors.push(Descriptor::Method {
name: name.clone(),
disambiguator: String::new(),
});
push_symbol(out, ctx, &node, name, kind, vis, descriptors);
if is_main {
if let Some(s) = out.last_mut() {
s.entry_points.push(EntryPoint::Main);
}
}
}
fn handle_property(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let var_name: Option<String> = {
let mut found = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable_declaration" {
let mut vc = child.walk();
for vc_child in child.children(&mut vc) {
if vc_child.kind() == "identifier" {
found = Some(node_text(&vc_child, ctx.bytes).to_owned());
break;
}
}
break;
}
if child.kind() == "multi_variable_declaration" {
return;
}
}
found
};
let var_name = match var_name {
Some(n) => n,
None => return,
};
let is_var = node.children(&mut node.walk()).any(|c| c.kind() == "var");
let kind = if is_var {
SymbolKind::Static
} else {
SymbolKind::Const
};
let vis = read_visibility(&node, ctx.bytes);
let mut descriptors = prefix.to_vec();
descriptors.push(Descriptor::Term(var_name.clone()));
push_symbol(out, ctx, &node, var_name, kind, vis, descriptors);
}
fn handle_typealias(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let name = match field_text(&node, "type", ctx.bytes) {
Some(n) => n,
None => return,
};
let vis = read_visibility(&node, ctx.bytes);
let mut descriptors = prefix.to_vec();
descriptors.push(Descriptor::Type(name.clone()));
push_symbol(
out,
ctx,
&node,
name,
SymbolKind::TypeAlias,
vis,
descriptors,
);
}
fn handle_enum_entry(node: Node, prefix: &[Descriptor], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let name = match child_text(&node, "identifier", ctx.bytes) {
Some(n) => n,
None => return,
};
let vis = read_visibility(&node, ctx.bytes);
let mut descriptors = prefix.to_vec();
descriptors.push(Descriptor::Term(name.clone()));
push_symbol(out, ctx, &node, name, SymbolKind::Const, vis, descriptors);
}
fn handle_secondary_constructor(
node: Node,
prefix: &[Descriptor],
ctx: &ExtractCtx,
out: &mut Vec<Symbol>,
) {
let vis = read_visibility(&node, ctx.bytes);
let mut descriptors = prefix.to_vec();
descriptors.push(Descriptor::Method {
name: "constructor".to_owned(),
disambiguator: String::new(),
});
push_symbol(
out,
ctx,
&node,
"constructor".to_owned(),
SymbolKind::Method,
vis,
descriptors,
);
}
fn is_non_read_position(node: &Node) -> bool {
let parent = match node.parent() {
Some(p) => p,
None => return true, };
match parent.kind() {
"call_expression" => {
parent.named_children(&mut parent.walk()).next().as_ref() == Some(node)
}
"navigation_expression" => {
let last_ident = parent
.named_children(&mut parent.walk())
.filter(|c| c.kind() == "identifier")
.last();
last_ident.as_ref() == Some(node)
}
"function_declaration"
| "class_declaration"
| "object_declaration"
| "companion_object" => parent.child_by_field_name("name").as_ref() == Some(node),
"type_alias" => parent.child_by_field_name("type").as_ref() == Some(node),
"variable_declaration" => {
parent
.named_children(&mut parent.walk())
.find(|c| c.kind() == "identifier")
.as_ref()
== Some(node)
}
"parameter" | "class_parameter" => {
parent
.named_children(&mut parent.walk())
.find(|c| c.kind() == "identifier")
.as_ref()
== Some(node)
}
"import" | "qualified_identifier" => true,
"package_header" => true,
"user_type" => true,
"type_identifier" => true,
"assignment" => parent.child_by_field_name("left").as_ref() == Some(node),
_ => false,
}
}
fn collect_read_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "identifier" {
let name = node_text(node, bytes);
if name.len() >= MIN_REF_LEN && !is_non_read_position(node) {
push_ref(out, name, node, file, RefRole::Read);
}
return;
}
for child in node.children(&mut node.walk()) {
collect_read_references(&child, bytes, file, out);
}
}
fn collect_write_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "assignment" {
if let Some(lhs) = node.child_by_field_name("left") {
if lhs.kind() == "identifier" {
let name = node_text(&lhs, bytes);
if name.len() >= MIN_REF_LEN {
push_ref(out, name, &lhs, file, RefRole::Write);
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_write_references(&child, bytes, file, out);
}
}
fn type_leaf(node: &Node, bytes: &[u8], file: &str, ctx: TypeRefContext, out: &mut Vec<Reference>) {
match node.kind() {
"type_identifier" => {
let name = node_text(node, bytes);
push_type_ref(out, name, node, file, ctx);
}
"user_type" => {
for child in node.named_children(&mut node.walk()) {
match child.kind() {
"simple_user_type" => {
for inner in child.named_children(&mut child.walk()) {
match inner.kind() {
"type_identifier" => {
let name = node_text(&inner, bytes);
push_type_ref(out, name, &inner, file, ctx);
}
"type_arguments" => {
type_leaf(&inner, bytes, file, TypeRefContext::GenericArg, out);
}
_ => {}
}
}
}
"type_identifier" | "identifier" => {
let name = node_text(&child, bytes);
push_type_ref(out, name, &child, file, ctx);
}
"type_arguments" => {
type_leaf(&child, bytes, file, TypeRefContext::GenericArg, out);
}
_ => {}
}
}
}
"nullable_type" => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
"type_arguments" => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, TypeRefContext::GenericArg, out);
}
}
"type_projection" => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
_ => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
}
}
fn kotlin_type_child<'a>(node: &Node<'a>) -> Option<Node<'a>> {
node.named_children(&mut node.walk()).find(|c| {
matches!(
c.kind(),
"user_type"
| "nullable_type"
| "function_type"
| "parenthesized_type"
| "type_identifier"
)
})
}
fn collect_type_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
match node.kind() {
"parameter" | "class_parameter" => {
if let Some(type_node) = kotlin_type_child(node) {
type_leaf(&type_node, bytes, file, TypeRefContext::ParameterType, out);
}
return;
}
"function_declaration" => {
let mut past_params = false;
for child in node.named_children(&mut node.walk()) {
match child.kind() {
"function_value_parameters" => {
past_params = true;
for param in child.named_children(&mut child.walk()) {
collect_type_references(¶m, bytes, file, out);
}
}
"user_type" | "nullable_type" | "function_type" | "parenthesized_type" => {
if past_params {
type_leaf(&child, bytes, file, TypeRefContext::ReturnType, out);
}
}
"function_body" => {
collect_type_references(&child, bytes, file, out);
}
_ => {
collect_type_references(&child, bytes, file, out);
}
}
}
return; }
"variable_declaration" => {
if let Some(type_node) = kotlin_type_child(node) {
type_leaf(&type_node, bytes, file, TypeRefContext::Field, out);
}
return;
}
_ => {}
}
for child in node.children(&mut node.walk()) {
collect_type_references(&child, bytes, file, out);
}
}
fn collect_scopes(root: &Node, source_len: usize) -> Vec<Scope> {
let mut scopes = Vec::new();
push_scope(
&mut scopes,
None,
ByteSpan {
start: 0,
end: source_len,
},
ScopeKind::Module,
);
for child in root.children(&mut root.walk()) {
scope_dfs(&child, 0, &mut scopes);
}
scopes
}
fn scope_dfs(node: &Node, parent_id: ScopeId, scopes: &mut Vec<Scope>) {
match node.kind() {
"class_declaration" | "object_declaration" | "companion_object" => {
let type_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Type);
for child in node.children(&mut node.walk()) {
if matches!(child.kind(), "class_body" | "enum_class_body") {
for body_child in child.children(&mut child.walk()) {
scope_dfs(&body_child, type_id, scopes);
}
}
}
}
"function_declaration" | "anonymous_function" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
for child in node.children(&mut node.walk()) {
if child.kind() == "function_body" {
let mut found_block = false;
for body_child in child.children(&mut child.walk()) {
if body_child.kind() == "block" {
found_block = true;
for block_child in body_child.children(&mut body_child.walk()) {
scope_dfs(&block_child, fn_id, scopes);
}
}
}
if !found_block {
for body_child in child.children(&mut child.walk()) {
scope_dfs(&body_child, fn_id, scopes);
}
}
}
}
}
"secondary_constructor" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
for child in node.children(&mut node.walk()) {
if child.kind() == "block" {
for block_child in child.children(&mut child.walk()) {
scope_dfs(&block_child, fn_id, scopes);
}
}
}
}
"lambda_literal" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
for child in node.children(&mut node.walk()) {
scope_dfs(&child, fn_id, scopes);
}
}
"block" => {
let block_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Block);
for child in node.children(&mut node.walk()) {
scope_dfs(&child, block_id, scopes);
}
}
_ => {
for child in node.children(&mut node.walk()) {
scope_dfs(&child, parent_id, scopes);
}
}
}
}
fn collect_bindings(root: &Node, bytes: &[u8], scopes: &[Scope]) -> Vec<Binding> {
let mut out = Vec::new();
collect_bindings_dfs(root, bytes, scopes, &mut out);
out
}
fn collect_bindings_dfs(node: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
match node.kind() {
"function_declaration" | "anonymous_function" | "secondary_constructor" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "function_value_parameters" {
collect_params(&child, bytes, scopes, out);
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"lambda_literal" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "lambda_parameters" {
for param in child.children(&mut child.walk()) {
if param.kind() == "variable_declaration" {
if let Some(ident) = param
.children(&mut param.walk())
.find(|c| c.kind() == "identifier")
{
let name = node_text(&ident, bytes);
let intro = ident.start_byte();
push_binding(
out,
name.to_owned(),
intro,
BindingKind::Param,
scopes,
);
}
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"property_declaration" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "variable_declaration" {
if let Some(ident) = child
.children(&mut child.walk())
.find(|c| c.kind() == "identifier")
{
let intro = ident.start_byte();
let sid = innermost_scope(intro, scopes).unwrap_or(0);
if matches!(scopes[sid].kind, ScopeKind::Function | ScopeKind::Block) {
let name = node_text(&ident, bytes);
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
break;
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"for_statement" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "variable_declaration" {
if let Some(ident) = child
.children(&mut child.walk())
.find(|c| c.kind() == "identifier")
{
let intro = ident.start_byte();
let sid = innermost_scope(intro, scopes).unwrap_or(0);
if matches!(scopes[sid].kind, ScopeKind::Function | ScopeKind::Block) {
let name = node_text(&ident, bytes);
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
break;
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
_ => {
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
}
}
fn collect_params(params: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
for child in params.named_children(&mut params.walk()) {
if child.kind() == "parameter" {
if let Some(ident) = child
.children(&mut child.walk())
.find(|c| c.kind() == "identifier")
{
let name = node_text(&ident, bytes);
let intro = ident.start_byte();
push_binding(out, name.to_owned(), intro, BindingKind::Param, scopes);
}
}
}
}
fn first_user_type<'a>(node: &Node<'a>) -> Option<Node<'a>> {
if node.kind() == "user_type" {
return Some(*node);
}
for child in node.children(&mut node.walk()) {
if let Some(found) = first_user_type(&child) {
return Some(found);
}
}
None
}
fn collect_inheritance(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if matches!(node.kind(), "class_declaration" | "object_declaration") {
for child in node.children(&mut node.walk()) {
if child.kind() == "delegation_specifiers" {
for spec in child.children(&mut child.walk()) {
if spec.kind() == "delegation_specifier" {
if let Some(user_type_node) = first_user_type(&spec) {
super::push_ref(
out,
super::simple_type_name(node_text(&user_type_node, bytes), "."),
&user_type_node,
file,
RefRole::IsImplementation,
);
}
}
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_inheritance(&child, bytes, file, out);
}
}
fn collect_imports(
node: &Node,
bytes: &[u8],
file: &str,
out: &mut Vec<Reference>,
module_id: &str,
) {
if node.kind() == "import" {
let raw = node_text(node, bytes);
if !raw.contains('*') {
for child in node.children(&mut node.walk()) {
if matches!(child.kind(), "qualified_identifier" | "identifier") {
let path = node_text(&child, bytes);
let (from_path, name) = path.rsplit_once('.').unwrap_or(("", path));
super::push_import_ref(out, name, &child, file, module_id, from_path);
break;
}
}
}
return;
}
for child in node.children(&mut node.walk()) {
collect_imports(&child, bytes, file, out, module_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(src: &str, path: &str) -> FileFacts {
KotlinExtractor.extract(src, path).unwrap()
}
fn by_name(facts: &FileFacts, name: &str) -> Option<Symbol> {
facts.symbols.iter().find(|s| s.name == name).cloned()
}
#[test]
fn top_level_main_is_entry_point() {
let facts = extract("fun main() {}", "src/main.kt");
let main = by_name(&facts, "main").unwrap();
assert!(
main.entry_points
.iter()
.any(|e| matches!(e, EntryPoint::Main))
);
}
#[test]
fn method_main_is_not_entry_point() {
let facts = extract("class App { fun main() {} }", "src/app.kt");
let main = by_name(&facts, "main").unwrap();
assert!(
!main
.entry_points
.iter()
.any(|e| matches!(e, EntryPoint::Main))
);
}
#[test]
fn class_visibility_all_emit() {
let src = r#"package com.ex
class Session {
fun open() {}
private fun secret() {}
}
"#;
let facts = extract(src, "src/com/ex/Session.kt");
let session = by_name(&facts, "Session").unwrap();
assert_eq!(session.kind, SymbolKind::Class);
assert_eq!(session.visibility, Visibility::Public);
assert_eq!(
session.id.to_scip_string(),
"codegraph . . . com/ex/Session#"
);
let open = by_name(&facts, "open").unwrap();
assert_eq!(open.kind, SymbolKind::Method);
assert_eq!(open.visibility, Visibility::Public);
assert_eq!(
open.id.to_scip_string(),
"codegraph . . . com/ex/Session#open()."
);
let secret = by_name(&facts, "secret").expect("private method 'secret' must be emitted");
assert_eq!(secret.kind, SymbolKind::Method);
assert_eq!(
secret.visibility,
Visibility::Private,
"private fun must have Visibility::Private"
);
assert_eq!(
secret.id.to_scip_string(),
"codegraph . . . com/ex/Session#secret()."
);
}
#[test]
fn interface_kind() {
let src = r#"package com.ex
interface Readable {
fun read(): String
}
"#;
let facts = extract(src, "src/com/ex/Readable.kt");
let readable = by_name(&facts, "Readable").unwrap();
assert_eq!(readable.kind, SymbolKind::Interface);
assert_eq!(
readable.id.to_scip_string(),
"codegraph . . . com/ex/Readable#"
);
}
#[test]
fn enum_class_with_entries() {
let src = r#"package com.ex
enum class Direction {
NORTH,
SOUTH,
EAST,
WEST
}
"#;
let facts = extract(src, "src/com/ex/Direction.kt");
let dir = by_name(&facts, "Direction").unwrap();
assert_eq!(dir.kind, SymbolKind::Enum);
assert_eq!(dir.id.to_scip_string(), "codegraph . . . com/ex/Direction#");
for entry in &["NORTH", "SOUTH", "EAST", "WEST"] {
let sym = by_name(&facts, entry).unwrap();
assert_eq!(sym.kind, SymbolKind::Const);
assert_eq!(
sym.id.to_scip_string(),
format!("codegraph . . . com/ex/Direction#{entry}.")
);
}
}
#[test]
fn object_singleton() {
let src = r#"package com.ex
object Registry {
fun register() {}
}
"#;
let facts = extract(src, "src/com/ex/Registry.kt");
let reg = by_name(&facts, "Registry").unwrap();
assert_eq!(reg.kind, SymbolKind::Class);
assert_eq!(reg.id.to_scip_string(), "codegraph . . . com/ex/Registry#");
let register = by_name(&facts, "register").unwrap();
assert_eq!(register.kind, SymbolKind::Method);
assert_eq!(
register.id.to_scip_string(),
"codegraph . . . com/ex/Registry#register()."
);
}
#[test]
fn val_and_var_properties() {
let src = r#"package com.ex
class Config {
val maxRetries: Int = 3
var timeout: Long = 5000
}
"#;
let facts = extract(src, "src/com/ex/Config.kt");
let max = by_name(&facts, "maxRetries").unwrap();
assert_eq!(max.kind, SymbolKind::Const);
assert_eq!(
max.id.to_scip_string(),
"codegraph . . . com/ex/Config#maxRetries."
);
let timeout = by_name(&facts, "timeout").unwrap();
assert_eq!(timeout.kind, SymbolKind::Static);
assert_eq!(
timeout.id.to_scip_string(),
"codegraph . . . com/ex/Config#timeout."
);
}
#[test]
fn top_level_function() {
let src = r#"package com.ex
fun greet(name: String): String {
return "Hello $name"
}
"#;
let facts = extract(src, "src/com/ex/Greeting.kt");
let greet = by_name(&facts, "greet").unwrap();
assert_eq!(greet.kind, SymbolKind::Function);
assert_eq!(greet.id.to_scip_string(), "codegraph . . . com/ex/greet().");
}
#[test]
fn type_alias() {
let src = r#"package com.ex
typealias StringList = List<String>
"#;
let facts = extract(src, "src/com/ex/Aliases.kt");
let alias = by_name(&facts, "StringList").unwrap();
assert_eq!(alias.kind, SymbolKind::TypeAlias);
assert_eq!(
alias.id.to_scip_string(),
"codegraph . . . com/ex/StringList#"
);
}
#[test]
fn call_references_captured() {
let src = r#"package com.ex
fun main() {
foo()
val x = SomeClass()
x.bar()
}
"#;
let facts = extract(src, "src/com/ex/Main.kt");
let names: Vec<&str> = facts.references.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"foo"), "expected 'foo' in {names:?}");
assert!(names.contains(&"bar"), "expected 'bar' in {names:?}");
}
#[test]
fn lang_tag() {
let facts = extract("fun foo() {}", "src/Foo.kt");
assert_eq!(facts.lang, "kotlin");
}
#[test]
fn class_inherits_base_and_interface() {
let src = "class Sub : Base(), Iface { }";
let facts = extract(src, "src/Sub.kt");
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"Base"),
"expected 'Base' in {inherit_names:?}"
);
assert!(
inherit_names.contains(&"Iface"),
"expected 'Iface' in {inherit_names:?}"
);
}
#[test]
fn class_inherits_dotted_name_simplified() {
let src = "class C : com.x.Base() { }";
let facts = extract(src, "src/C.kt");
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"Base"),
"expected 'Base' in {inherit_names:?}"
);
}
#[test]
fn object_inherits_service() {
let src = "object O : Service { }";
let facts = extract(src, "src/O.kt");
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"Service"),
"expected 'Service' in {inherit_names:?}"
);
}
#[test]
fn import_qualified_emits_leaf() {
let src = "import com.example.Service\nclass C";
let facts = extract(src, "src/C.kt");
let import_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert_eq!(
import_names,
vec!["Service"],
"expected exactly ['Service'], got {import_names:?}"
);
}
#[test]
fn import_simple_emits_name() {
let src = "import Foo\nclass C";
let facts = extract(src, "src/C.kt");
let import_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert_eq!(
import_names,
vec!["Foo"],
"expected exactly ['Foo'], got {import_names:?}"
);
}
#[test]
fn import_wildcard_skipped() {
let src = "import com.example.*\nclass C";
let facts = extract(src, "src/C.kt");
let import_refs: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert!(
import_refs.is_empty(),
"expected no Import refs for wildcard, got {import_refs:?}"
);
}
#[test]
fn func_params_emit_param_bindings() {
let src = "fun f(a: Int, b: String) {}";
let facts = extract(src, "src/F.kt");
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope");
let mut param_names: Vec<(&str, ScopeId)> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Param)
.map(|b| (b.name.as_str(), b.scope))
.collect();
param_names.sort_by_key(|(n, _)| *n);
assert_eq!(
param_names,
vec![("a", fn_scope_id), ("b", fn_scope_id)],
"expected Param bindings for a and b, got {param_names:?}"
);
}
#[test]
fn local_val_emits_local_binding() {
let src = "fun f() { val x = 1 }";
let facts = extract(src, "src/F.kt");
let x = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "x")
.expect("expected a Local binding for 'x'");
assert!(
matches!(
facts.scopes[x.scope].kind,
ScopeKind::Function | ScopeKind::Block
),
"x should be in a Function or Block scope, got {:?}",
facts.scopes[x.scope].kind
);
}
#[test]
fn local_var_emits_local_binding() {
let src = "fun f() { var y = 2 }";
let facts = extract(src, "src/F.kt");
let y = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "y")
.expect("expected a Local binding for 'y'");
assert!(
matches!(
facts.scopes[y.scope].kind,
ScopeKind::Function | ScopeKind::Block
),
"y should be in a Function or Block scope, got {:?}",
facts.scopes[y.scope].kind
);
}
#[test]
fn for_loop_var_emits_local_binding() {
let src = "fun f(xs: List<Int>) { for (x in xs) {} }";
let facts = extract(src, "src/F.kt");
let x = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "x")
.expect("expected a Local binding for for-loop 'x'");
assert!(
matches!(
facts.scopes[x.scope].kind,
ScopeKind::Function | ScopeKind::Block
),
"for-loop x should be in a Function or Block scope, got {:?}",
facts.scopes[x.scope].kind
);
}
#[test]
fn class_property_not_local_but_is_definition() {
let src = "class C { val count: Int = 0 }";
let facts = extract(src, "src/C.kt");
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "count"),
"class property 'count' must NOT be a Local binding"
);
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Definition && b.name == "count"),
"class property 'count' must have a Definition binding"
);
}
#[test]
fn nested_class_fun_scope_chain() {
let src = "class C { fun f() {} }";
let facts = extract(src, "src/C.kt");
assert_eq!(
facts.scopes[0].kind,
ScopeKind::Module,
"scopes[0] must be Module"
);
let type_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Type)
.expect("expected a Type scope");
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope");
assert_eq!(
facts.scopes[type_scope_id].parent,
Some(0),
"Type scope parent should be Module (0)"
);
assert_eq!(
facts.scopes[fn_scope_id].parent,
Some(type_scope_id),
"Function scope parent should be the Type scope"
);
}
#[test]
fn lambda_params_emit_param_bindings() {
let src = "fun f() { val g = { a: Int -> a + 1 } }";
let facts = extract(src, "src/F.kt");
let a = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Param && b.name == "a")
.expect("expected a Param binding for lambda param 'a'");
assert_eq!(
facts.scopes[a.scope].kind,
ScopeKind::Function,
"lambda param 'a' should be in a Function scope"
);
}
#[test]
fn object_members_produce_type_and_function_scopes() {
let src = "object Reg { fun get() {} }";
let facts = extract(src, "src/Reg.kt");
let type_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Type)
.expect("expected a Type scope for the object");
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope for the method");
assert_eq!(
facts.scopes[type_scope_id].parent,
Some(0),
"object Type scope should be nested under Module"
);
assert_eq!(
facts.scopes[fn_scope_id].parent,
Some(type_scope_id),
"method Function scope should be nested under the Type scope"
);
}
#[test]
fn same_file_call_ref_has_scope() {
let src = "fun greet() {}\nfun main() { greet() }";
let facts = extract(src, "src/Greet.kt");
assert!(
by_name(&facts, "greet").is_some(),
"expected 'greet' Definition"
);
let greet_ref = facts
.references
.iter()
.find(|r| r.role == RefRole::Call && r.name == "greet")
.expect("expected a Call ref for 'greet'");
assert!(
greet_ref.scope.is_some() && greet_ref.scope != Some(0),
"greet() call ref should be in a non-root scope, got {:?}",
greet_ref.scope
);
}
#[test]
fn import_emits_import_binding() {
let src = "import com.example.Service\nclass C";
let facts = extract(src, "src/C.kt");
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Import && b.name == "Service"),
"expected an Import binding named 'Service', got {:?}",
facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Import)
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn type_ref_param_type_emitted() {
let src = "fun f(c: Config) {}";
let facts = extract(src, "src/F.kt");
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::ParameterType),
"expected ParameterType ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn type_ref_field_type_emitted() {
let src = "class C { val conf: Config = null }";
let facts = extract(src, "src/C.kt");
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::Field),
"expected Field ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn type_ref_generic_param_emitted() {
let src = "fun f(xs: List<Config>) {}";
let facts = extract(src, "src/F.kt");
let list_ref = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "List")
.expect("expected TypeRef ref for 'List'");
assert_eq!(
list_ref.type_ref_ctx,
Some(TypeRefContext::ParameterType),
"expected ParameterType ctx for 'List', got {:?}",
list_ref.type_ref_ctx
);
let config_ref = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
config_ref.type_ref_ctx,
Some(TypeRefContext::GenericArg),
"expected GenericArg ctx for 'Config', got {:?}",
config_ref.type_ref_ctx
);
}
#[test]
fn type_ref_return_type_emitted() {
let src = "fun f(): Config = TODO()";
let facts = extract(src, "src/F.kt");
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::ReturnType),
"expected ReturnType ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn read_at_use_not_declaration() {
let src = "fun f(): Int { val base = 1; return base }";
let facts = extract(src, "src/F.kt");
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "base")
.collect();
assert!(
!read_refs.is_empty(),
"expected at least one Read ref for 'base', got none"
);
let use_ref = read_refs
.iter()
.find(|r| r.occ.byte > 30)
.expect("Read ref for 'base' should be at the return site (byte > 30)");
assert!(
use_ref.occ.byte > 30,
"Read ref should be at the use site, not the declaration"
);
}
#[test]
fn write_emitted_for_assignment() {
let src = "fun f() { var cnt = 0; cnt = 5 }";
let facts = extract(src, "src/F.kt");
let write_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Write && r.name == "cnt")
.collect();
assert!(
!write_refs.is_empty(),
"expected at least one Write ref for 'cnt', got none — all refs: {:?}",
facts
.references
.iter()
.map(|r| (&r.name, r.role))
.collect::<Vec<_>>()
);
}
#[test]
fn call_not_also_read() {
let src = "fun f() { helper() }";
let facts = extract(src, "src/F.kt");
let call_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Call && r.name == "helper")
.collect();
assert!(!call_refs.is_empty(), "expected a Call ref for 'helper'");
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "helper")
.collect();
assert!(
read_refs.is_empty(),
"helper() must NOT produce a Read ref; got: {read_refs:?}"
);
}
#[test]
fn navigation_member_not_read_receiver_is_read() {
let src = "fun f(obj: C) { use(obj.field) }";
let facts = extract(src, "src/F.kt");
let field_reads: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "field")
.collect();
assert!(
field_reads.is_empty(),
"member 'field' in navigation expression must NOT be a Read ref; got: {field_reads:?}"
);
let obj_reads: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "obj")
.collect();
assert!(
!obj_reads.is_empty(),
"receiver 'obj' should be a Read ref; got none"
);
}
#[test]
fn qualified_call_captures_receiver_as_qualifier() {
let src = "fun run(): Int { return Service.helper() }\n";
let facts = extract(src, "src/com/ex/Main.kt");
let call_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Call)
.collect();
let helper = call_refs
.iter()
.find(|r| r.name == "helper")
.expect("expected a Call ref for 'helper'");
assert_eq!(
helper.qualifier.as_deref(),
Some("Service"),
"the `helper` call must be qualified by `Service`"
);
assert!(
!call_refs.iter().any(|r| r.name == "Service"),
"receiver `Service` must NOT be a Call ref; got: {:?}",
call_refs.iter().map(|r| &r.name).collect::<Vec<_>>()
);
}
#[test]
fn import_carries_from_path_and_source_module() {
let src = "package com.example\nimport com.example.alpha.Service\nfun run() {}\n";
let file = "src/com/example/Main.kt";
let facts = extract(src, file);
let import = facts
.references
.iter()
.find(|r| r.role == RefRole::Import && r.name == "Service")
.expect("expected an Import ref for 'Service'");
assert_eq!(import.from_path.as_deref(), Some("com.example.alpha"));
let expected_module_id = crate::extract::module_symbol(
Language::Kotlin,
&["com".into(), "example".into()],
file,
src.len(),
)
.id
.to_scip_string();
assert_eq!(import.source_module, Some(expected_module_id));
}
#[test]
fn explicit_public_modifier_yields_public() {
let src = "package com.ex\npublic fun doWork() {}";
let facts = extract(src, "src/com/ex/Work.kt");
let sym = by_name(&facts, "doWork").expect("expected 'doWork'");
assert_eq!(
sym.visibility,
Visibility::Public,
"explicit 'public' must yield Visibility::Public"
);
}
#[test]
fn no_modifier_yields_public() {
let src = "package com.ex\nfun compute() {}";
let facts = extract(src, "src/com/ex/Comp.kt");
let sym = by_name(&facts, "compute").expect("expected 'compute'");
assert_eq!(
sym.visibility,
Visibility::Public,
"missing modifier must default to Visibility::Public"
);
}
#[test]
fn private_modifier_emits_private_visibility() {
let src = "package com.ex\nclass Foo {\n private fun hidden() {}\n}";
let facts = extract(src, "src/com/ex/Foo.kt");
let sym = by_name(&facts, "hidden").expect("private method 'hidden' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Private,
"private fun must have Visibility::Private"
);
}
#[test]
fn internal_modifier_yields_internal() {
let src = "package com.ex\ninternal class Cache {}";
let facts = extract(src, "src/com/ex/Cache.kt");
let sym = by_name(&facts, "Cache").expect("expected 'Cache'");
assert_eq!(
sym.visibility,
Visibility::Internal,
"internal class must have Visibility::Internal"
);
}
#[test]
fn protected_modifier_yields_protected() {
let src = "package com.ex\nopen class Base {\n protected fun hook() {}\n}";
let facts = extract(src, "src/com/ex/Base.kt");
let sym = by_name(&facts, "hook").expect("expected 'hook'");
assert_eq!(
sym.visibility,
Visibility::Protected,
"protected fun must have Visibility::Protected"
);
}
#[test]
fn private_property_emits_private_visibility() {
let src = "package com.ex\nprivate val secret: Int = 42";
let facts = extract(src, "src/com/ex/Secrets.kt");
let sym = by_name(&facts, "secret").expect("private property 'secret' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Private,
"private val must have Visibility::Private"
);
}
#[test]
fn private_class_emits_private_visibility() {
let src = "package com.ex\nprivate class Impl {}";
let facts = extract(src, "src/com/ex/Impl.kt");
let sym = by_name(&facts, "Impl").expect("private class 'Impl' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Private,
"private class must have Visibility::Private"
);
}
}