use tree_sitter::Node;
use crate::analysis::{GinCallSummary, GormChainStep, GormQueryChain, ImportSpec, ParseInputCall};
use super::general::{
collect_expression_nodes, extract_call_target, first_string_literal, is_identifier_name,
split_assignment,
};
const GORM_TERMINAL_METHODS: &[&str] = &[
"Count",
"Create",
"CreateInBatches",
"Delete",
"Find",
"FindInBatches",
"First",
"FirstOrCreate",
"Rows",
"Save",
"Scan",
"Take",
"Update",
"UpdateColumn",
"Updates",
];
const GORM_QUERY_METHODS: &[&str] = &[
"Association",
"Clauses",
"Count",
"Create",
"CreateInBatches",
"Delete",
"Debug",
"Distinct",
"Find",
"FindInBatches",
"First",
"FirstOrCreate",
"Group",
"Joins",
"Limit",
"Model",
"Offset",
"Omit",
"Order",
"Preload",
"Raw",
"Rows",
"Save",
"Scan",
"Scopes",
"Select",
"Session",
"Table",
"Take",
"Update",
"UpdateColumn",
"Updates",
"Where",
"WithContext",
];
const YAML_IMPORT_PATHS: &[&str] = &["gopkg.in/yaml.v2", "gopkg.in/yaml.v3", "sigs.k8s.io/yaml"];
const PROTO_IMPORT_PATHS: &[&str] = &[
"google.golang.org/protobuf/proto",
"github.com/golang/protobuf/proto",
];
pub(super) fn collect_gorm_query_chains(
body_node: Node<'_>,
source: &str,
imports: &[ImportSpec],
) -> Vec<GormQueryChain> {
if !imports.iter().any(|import| import.path == "gorm.io/gorm") {
return Vec::new();
}
let mut chains = Vec::new();
visit_gorm_query_chains(body_node, source, false, &mut chains);
chains
}
fn visit_gorm_query_chains(
node: Node<'_>,
source: &str,
inside_loop: bool,
chains: &mut Vec<GormQueryChain>,
) {
let next_inside_loop = inside_loop || node.kind() == "for_statement";
if node.kind() == "call_expression"
&& !is_chained_subcall(node)
&& let Some((root_text, steps)) = extract_call_chain(node, source)
&& is_gorm_chain_candidate(&steps)
{
let terminal_method = steps
.last()
.map(|step| step.method_name.clone())
.unwrap_or_default();
chains.push(GormQueryChain {
line: node.start_position().row + 1,
root_text,
terminal_method,
steps,
in_loop: next_inside_loop,
});
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
visit_gorm_query_chains(child, source, next_inside_loop, chains);
}
}
pub(super) fn collect_parse_input_calls(
body_node: Node<'_>,
source: &str,
imports: &[ImportSpec],
) -> Vec<ParseInputCall> {
let mut calls = Vec::new();
visit_parse_input_calls(body_node, source, imports, false, &mut calls);
calls
}
fn visit_parse_input_calls(
node: Node<'_>,
source: &str,
imports: &[ImportSpec],
inside_loop: bool,
calls: &mut Vec<ParseInputCall>,
) {
let next_inside_loop = inside_loop || node.kind() == "for_statement";
if node.kind() == "call_expression"
&& let Some(call) = parse_input_call(node, source, imports)
{
calls.push(call);
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
visit_parse_input_calls(child, source, imports, next_inside_loop, calls);
}
}
pub(super) fn collect_gin_calls(
body_node: Node<'_>,
source: &str,
imports: &[ImportSpec],
) -> Vec<GinCallSummary> {
if !imports
.iter()
.any(|import| import.path == "github.com/gin-gonic/gin")
{
return Vec::new();
}
let mut calls = Vec::new();
visit_gin_calls(body_node, source, imports, false, &mut calls);
calls
}
fn visit_gin_calls(
node: Node<'_>,
source: &str,
imports: &[ImportSpec],
inside_loop: bool,
calls: &mut Vec<GinCallSummary>,
) {
let next_inside_loop = inside_loop || node.kind() == "for_statement";
if node.kind() == "call_expression"
&& let Some(call) = parse_gin_call(node, source, imports, next_inside_loop)
{
calls.push(call);
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
visit_gin_calls(child, source, imports, next_inside_loop, calls);
}
}
fn parse_input_call(
node: Node<'_>,
source: &str,
imports: &[ImportSpec],
) -> Option<ParseInputCall> {
let function_node = node.child_by_field_name("function")?;
let arguments_node = node.child_by_field_name("arguments")?;
let (receiver, name) = extract_call_target(function_node, source)?;
let import_path = receiver
.as_deref()
.and_then(|receiver_name| resolve_import_path(receiver_name, imports));
let parser_family = match (import_path, name.as_str()) {
(Some("encoding/json"), "Unmarshal") => "json_unmarshal",
(Some("encoding/xml"), "Unmarshal") => "xml_unmarshal",
(Some(path), "Unmarshal" | "UnmarshalStrict") if YAML_IMPORT_PATHS.contains(&path) => {
"yaml_unmarshal"
}
(Some(path), "Unmarshal") if PROTO_IMPORT_PATHS.contains(&path) => "proto_unmarshal",
_ => return None,
};
let arguments = argument_nodes(arguments_node);
let input_node = arguments.first().copied()?;
let input_text = normalize_text(source.get(input_node.byte_range())?);
let target_text = arguments
.get(1)
.and_then(|node| source.get(node.byte_range()))
.map(normalize_text);
Some(ParseInputCall {
line: node.start_position().row + 1,
parser_family: parser_family.to_string(),
input_binding: simple_reference_text(&input_text),
input_text,
target_text,
})
}
fn parse_gin_call(
node: Node<'_>,
source: &str,
imports: &[ImportSpec],
in_loop: bool,
) -> Option<GinCallSummary> {
let function_node = node.child_by_field_name("function")?;
let arguments_node = node.child_by_field_name("arguments")?;
let (receiver, name) = extract_call_target(function_node, source)?;
let argument_texts = collect_argument_texts(arguments_node, source);
let assigned_binding = assigned_binding_for_call(node, source);
let operation = match name.as_str() {
"GetRawData" => Some("get_raw_data"),
"ShouldBind" => Some("should_bind"),
"Bind" => Some("bind"),
"ParseMultipartForm" => Some("parse_multipart_form"),
"FormFile" => Some("form_file"),
"ShouldBindQuery" => Some("should_bind_query"),
"BindQuery" => Some("bind_query"),
"ShouldBindJSON" => Some("should_bind_json"),
"BindJSON" => Some("bind_json"),
"ShouldBindXML" => Some("should_bind_xml"),
"BindXML" => Some("bind_xml"),
"ShouldBindYAML" => Some("should_bind_yaml"),
"BindYAML" => Some("bind_yaml"),
"ShouldBindTOML" => Some("should_bind_toml"),
"BindTOML" => Some("bind_toml"),
"ShouldBindBodyWith" => Some("should_bind_body_with"),
"Copy" => Some("copy"),
"IndentedJSON" => Some("indented_json"),
"JSON" => Some("json"),
"PureJSON" => Some("pure_json"),
"Data" => Some("data"),
_ => None,
};
if let Some(operation) = operation {
return Some(GinCallSummary {
line: node.start_position().row + 1,
operation: operation.to_string(),
argument_texts,
assigned_binding,
in_loop,
});
}
let import_path = receiver
.as_deref()
.and_then(|receiver_name| resolve_import_path(receiver_name, imports));
if matches!(import_path, Some("io") | Some("io/ioutil"))
&& name == "ReadAll"
&& argument_texts
.first()
.is_some_and(|argument| argument.contains(".Request.Body"))
{
return Some(GinCallSummary {
line: node.start_position().row + 1,
operation: "read_request_body".to_string(),
argument_texts,
assigned_binding,
in_loop,
});
}
None
}
fn extract_call_chain(call_node: Node<'_>, source: &str) -> Option<(String, Vec<GormChainStep>)> {
let function_node = call_node.child_by_field_name("function")?;
if function_node.kind() != "selector_expression" {
return None;
}
let mut function_cursor = function_node.walk();
let children = function_node
.named_children(&mut function_cursor)
.collect::<Vec<_>>();
let receiver_node = *children.first()?;
let field_node = *children.get(1)?;
let method_name = source.get(field_node.byte_range())?.trim().to_string();
let arguments_node = call_node.child_by_field_name("arguments")?;
let step = GormChainStep {
line: call_node.start_position().row + 1,
method_name,
argument_texts: collect_argument_texts(arguments_node, source),
first_string_arg: first_string_literal(arguments_node, source),
};
if receiver_node.kind() == "call_expression" {
let (root_text, mut steps) = extract_call_chain(receiver_node, source)?;
steps.push(step);
return Some((root_text, steps));
}
let root_text = normalize_text(source.get(receiver_node.byte_range())?);
Some((root_text, vec![step]))
}
fn is_chained_subcall(node: Node<'_>) -> bool {
node.parent().is_some_and(|parent| {
parent.kind() == "selector_expression"
&& parent
.parent()
.is_some_and(|grandparent| grandparent.kind() == "call_expression")
})
}
fn is_gorm_chain_candidate(steps: &[GormChainStep]) -> bool {
let Some(terminal_method) = steps.last().map(|step| step.method_name.as_str()) else {
return false;
};
GORM_TERMINAL_METHODS.contains(&terminal_method)
&& steps
.iter()
.all(|step| GORM_QUERY_METHODS.contains(&step.method_name.as_str()))
}
fn collect_argument_texts(arguments_node: Node<'_>, source: &str) -> Vec<String> {
argument_nodes(arguments_node)
.into_iter()
.filter_map(|argument| source.get(argument.byte_range()).map(normalize_text))
.collect()
}
fn argument_nodes(arguments_node: Node<'_>) -> Vec<Node<'_>> {
let mut cursor = arguments_node.walk();
let arguments = arguments_node
.named_children(&mut cursor)
.collect::<Vec<_>>();
if arguments.is_empty() {
collect_expression_nodes(arguments_node)
} else {
arguments
}
}
fn assigned_binding_for_call(call_node: Node<'_>, source: &str) -> Option<String> {
let owner = match call_node.parent() {
Some(parent) if parent.kind() == "expression_list" => parent.parent(),
other => other,
}?;
match owner.kind() {
"assignment_statement" | "short_var_declaration" | "var_spec" => {
let text = source.get(owner.byte_range())?;
let (left, _) = split_assignment(text)?;
let binding = left
.trim()
.trim_start_matches("var ")
.split(',')
.next()?
.split_whitespace()
.next()?
.trim();
is_identifier_name(binding).then(|| binding.to_string())
}
_ => None,
}
}
fn resolve_import_path<'a>(receiver: &str, imports: &'a [ImportSpec]) -> Option<&'a str> {
imports
.iter()
.find(|import| import.alias == receiver || import.path == receiver)
.map(|import| import.path.as_str())
}
fn simple_reference_text(text: &str) -> Option<String> {
let trimmed = text
.trim()
.trim_start_matches('&')
.trim_start_matches('*')
.trim();
(!trimmed.is_empty()
&& trimmed
.split('.')
.all(|segment| is_identifier_name(segment.trim())))
.then(|| trimmed.to_string())
}
fn normalize_text(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}