use std::collections::{HashMap, HashSet};
use std::path::Path;
use chrono::{DateTime, Utc};
use tree_sitter::{Node, Parser, TreeCursor};
use uuid::Uuid;
use crate::graph::types::{RelationType, Relationship, Symbol, SymbolType};
#[derive(Debug, Clone)]
pub struct RawImport {
pub source_id: Uuid,
pub module_raw: String,
pub is_relative: bool,
pub dot_count: usize,
pub module_path: String,
}
#[derive(Debug, Default)]
pub struct FileParseResult {
pub symbols: Vec<Symbol>,
pub relationships: Vec<Relationship>,
pub raw_imports: Vec<RawImport>,
}
pub fn parse_python_file(
file_path: &str,
source: &str,
project: &str,
file_mtime: DateTime<Utc>,
) -> FileParseResult {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.expect("failed to load Python grammar");
let Some(tree) = parser.parse(source, None) else {
tracing::warn!("tree-sitter failed to parse {file_path}");
return FileParseResult::default();
};
let source_bytes = source.as_bytes();
let root = tree.root_node();
let mut ctx = ParseContext {
file_path,
project,
file_mtime,
result: FileParseResult::default(),
name_to_id: HashMap::new(),
imported_names: HashSet::new(),
class_fields: HashMap::new(),
};
let file_symbol_id = Uuid::new_v4();
ctx.result.symbols.push(Symbol {
id: file_symbol_id,
name: file_path.to_string(),
symbol_type: SymbolType::File,
file_path: file_path.to_string(),
start_line: Some(1),
end_line: Some(source.lines().count() as i32),
language: "python".to_string(),
project: project.to_string(),
signature: None,
file_mtime,
layer: None,
parent_symbol_id: None,
moniker: None,
});
let mut cursor = root.walk();
collect_imports(&root, source_bytes, &mut ctx, &mut cursor);
let mut cursor2 = root.walk();
collect_definitions(
&root,
file_symbol_id,
None, source_bytes,
&mut ctx,
&mut cursor2,
);
let mut cursor3 = root.walk();
collect_calls(&root, source_bytes, &mut ctx, &mut cursor3);
ctx.result
}
pub fn resolve_python_import(
importing_file: &str,
dot_count: usize,
module_path: &str,
) -> Option<String> {
let file = Path::new(importing_file);
let file_dir = file.parent()?;
let base_dir = if dot_count == 0 {
return None;
} else {
let levels_up = dot_count - 1;
let mut dir = file_dir.to_path_buf();
for _ in 0..levels_up {
dir = dir.parent()?.to_path_buf();
}
dir
};
let path_suffix = module_path.replace('.', "/");
let resolved = if path_suffix.is_empty() {
base_dir
} else {
base_dir.join(&path_suffix)
};
Some(resolved.to_string_lossy().to_string())
}
struct ParseContext<'a> {
file_path: &'a str,
project: &'a str,
file_mtime: DateTime<Utc>,
result: FileParseResult,
name_to_id: HashMap<String, Uuid>,
imported_names: HashSet<String>,
class_fields: HashMap<(Uuid, String), Uuid>,
}
fn collect_imports<'a>(
node: &Node<'a>,
source: &[u8],
ctx: &mut ParseContext<'_>,
cursor: &mut TreeCursor<'a>,
) {
for child in node.children(cursor) {
match child.kind() {
"import_statement" => {
process_import_statement(&child, source, ctx);
}
"import_from_statement" => {
process_import_from_statement(&child, source, ctx);
}
_ => {}
}
}
}
fn process_import_statement(node: &Node<'_>, source: &[u8], ctx: &mut ParseContext<'_>) {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
let module_name = match child.kind() {
"dotted_name" | "relative_import" => node_text(&child, source),
"aliased_import" => {
let alias = child
.child_by_field_name("alias")
.and_then(|n| Some(node_text(&n, source)))
.unwrap_or_default();
let original = child
.child_by_field_name("name")
.and_then(|n| Some(node_text(&n, source)))
.unwrap_or_default();
if !alias.is_empty() {
ctx.imported_names.insert(alias.clone());
}
original
}
_ => continue,
};
if !module_name.is_empty() {
let top = module_name.split('.').next().unwrap_or(&module_name);
ctx.imported_names.insert(top.to_string());
let (dot_count, path_part) = parse_dot_prefix(&module_name);
let file_id = ctx.result.symbols[0].id;
ctx.result.raw_imports.push(RawImport {
source_id: file_id,
module_raw: module_name.clone(),
is_relative: dot_count > 0,
dot_count,
module_path: path_part.to_string(),
});
let target_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, module_name.as_bytes());
ctx.result.relationships.push(Relationship {
source_id: file_id,
target_id,
rel_type: RelationType::Imports,
confidence: 0.3, });
}
}
}
fn process_import_from_statement(node: &Node<'_>, source: &[u8], ctx: &mut ParseContext<'_>) {
let module_node = node.child_by_field_name("module_name");
let raw_module = if let Some(n) = &module_node {
node_text(n, source)
} else {
let stmt_text = node_text(node, source);
extract_from_module(&stmt_text)
};
let (dot_count, module_path) = parse_dot_prefix(&raw_module);
let file_id = ctx.result.symbols[0].id;
if !raw_module.is_empty() || dot_count > 0 {
ctx.result.raw_imports.push(RawImport {
source_id: file_id,
module_raw: raw_module.clone(),
is_relative: dot_count > 0,
dot_count,
module_path: module_path.to_string(),
});
let target_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, raw_module.as_bytes());
ctx.result.relationships.push(Relationship {
source_id: file_id,
target_id,
rel_type: RelationType::Imports,
confidence: 0.3,
});
}
let file_id = ctx.result.symbols[0].id;
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
match child.kind() {
"dotted_name"
if child.id()
!= node
.child_by_field_name("module_name")
.map(|n| n.id())
.unwrap_or(0) =>
{
let import_name = node_text(&child, source);
ctx.imported_names.insert(import_name.clone());
if !import_name.is_empty() && !is_builtin_type(&import_name) {
let target_id =
Uuid::new_v5(&Uuid::NAMESPACE_OID, import_name.as_bytes());
ctx.result.relationships.push(Relationship {
source_id: file_id,
target_id,
rel_type: RelationType::UsesType,
confidence: 0.8,
});
}
}
"aliased_import" => {
if let Some(alias) = child.child_by_field_name("alias") {
ctx.imported_names.insert(node_text(&alias, source));
}
if let Some(name_node) = child.child_by_field_name("name") {
let import_name = node_text(&name_node, source);
ctx.imported_names.insert(import_name.clone());
if !import_name.is_empty() && !is_builtin_type(&import_name) {
let target_id =
Uuid::new_v5(&Uuid::NAMESPACE_OID, import_name.as_bytes());
ctx.result.relationships.push(Relationship {
source_id: file_id,
target_id,
rel_type: RelationType::UsesType,
confidence: 0.8,
});
}
}
}
"wildcard_import" => {}
_ => {}
}
}
}
fn parse_dot_prefix(s: &str) -> (usize, &str) {
let dots = s.chars().take_while(|&c| c == '.').count();
(dots, &s[dots..])
}
fn extract_from_module(stmt: &str) -> String {
let after_from = stmt.trim_start_matches("from").trim_start();
let before_import = after_from.split("import").next().unwrap_or("").trim();
before_import.to_string()
}
fn collect_definitions<'a>(
node: &Node<'a>,
parent_id: Uuid,
enclosing_class: Option<Uuid>,
source: &[u8],
ctx: &mut ParseContext<'_>,
cursor: &mut TreeCursor<'a>,
) {
for child in node.children(cursor) {
match child.kind() {
"function_definition" => {
let sym_id = process_function(&child, parent_id, enclosing_class, source, ctx);
if let Some(body) = child.child_by_field_name("body") {
let mut inner = body.walk();
collect_definitions(&body, sym_id, None, source, ctx, &mut inner);
}
}
"class_definition" => {
let class_id = process_class(&child, parent_id, source, ctx);
if let Some(body) = child.child_by_field_name("body") {
let mut inner = body.walk();
collect_definitions(&body, class_id, Some(class_id), source, ctx, &mut inner);
}
}
"assignment" | "annotated_assignment" if enclosing_class.is_some() => {
if let Some(class_id) = enclosing_class {
process_field(&child, class_id, source, ctx);
}
}
"decorated_definition" => {
let mut dc = child.walk();
for inner_child in child.named_children(&mut dc) {
match inner_child.kind() {
"function_definition" => {
let sym_id = process_function(
&inner_child,
parent_id,
enclosing_class,
source,
ctx,
);
if let Some(body) = inner_child.child_by_field_name("body") {
let mut bc = body.walk();
collect_definitions(
&body, sym_id, None, source, ctx, &mut bc,
);
}
}
"class_definition" => {
let class_id =
process_class(&inner_child, parent_id, source, ctx);
if let Some(body) = inner_child.child_by_field_name("body") {
let mut bc = body.walk();
collect_definitions(
&body,
class_id,
Some(class_id),
source,
ctx,
&mut bc,
);
}
}
_ => {}
}
}
}
_ => {
let mut inner = child.walk();
collect_definitions(&child, parent_id, enclosing_class, source, ctx, &mut inner);
}
}
}
}
fn process_function(
node: &Node<'_>,
parent_id: Uuid,
enclosing_class: Option<Uuid>,
source: &[u8],
ctx: &mut ParseContext<'_>,
) -> Uuid {
let name = node
.child_by_field_name("name")
.map(|n| node_text(&n, source))
.unwrap_or_else(|| "<anonymous>".to_string());
let symbol_type = if enclosing_class.is_some() {
SymbolType::Method
} else {
SymbolType::Function
};
let signature = build_function_signature(node, &name, source);
let start_line = node.start_position().row as i32 + 1;
let end_line = node.end_position().row as i32 + 1;
let id = Uuid::new_v4();
ctx.name_to_id.insert(name.clone(), id);
ctx.result.symbols.push(Symbol {
id,
name: name.clone(),
symbol_type,
file_path: ctx.file_path.to_string(),
start_line: Some(start_line),
end_line: Some(end_line),
language: "python".to_string(),
project: ctx.project.to_string(),
signature: Some(signature),
file_mtime: ctx.file_mtime,
layer: None,
parent_symbol_id: None,
moniker: None,
});
ctx.result.relationships.push(Relationship {
source_id: parent_id,
target_id: id,
rel_type: RelationType::Defines,
confidence: 1.0,
});
collect_type_annotations(node, id, source, ctx);
id
}
fn process_class(
node: &Node<'_>,
parent_id: Uuid,
source: &[u8],
ctx: &mut ParseContext<'_>,
) -> Uuid {
let name = node
.child_by_field_name("name")
.map(|n| node_text(&n, source))
.unwrap_or_else(|| "<anonymous>".to_string());
let start_line = node.start_position().row as i32 + 1;
let end_line = node.end_position().row as i32 + 1;
let id = Uuid::new_v4();
ctx.name_to_id.insert(name.clone(), id);
ctx.result.symbols.push(Symbol {
id,
name: name.clone(),
symbol_type: SymbolType::Class,
file_path: ctx.file_path.to_string(),
start_line: Some(start_line),
end_line: Some(end_line),
language: "python".to_string(),
project: ctx.project.to_string(),
signature: Some(format!("class {name}")),
file_mtime: ctx.file_mtime,
layer: None,
parent_symbol_id: None,
moniker: None,
});
ctx.result.relationships.push(Relationship {
source_id: parent_id,
target_id: id,
rel_type: RelationType::Defines,
confidence: 1.0,
});
if let Some(superclasses) = node.child_by_field_name("superclasses") {
let mut cursor = superclasses.walk();
for arg in superclasses.named_children(&mut cursor) {
let base_name = node_text(&arg, source);
if base_name.is_empty() || base_name == "object" {
continue;
}
let target_id = ctx
.name_to_id
.get(&base_name)
.copied()
.unwrap_or_else(|| Uuid::new_v5(&Uuid::NAMESPACE_OID, base_name.as_bytes()));
let confidence = if ctx.name_to_id.contains_key(&base_name) {
1.0
} else if ctx.imported_names.contains(&base_name) {
0.8
} else {
0.5
};
ctx.result.relationships.push(Relationship {
source_id: id,
target_id,
rel_type: RelationType::Inherits,
confidence,
});
}
}
id
}
fn process_field(
node: &Node<'_>,
enclosing_class_id: Uuid,
source: &[u8],
ctx: &mut ParseContext<'_>,
) -> Option<Uuid> {
let left = node.child_by_field_name("left")?;
if left.kind() != "identifier" {
return None;
}
let name = node_text(&left, source);
if name.is_empty() {
return None;
}
let start_line = node.start_position().row as i32 + 1;
let end_line = node.end_position().row as i32 + 1;
let id = Uuid::new_v4();
ctx.class_fields
.insert((enclosing_class_id, name.clone()), id);
ctx.result.symbols.push(Symbol {
id,
name: name.clone(),
symbol_type: SymbolType::Field,
file_path: ctx.file_path.to_string(),
start_line: Some(start_line),
end_line: Some(end_line),
language: "python".to_string(),
project: ctx.project.to_string(),
signature: None,
file_mtime: ctx.file_mtime,
layer: None,
parent_symbol_id: Some(enclosing_class_id),
moniker: None,
});
ctx.result.relationships.push(Relationship {
source_id: enclosing_class_id,
target_id: id,
rel_type: RelationType::Defines,
confidence: 1.0,
});
Some(id)
}
fn build_function_signature(node: &Node<'_>, name: &str, source: &[u8]) -> String {
let params = node
.child_by_field_name("parameters")
.map(|n| node_text(&n, source))
.unwrap_or_else(|| "()".to_string());
let return_type = node
.child_by_field_name("return_type")
.map(|n| format!(" -> {}", node_text(&n, source)));
format!(
"def {name}{params}{}",
return_type.as_deref().unwrap_or("")
)
}
fn collect_type_annotations(
func_node: &Node<'_>,
func_id: Uuid,
source: &[u8],
ctx: &mut ParseContext<'_>,
) {
let mut type_names: Vec<String> = Vec::new();
if let Some(params) = func_node.child_by_field_name("parameters") {
let mut cursor = params.walk();
for param in params.named_children(&mut cursor) {
if param.kind() == "typed_parameter" || param.kind() == "typed_default_parameter" {
if let Some(type_node) = param.child_by_field_name("type") {
extract_type_identifiers(&type_node, source, &mut type_names);
}
}
}
}
if let Some(return_type) = func_node.child_by_field_name("return_type") {
extract_type_identifiers(&return_type, source, &mut type_names);
}
for type_name in type_names {
if is_builtin_type(&type_name) {
continue;
}
let (target_id, confidence) = if let Some(&id) = ctx.name_to_id.get(&type_name) {
(id, 1.0_f32)
} else if ctx.imported_names.contains(&type_name) {
(Uuid::new_v5(&Uuid::NAMESPACE_OID, type_name.as_bytes()), 0.8)
} else {
(Uuid::new_v5(&Uuid::NAMESPACE_OID, type_name.as_bytes()), 0.5)
};
ctx.result.relationships.push(Relationship {
source_id: func_id,
target_id,
rel_type: RelationType::UsesType,
confidence,
});
}
}
fn extract_type_identifiers(node: &Node<'_>, source: &[u8], out: &mut Vec<String>) {
match node.kind() {
"identifier" => {
let name = node_text(node, source);
if !name.is_empty() {
out.push(name);
}
}
"attribute" => {
if let Some(attr) = node.child_by_field_name("attribute") {
let name = node_text(&attr, source);
if !name.is_empty() {
out.push(name);
}
}
}
"string" | "concatenated_string" => {
let text = node_text(node, source);
let unquoted = text
.trim_start_matches('"')
.trim_end_matches('"')
.trim_start_matches('\'')
.trim_end_matches('\'')
.trim();
if !unquoted.is_empty()
&& !unquoted.contains('.')
&& !unquoted.contains('[')
&& !unquoted.contains(' ')
{
out.push(unquoted.to_string());
}
}
_ => {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
extract_type_identifiers(&child, source, out);
}
}
}
}
fn is_builtin_type(name: &str) -> bool {
matches!(
name,
"str" | "int" | "float" | "bool" | "None" | "none"
| "list" | "dict" | "tuple" | "set" | "bytes" | "type" | "object"
| "Any" | "Optional" | "Union" | "List" | "Dict" | "Tuple" | "Set"
| "Type" | "Callable" | "Iterator" | "Generator" | "Coroutine"
| "Sequence" | "Mapping" | "MutableMapping" | "Iterable"
| "ClassVar" | "Final" | "Literal" | "TypeVar" | "Protocol"
| "AbstractSet" | "IO" | "TextIO" | "BinaryIO" | "Pattern" | "Match"
| "SupportsInt" | "SupportsFloat" | "SupportsComplex" | "SupportsBytes"
| "SupportsAbs" | "SupportsRound" | "Reversible" | "Container"
| "Collection" | "Hashable" | "Sized" | "Awaitable" | "AsyncIterator"
| "AsyncIterable" | "AsyncGenerator" | "ContextManager"
| "AsyncContextManager" | "NoReturn" | "Never"
)
}
fn collect_calls<'a>(
node: &Node<'a>,
source: &[u8],
ctx: &mut ParseContext<'_>,
cursor: &mut TreeCursor<'a>,
) {
for child in node.children(cursor) {
if child.kind() == "call" {
process_call(&child, source, ctx);
}
if child.kind() == "assignment" {
if let Some(type_node) = child.child_by_field_name("type") {
process_variable_annotation(&child, &type_node, source, ctx);
}
}
if child.kind() == "attribute" {
process_field_read(&child, source, ctx);
}
let mut inner = child.walk();
collect_calls(&child, source, ctx, &mut inner);
}
}
fn process_variable_annotation(
assignment_node: &Node<'_>,
type_node: &Node<'_>,
source: &[u8],
ctx: &mut ParseContext<'_>,
) {
let mut type_names: Vec<String> = Vec::new();
extract_type_identifiers(type_node, source, &mut type_names);
if type_names.is_empty() {
return;
}
let source_id = find_enclosing_function(assignment_node, source, ctx)
.unwrap_or_else(|| ctx.result.symbols[0].id);
for type_name in type_names {
if is_builtin_type(&type_name) {
continue;
}
let (target_id, confidence) = if let Some(&id) = ctx.name_to_id.get(&type_name) {
(id, 1.0_f32)
} else if ctx.imported_names.contains(&type_name) {
(Uuid::new_v5(&Uuid::NAMESPACE_OID, type_name.as_bytes()), 0.8)
} else {
(Uuid::new_v5(&Uuid::NAMESPACE_OID, type_name.as_bytes()), 0.5)
};
ctx.result.relationships.push(Relationship {
source_id,
target_id,
rel_type: RelationType::UsesType,
confidence,
});
}
}
#[derive(Debug, PartialEq)]
enum CalleeKind {
Bare,
SelfChain,
Attribute,
}
fn process_call(node: &Node<'_>, source: &[u8], ctx: &mut ParseContext<'_>) {
let Some(function_node) = node.child_by_field_name("function") else {
return;
};
let (callee_name, callee_kind) = extract_callee_name(&function_node, source);
if callee_name.is_empty() {
return;
}
let caller_id = find_enclosing_function(node, source, ctx);
let (target_id, confidence) = if let Some(&id) = ctx.name_to_id.get(&callee_name) {
(id, 1.0_f32)
} else if ctx.imported_names.contains(&callee_name) {
(
Uuid::new_v5(&Uuid::NAMESPACE_OID, callee_name.as_bytes()),
0.8,
)
} else if callee_kind == CalleeKind::SelfChain {
(
Uuid::new_v5(&Uuid::NAMESPACE_OID, callee_name.as_bytes()),
0.6,
)
} else {
(
Uuid::new_v5(&Uuid::NAMESPACE_OID, callee_name.as_bytes()),
0.5,
)
};
let source_id = caller_id.unwrap_or(ctx.result.symbols[0].id);
ctx.result.relationships.push(Relationship {
source_id,
target_id,
rel_type: RelationType::Calls,
confidence,
});
}
fn extract_callee_name(node: &Node<'_>, source: &[u8]) -> (String, CalleeKind) {
match node.kind() {
"identifier" => (node_text(node, source), CalleeKind::Bare),
"attribute" => {
let method = node
.child_by_field_name("attribute")
.map(|n| node_text(&n, source))
.unwrap_or_default();
let kind = if attribute_chain_starts_with_self(node, source) {
CalleeKind::SelfChain
} else {
CalleeKind::Attribute
};
(method, kind)
}
_ => (String::new(), CalleeKind::Bare),
}
}
fn attribute_chain_starts_with_self(node: &Node<'_>, source: &[u8]) -> bool {
let mut current = node.clone();
loop {
match current.kind() {
"attribute" => {
if let Some(obj) = current.child_by_field_name("object") {
current = obj;
} else {
return false;
}
}
"identifier" => {
return node_text(¤t, source) == "self";
}
_ => return false,
}
}
}
fn find_enclosing_function(
call_node: &Node<'_>,
_source: &[u8],
ctx: &ParseContext<'_>,
) -> Option<Uuid> {
let call_start = call_node.start_position().row as i32 + 1;
let mut best: Option<(Uuid, i32, i32)> = None;
for sym in &ctx.result.symbols {
if !matches!(sym.symbol_type, SymbolType::Function | SymbolType::Method) {
continue;
}
if sym.file_path != ctx.file_path {
continue;
}
let (start, end) = match (sym.start_line, sym.end_line) {
(Some(s), Some(e)) => (s, e),
_ => continue,
};
if call_start >= start && call_start <= end {
let range = end - start;
let current_best_range = best.map(|(_, s, e)| e - s).unwrap_or(i32::MAX);
if range < current_best_range {
best = Some((sym.id, start, end));
}
}
}
best.map(|(id, _, _)| id)
}
fn find_enclosing_class(node: &Node<'_>, ctx: &ParseContext<'_>) -> Option<Uuid> {
let target = node.start_position().row as i32 + 1;
let mut best: Option<(Uuid, i32)> = None;
for sym in &ctx.result.symbols {
if sym.symbol_type != SymbolType::Class {
continue;
}
if sym.file_path != ctx.file_path {
continue;
}
let (Some(s), Some(e)) = (sym.start_line, sym.end_line) else {
continue;
};
if target >= s && target <= e {
let range = e - s;
if range < best.map(|(_, r)| r).unwrap_or(i32::MAX) {
best = Some((sym.id, range));
}
}
}
best.map(|(id, _)| id)
}
fn process_field_read(node: &Node<'_>, source: &[u8], ctx: &mut ParseContext<'_>) {
let Some(obj) = node.child_by_field_name("object") else {
return;
};
if obj.kind() != "identifier" || node_text(&obj, source) != "self" {
return;
}
let Some(attr) = node.child_by_field_name("attribute") else {
return;
};
let field_name = node_text(&attr, source);
if field_name.is_empty() {
return;
}
if let Some(parent) = node.parent() {
if parent.kind() == "call"
&& parent.child_by_field_name("function").map(|f| f.id()) == Some(node.id())
{
return;
}
if (parent.kind() == "assignment" || parent.kind() == "annotated_assignment")
&& parent.child_by_field_name("left").map(|l| l.id()) == Some(node.id())
{
return;
}
}
let Some(class_id) = find_enclosing_class(node, ctx) else {
return;
};
let Some(&field_id) = ctx.class_fields.get(&(class_id, field_name.clone())) else {
return;
};
let source_id = find_enclosing_function(node, source, ctx)
.unwrap_or_else(|| ctx.result.symbols[0].id);
ctx.result.relationships.push(Relationship {
source_id,
target_id: field_id,
rel_type: RelationType::References,
confidence: 1.0,
});
}
fn node_text(node: &Node<'_>, source: &[u8]) -> String {
node.utf8_text(source)
.unwrap_or("")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn parse(source: &str) -> FileParseResult {
parse_python_file("test.py", source, "proj", Utc::now())
}
fn field_symbols(result: &FileParseResult) -> Vec<&Symbol> {
result
.symbols
.iter()
.filter(|s| s.symbol_type == SymbolType::Field)
.collect()
}
fn references_rels(result: &FileParseResult) -> Vec<&Relationship> {
result
.relationships
.iter()
.filter(|r| r.rel_type == RelationType::References)
.collect()
}
#[test]
fn test_class_body_fields_captured() {
let source = r#"
class Invoice:
amount = 0
currency: str = "USD"
revenue_recognition_date = None
"#;
let result = parse(source);
let fields = field_symbols(&result);
let names: Vec<_> = fields.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["amount", "currency", "revenue_recognition_date"],
"fields: {fields:?}"
);
let class_id = result
.symbols
.iter()
.find(|s| s.symbol_type == SymbolType::Class && s.name == "Invoice")
.map(|s| s.id)
.unwrap();
for f in &fields {
assert_eq!(f.parent_symbol_id, Some(class_id));
assert!(
result.relationships.iter().any(|r| {
r.rel_type == RelationType::Defines
&& r.source_id == class_id
&& r.target_id == f.id
}),
"missing Defines(class -> field {})",
f.name
);
}
}
#[test]
fn test_method_local_assignment_not_a_field() {
let source = r#"
class Service:
timeout = 30
def run(self):
local = 5
self.cache = {}
return self.timeout
"#;
let result = parse(source);
let fields = field_symbols(&result);
let names: Vec<_> = fields.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["timeout"], "only class-body fields: {fields:?}");
}
#[test]
fn test_self_field_read_emits_references() {
let source = r#"
class Invoice:
amount = 0
def total(self):
return self.amount
"#;
let result = parse(source);
let amount = result
.symbols
.iter()
.find(|s| s.symbol_type == SymbolType::Field && s.name == "amount")
.map(|s| s.id)
.expect("amount field should exist");
let total = result
.symbols
.iter()
.find(|s| s.name == "total")
.map(|s| s.id)
.expect("total method should exist");
let refs = references_rels(&result);
assert!(
refs.iter()
.any(|r| r.source_id == total && r.target_id == amount),
"expected References(total -> amount), refs: {refs:?}"
);
}
#[test]
fn test_self_method_call_not_a_field_reference() {
let source = r#"
class Calc:
factor = 1
def compute(self):
return self.factor
def run(self):
return self.compute()
"#;
let result = parse(source);
let refs = references_rels(&result);
assert_eq!(
refs.len(),
1,
"expected exactly 1 References edge (self.factor), got: {refs:?}"
);
}
#[test]
fn test_self_field_write_not_a_reference() {
let source = r#"
class Invoice:
amount = 0
def set_amount(self, value):
self.amount = value
"#;
let result = parse(source);
let refs = references_rels(&result);
assert!(
refs.is_empty(),
"self.amount write should not emit References, got: {refs:?}"
);
}
}