use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::analysis::{InterfaceSummary, ParsedFile, ParsedFunction};
use crate::model::{Finding, Severity, SymbolKind};
pub(crate) const BINDING_LOCATION: &str = file!();
use super::super::common::import_alias_lookup;
#[derive(Debug, Clone)]
struct BodyLine {
line: usize,
text: String,
in_loop: bool,
}
pub(crate) fn go_file_findings(file: &ParsedFile) -> Vec<Finding> {
let mut findings = mutable_package_global_findings(file);
for function in &file.functions {
findings.extend(go_function_findings(file, function));
}
findings
}
pub(crate) fn go_function_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
if file.is_test_file || function.is_test_function {
return Vec::new();
}
let mut findings = channel_and_timer_findings(file, function);
findings.extend(http_boundary_findings(file, function));
findings.extend(resource_hygiene_findings(file, function));
findings.extend(init_side_effect_findings(file, function));
findings.extend(public_bool_parameter_findings(file, function));
findings
}
pub(crate) fn go_repo_findings(files: &[&ParsedFile]) -> Vec<Finding> {
let mut findings = single_impl_interface_findings(files);
findings.extend(passthrough_wrapper_interface_findings(files));
findings
}
fn channel_and_timer_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
let mut findings = Vec::new();
let lines = body_lines(function);
let time_aliases = import_aliases_for(file, "time");
let local_channels = binding_matches(&lines, &["make(chan ", "make(chan<-"]);
for (name, line, _) in &local_channels {
let close_lines = lines_for(&lines, &format!("close({name})"));
let range_line = lines
.iter()
.find(|body_line| {
body_line.text.contains("for")
&& body_line.text.contains("range")
&& body_line.text.contains(name)
})
.map(|body_line| body_line.line);
if let Some(range_line) = range_line
&& close_lines.is_empty()
&& !returns_binding(function, name)
{
findings.push(Finding {
rule_id: "range_over_local_channel_without_close".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: range_line,
end_line: range_line,
message: format!(
"function {} ranges over local channel {} without an observed close path",
function.fingerprint.name, name
),
evidence: vec![
format!("local channel {name} created at line {line}"),
format!("for-range over {name} observed at line {range_line}"),
format!("no close({name}) call was observed in the owning function"),
],
});
}
if close_lines.len() > 1 {
findings.push(Finding {
rule_id: "double_close_local_channel".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: close_lines[1],
end_line: close_lines[1],
message: format!(
"function {} closes local channel {} more than once",
function.fingerprint.name, name
),
evidence: vec![
format!("local channel {name} created at line {line}"),
format!(
"observed close({name}) at lines {}",
join_lines(&close_lines)
),
],
});
}
if let Some(first_close) = close_lines.iter().copied().min() {
let send_line = lines
.iter()
.find(|body_line| {
body_line.line > first_close && body_line.text.contains(&format!("{name} <-"))
})
.map(|body_line| body_line.line);
if let Some(send_line) = send_line {
findings.push(Finding {
rule_id: "send_after_local_close_risk".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: send_line,
end_line: send_line,
message: format!(
"function {} sends on local channel {} after it appears closed",
function.fingerprint.name, name
),
evidence: vec![
format!("close({name}) observed at line {first_close}"),
format!("send on {name} observed later at line {send_line}"),
],
});
}
}
}
for alias in &time_aliases {
for body_line in &lines {
if body_line.in_loop && body_line.text.contains(&format!("{alias}.After(")) {
findings.push(Finding {
rule_id: "time_after_in_loop".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} allocates time.After inside a loop",
function.fingerprint.name
),
evidence: vec![
format!("{alias}.After(...) observed inside a loop at line {}", body_line.line),
"reusing a timer or context deadline is usually more stable than allocating a fresh timer each iteration"
.to_string(),
],
});
}
}
}
for alias in &time_aliases {
let ticker_bindings = binding_matches(&lines, &[&format!("{alias}.NewTicker(")]);
for (name, line, _) in ticker_bindings {
if !contains_text(&lines, &format!("{name}.Stop()")) {
findings.push(Finding {
rule_id: "ticker_without_stop".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} creates ticker {} without an observed Stop() call",
function.fingerprint.name, name
),
evidence: vec![
format!("{name} bound from {alias}.NewTicker(...) at line {line}"),
format!("no {}.Stop() call was observed in the function", name),
],
});
}
}
}
findings
}
fn http_boundary_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
let mut findings = Vec::new();
let lines = body_lines(function);
let http_aliases = import_aliases_for(file, "net/http");
let response_bindings = http_response_bindings(&lines, &http_aliases);
for (name, line, target) in &response_bindings {
if !contains_text(&lines, &format!("{name}.Body.Close()"))
&& !returns_binding(function, name)
{
findings.push(Finding {
rule_id: "http_response_body_not_closed".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: *line,
end_line: *line,
message: format!(
"function {} obtains HTTP response {} without an observed Body.Close() call",
function.fingerprint.name, name
),
evidence: vec![
format!("response binding {name} created from {target} at line {line}"),
format!(
"no {}.Body.Close() call was observed in the owning function",
name
),
],
});
}
let decode_line = lines
.iter()
.find(|body_line| {
body_line.text.contains(&format!("{}.Body", name))
&& (body_line.text.contains("Decode(")
|| body_line.text.contains("ReadAll(")
|| body_line.text.contains("Read(")
|| body_line.text.contains("Unmarshal("))
})
.map(|body_line| body_line.line);
if let Some(decode_line) = decode_line
&& !contains_text(&lines, &format!("{name}.StatusCode"))
{
findings.push(Finding {
rule_id: "http_status_ignored_before_decode".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: decode_line,
end_line: decode_line,
message: format!(
"function {} decodes HTTP response {} without an observed status check",
function.fingerprint.name, name
),
evidence: vec![
format!("response binding {name} created at line {line}"),
format!("response body used at line {decode_line}"),
format!("no {}.StatusCode check was observed before decode", name),
],
});
}
}
for alias in &http_aliases {
for (line, literal) in composite_literal_blocks(function, &format!("{alias}.Client{{")) {
if !literal.contains("Timeout:") {
findings.push(Finding {
rule_id: "http_client_without_timeout".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} constructs http.Client without an explicit timeout",
function.fingerprint.name
),
evidence: vec![
format!("{alias}.Client{{...}} literal observed at line {line}"),
"no Timeout field was present in the client literal".to_string(),
],
});
}
}
for (line, literal) in composite_literal_blocks(function, &format!("{alias}.Server{{")) {
if !literal.contains("ReadTimeout:")
&& !literal.contains("WriteTimeout:")
&& !literal.contains("IdleTimeout:")
&& !literal.contains("ReadHeaderTimeout:")
{
findings.push(Finding {
rule_id: "http_server_without_timeouts".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} constructs http.Server without explicit timeout fields",
function.fingerprint.name
),
evidence: vec![
format!("{alias}.Server{{...}} literal observed at line {line}"),
"no ReadTimeout, WriteTimeout, IdleTimeout, or ReadHeaderTimeout field was present"
.to_string(),
],
});
}
}
}
findings.extend(write_header_order_findings(file, function, &lines));
findings
}
fn resource_hygiene_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
let mut findings = Vec::new();
let lines = body_lines(function);
let os_aliases = import_aliases_for(file, "os");
for alias in &os_aliases {
for (name, line, target) in binding_matches(
&lines,
&[
&format!("{alias}.Open("),
&format!("{alias}.Create("),
&format!("{alias}.OpenFile("),
],
) {
if !contains_text(&lines, &format!("{name}.Close()"))
&& !returns_binding(function, &name)
{
findings.push(Finding {
rule_id: "file_handle_without_close".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} opens file handle {} without an observed Close() call",
function.fingerprint.name, name
),
evidence: vec![
format!("file handle {name} created from {target} at line {line}"),
format!(
"no {}.Close() call was observed in the owning function",
name
),
],
});
}
}
}
for (name, line, target) in binding_matches(&lines, &[".Query(", ".QueryContext("]) {
if !contains_text(&lines, &format!("{name}.Close()")) && !returns_binding(function, &name) {
findings.push(Finding {
rule_id: "rows_without_close".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} uses rows handle {} without an observed Close() call",
function.fingerprint.name, name
),
evidence: vec![
format!("rows binding {name} created from {target} at line {line}"),
format!(
"no {}.Close() call was observed in the owning function",
name
),
],
});
}
}
for (name, line, target) in binding_matches(&lines, &[".Prepare(", ".PrepareContext("]) {
if !contains_text(&lines, &format!("{name}.Close()")) && !returns_binding(function, &name) {
findings.push(Finding {
rule_id: "stmt_without_close".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} prepares statement {} without an observed Close() call",
function.fingerprint.name, name
),
evidence: vec![
format!("statement binding {name} created from {target} at line {line}"),
format!(
"no {}.Close() call was observed in the owning function",
name
),
],
});
}
}
for (name, line, target) in binding_matches(&lines, &[".Begin(", ".BeginTx("]) {
if contains_text(&lines, &format!("{name}.Commit()"))
&& !contains_text(&lines, &format!("{name}.Rollback()"))
{
findings.push(Finding {
rule_id: "tx_without_rollback_guard".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: line,
end_line: line,
message: format!(
"function {} begins transaction {} without an observed rollback guard",
function.fingerprint.name, name
),
evidence: vec![
format!("transaction binding {name} created from {target} at line {line}"),
format!(
"{}.Commit() was observed but {}.Rollback() was not",
name, name
),
],
});
}
}
for body_line in &lines {
if body_line.in_loop && body_line.text.starts_with("defer ") {
findings.push(Finding {
rule_id: "defer_in_loop_resource_growth".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} defers cleanup inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"defer statement observed inside a loop at line {}",
body_line.line
),
"loop-local defer calls can accumulate resources until function exit"
.to_string(),
],
});
}
}
findings
}
fn init_side_effect_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
if function.fingerprint.name != "init" {
return Vec::new();
}
let import_aliases = import_alias_lookup(&file.imports);
for call in &function.calls {
let side_effect = match call
.receiver
.as_deref()
.and_then(|receiver| import_aliases.get(receiver))
{
Some(path)
if path == "net/http"
&& matches!(call.name.as_str(), "Get" | "Post" | "PostForm" | "Head") =>
{
Some(format!(
"network call via {}.{}",
call.receiver.as_deref().unwrap_or("<unknown>"),
call.name
))
}
Some(path)
if path == "os"
&& matches!(
call.name.as_str(),
"Open" | "OpenFile" | "Create" | "ReadFile"
) =>
{
Some(format!(
"file-system call via {}.{}",
call.receiver.as_deref().unwrap_or("<unknown>"),
call.name
))
}
Some(path) if path == "os/exec" => Some(format!(
"subprocess setup via {}.{}",
call.receiver.as_deref().unwrap_or("<unknown>"),
call.name
)),
_ => None,
};
if let Some(side_effect) = side_effect {
return vec![Finding {
rule_id: "init_side_effect".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: call.line,
end_line: call.line,
message: "init() performs external side effects at import/startup time".to_string(),
evidence: vec![side_effect],
}];
}
}
Vec::new()
}
fn public_bool_parameter_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
if !is_exported_name(&function.fingerprint.name) {
return Vec::new();
}
let bool_count = bool_parameter_count(&function.signature_text);
if bool_count == 0 {
return Vec::new();
}
vec![Finding {
rule_id: "public_bool_parameter_api".to_string(),
severity: if bool_count > 1 {
Severity::Warning
} else {
Severity::Info
},
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: function.fingerprint.start_line,
end_line: function.fingerprint.start_line,
message: format!(
"public function {} uses boolean parameter{} to control behavior",
function.fingerprint.name,
if bool_count == 1 { "" } else { "s" }
),
evidence: vec![
format!(
"signature: {}",
compact_whitespace(&function.signature_text)
),
format!(
"observed {} bool parameter{} in the public API",
bool_count,
if bool_count == 1 { "" } else { "s" }
),
],
}]
}
fn mutable_package_global_findings(file: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
for package_var in file.package_vars() {
let mut mutation_lines = Vec::new();
for function in &file.functions {
if function.is_test_function {
continue;
}
if let Some(line) = mutation_line(function, &package_var.name) {
mutation_lines.push(line);
}
}
if mutation_lines.is_empty() {
continue;
}
findings.push(Finding {
rule_id: "mutable_package_global".to_string(),
severity: if package_var.is_pub {
Severity::Warning
} else {
Severity::Info
},
path: file.path.clone(),
function_name: None,
start_line: package_var.line,
end_line: package_var.line,
message: format!(
"package variable {} is mutated across function bodies",
package_var.name
),
evidence: {
let mut evidence = vec![
format!("package variable declared at line {}", package_var.line),
format!(
"mutation observed at line{} {}",
if mutation_lines.len() == 1 { "" } else { "s" },
join_lines(&mutation_lines)
),
];
if let Some(type_text) = &package_var.type_text {
evidence.push(format!("declared type: {type_text}"));
}
if let Some(value_text) = &package_var.value_text {
evidence.push(format!("initial value text: {value_text}"));
}
evidence
},
});
}
findings
}
fn single_impl_interface_findings(files: &[&ParsedFile]) -> Vec<Finding> {
let mut findings = Vec::new();
for (_, package_files) in group_by_package(files) {
let mut receiver_methods = BTreeMap::<String, BTreeSet<String>>::new();
let mut interfaces = Vec::<(&ParsedFile, &InterfaceSummary)>::new();
let mut consumer_counts = BTreeMap::<String, usize>::new();
for file in &package_files {
for symbol in &file.symbols {
if matches!(symbol.kind, SymbolKind::Method)
&& let (Some(receiver), Some(_)) =
(&symbol.receiver_type, symbol.receiver_is_pointer)
{
receiver_methods
.entry(receiver.clone())
.or_default()
.insert(symbol.name.clone());
}
}
for interface in file.interfaces() {
interfaces.push((file, interface));
}
}
for file in &package_files {
for function in &file.functions {
for (_, interface) in &interfaces {
if function.signature_text.contains(&interface.name) {
*consumer_counts.entry(interface.name.clone()).or_default() += 1;
}
}
}
for go_struct in file.go_structs() {
for field in &go_struct.fields {
*consumer_counts.entry(field.type_text.clone()).or_default() += 1;
}
}
}
for (file, interface) in interfaces {
if interface.methods.is_empty() {
continue;
}
let impl_candidates = receiver_methods
.iter()
.filter(|(receiver, methods)| {
*receiver != &interface.name
&& interface
.methods
.iter()
.all(|method| methods.contains(method))
})
.map(|(receiver, _)| receiver.clone())
.collect::<Vec<_>>();
let consumer_count = consumer_counts.get(&interface.name).copied().unwrap_or(0);
if impl_candidates.len() == 1 && consumer_count <= 2 {
findings.push(Finding {
rule_id: "single_impl_interface".to_string(),
severity: if interface.is_pub {
Severity::Warning
} else {
Severity::Info
},
path: file.path.clone(),
function_name: None,
start_line: interface.line,
end_line: interface.line,
message: format!(
"interface {} currently has one obvious repository-local implementation",
interface.name
),
evidence: vec![
format!("interface methods: {}", interface.methods.join(", ")),
format!("implementation candidate: {}", impl_candidates[0]),
format!(
"observed consumer count for {}: {}",
interface.name, consumer_count
),
],
});
}
}
}
findings
}
fn passthrough_wrapper_interface_findings(files: &[&ParsedFile]) -> Vec<Finding> {
let mut findings = Vec::new();
for (_, package_files) in group_by_package(files) {
let interface_names = package_files
.iter()
.flat_map(|file| {
file.interfaces()
.iter()
.map(|interface| interface.name.clone())
})
.collect::<BTreeSet<_>>();
for file in &package_files {
for go_struct in file.go_structs() {
let interface_fields = go_struct
.fields
.iter()
.filter(|field| interface_names.contains(&field.type_text))
.collect::<Vec<_>>();
if interface_fields.is_empty() {
continue;
}
let forwarding_methods = file
.functions
.iter()
.filter(|function| {
function.fingerprint.receiver_type.as_deref()
== Some(go_struct.name.as_str())
&& function.fingerprint.line_count <= 6
&& function.fingerprint.call_count <= 2
&& interface_fields.iter().any(|field| {
function.body_text.contains(&format!(".{}.", field.name))
})
})
.collect::<Vec<_>>();
if forwarding_methods.len() >= 2 {
findings.push(Finding {
rule_id: "passthrough_wrapper_interface".to_string(),
severity: if go_struct.is_pub {
Severity::Warning
} else {
Severity::Info
},
path: file.path.clone(),
function_name: None,
start_line: go_struct.line,
end_line: go_struct.line,
message: format!(
"struct {} mostly forwards to an interface field with little added policy",
go_struct.name
),
evidence: vec![
format!(
"interface-like field{}: {}",
if interface_fields.len() == 1 { "" } else { "s" },
interface_fields
.iter()
.map(|field| {
format!(
"{} {} (line {}, exported={})",
field.name, field.type_text, field.line, field.is_pub
)
})
.collect::<Vec<_>>()
.join(", ")
),
format!(
"forwarding-style methods: {}",
forwarding_methods
.iter()
.map(|function| function.fingerprint.name.clone())
.collect::<Vec<_>>()
.join(", ")
),
],
});
}
}
}
}
findings
}
fn write_header_order_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for body_line in lines {
let Some((receiver, _)) = body_line.text.split_once(".WriteHeader(") else {
continue;
};
let receiver = receiver.trim();
if receiver.is_empty() {
continue;
}
let prior_write_line = lines
.iter()
.find(|candidate| {
candidate.line < body_line.line
&& (candidate.text.contains(&format!("{receiver}.Write("))
|| candidate
.text
.contains(&format!("NewEncoder({receiver}).Encode("))
|| candidate.text.contains(&format!("Fprint({receiver},"))
|| candidate.text.contains(&format!("Fprintf({receiver},"))
|| candidate.text.contains(&format!("WriteString({receiver},")))
})
.map(|candidate| candidate.line);
if let Some(prior_write_line) = prior_write_line {
findings.push(Finding {
rule_id: "http_writeheader_after_write".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} calls {}.WriteHeader after writing the response body",
function.fingerprint.name, receiver
),
evidence: vec![
format!("response body write observed first at line {prior_write_line}"),
format!(
"{}.WriteHeader(...) observed later at line {}",
receiver, body_line.line
),
],
});
}
}
findings
}
fn mutation_line(function: &ParsedFunction, name: &str) -> Option<usize> {
body_lines(function)
.into_iter()
.find(|body_line| {
body_line.text.starts_with(&format!("{name} ="))
|| body_line.text.starts_with(&format!("{name} :="))
|| body_line.text.starts_with(&format!("{name} +="))
|| body_line.text.starts_with(&format!("{name} -="))
|| body_line.text.starts_with(&format!("{name}++"))
|| body_line.text.starts_with(&format!("{name}--"))
|| body_line.text.contains(&format!("{name}[")) && body_line.text.contains('=')
})
.map(|body_line| body_line.line)
}
fn http_response_bindings(
lines: &[BodyLine],
http_aliases: &[String],
) -> Vec<(String, usize, String)> {
let mut patterns = http_aliases
.iter()
.flat_map(|alias| {
[
format!("{alias}.Get("),
format!("{alias}.Head("),
format!("{alias}.Post("),
format!("{alias}.PostForm("),
]
})
.collect::<Vec<_>>();
patterns.push(".Do(".to_string());
let string_patterns = patterns.iter().map(String::as_str).collect::<Vec<_>>();
binding_matches(lines, &string_patterns)
}
fn binding_matches(lines: &[BodyLine], patterns: &[&str]) -> Vec<(String, usize, String)> {
let mut matches = Vec::new();
for body_line in lines {
if let Some((name, target)) = binding_for_patterns(&body_line.text, patterns) {
matches.push((name, body_line.line, target));
}
}
matches
}
fn binding_for_patterns(text: &str, patterns: &[&str]) -> Option<(String, String)> {
let (left, right) = split_assignment(text)?;
let target = patterns
.iter()
.find(|pattern| right.contains(**pattern))?
.to_string();
let binding = left
.trim()
.trim_start_matches("var ")
.split(',')
.next()?
.split_whitespace()
.next()?
.trim();
is_identifier_name(binding).then(|| (binding.to_string(), target))
}
fn split_assignment(text: &str) -> Option<(&str, &str)> {
if let Some((left, right)) = text.split_once(":=") {
return Some((left, right));
}
if text.contains("==") || text.contains("!=") || text.contains("<=") || text.contains(">=") {
return None;
}
text.split_once(" = ")
.or_else(|| text.split_once('='))
.filter(|(left, _)| !left.trim_start().starts_with("if "))
}
fn body_lines(function: &ParsedFunction) -> Vec<BodyLine> {
let mut brace_depth = 0usize;
let mut loop_exit_depths = Vec::new();
let mut lines = Vec::new();
for (offset, raw_line) in function.body_text.lines().enumerate() {
let line_no = function.body_start_line + offset;
let stripped = strip_line_comment(raw_line).trim().to_string();
let closing_braces = stripped
.chars()
.filter(|character| *character == '}')
.count();
for _ in 0..closing_braces {
brace_depth = brace_depth.saturating_sub(1);
while loop_exit_depths
.last()
.is_some_and(|exit_depth| *exit_depth > brace_depth)
{
loop_exit_depths.pop();
}
}
let starts_loop = contains_keyword(&stripped, "for");
let in_loop = !loop_exit_depths.is_empty() || starts_loop;
let opening_braces = stripped
.chars()
.filter(|character| *character == '{')
.count();
if starts_loop {
loop_exit_depths.push(brace_depth + opening_braces.max(1));
}
brace_depth += opening_braces;
lines.push(BodyLine {
line: line_no,
text: stripped,
in_loop,
});
}
lines
}
fn composite_literal_blocks(function: &ParsedFunction, marker: &str) -> Vec<(usize, String)> {
let mut blocks = Vec::new();
let lines = function.body_text.lines().collect::<Vec<_>>();
let mut index = 0usize;
while index < lines.len() {
let line = strip_line_comment(lines[index]);
if !line.contains(marker) && !line.contains(&format!("&{marker}")) {
index += 1;
continue;
}
let start_line = function.body_start_line + index;
let mut text = String::new();
let mut brace_balance = 0isize;
let mut started = false;
while index < lines.len() {
let candidate = strip_line_comment(lines[index]);
if !text.is_empty() {
text.push('\n');
}
text.push_str(candidate.trim());
for character in candidate.chars() {
match character {
'{' => {
brace_balance += 1;
started = true;
}
'}' => brace_balance -= 1,
_ => {}
}
}
index += 1;
if started && brace_balance <= 0 {
break;
}
}
blocks.push((start_line, text));
}
blocks
}
fn import_aliases_for(file: &ParsedFile, import_path: &str) -> Vec<String> {
file.imports
.iter()
.filter(|import| import.path == import_path)
.map(|import| import.alias.clone())
.collect()
}
fn contains_text(lines: &[BodyLine], needle: &str) -> bool {
lines
.iter()
.any(|body_line| body_line.text.contains(needle))
}
fn lines_for(lines: &[BodyLine], needle: &str) -> Vec<usize> {
lines
.iter()
.filter(|body_line| body_line.text.contains(needle))
.map(|body_line| body_line.line)
.collect()
}
fn returns_binding(function: &ParsedFunction, name: &str) -> bool {
function
.body_text
.lines()
.any(|line| strip_line_comment(line).contains(&format!("return {name}")))
}
fn bool_parameter_count(signature_text: &str) -> usize {
let Some(parameters_start) = signature_text.find('(') else {
return 0;
};
let Some(parameters_end) = signature_text.rfind(')') else {
return 0;
};
let parameters = &signature_text[parameters_start + 1..parameters_end];
parameters
.split(',')
.map(str::trim)
.filter(|segment| !segment.is_empty() && segment.ends_with(" bool"))
.count()
}
fn is_identifier_name(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.all(|character| character == '_' || character.is_ascii_alphanumeric())
&& text
.chars()
.next()
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
}
fn is_exported_name(text: &str) -> bool {
text.chars()
.next()
.is_some_and(|character| character.is_ascii_uppercase())
}
fn compact_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn join_lines(lines: &[usize]) -> String {
lines
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn contains_keyword(line: &str, keyword: &str) -> bool {
let bytes = line.as_bytes();
let keyword_bytes = keyword.as_bytes();
if keyword_bytes.is_empty() || bytes.len() < keyword_bytes.len() {
return false;
}
for start in 0..=bytes.len() - keyword_bytes.len() {
if &bytes[start..start + keyword_bytes.len()] != keyword_bytes {
continue;
}
let left_ok =
start == 0 || (!bytes[start - 1].is_ascii_alphanumeric() && bytes[start - 1] != b'_');
let right_index = start + keyword_bytes.len();
let right_ok = right_index == bytes.len()
|| (!bytes[right_index].is_ascii_alphanumeric() && bytes[right_index] != b'_');
if left_ok && right_ok {
return true;
}
}
false
}
fn strip_line_comment(line: &str) -> &str {
line.split("//").next().unwrap_or("")
}
fn group_by_package<'a>(
files: &'a [&'a ParsedFile],
) -> BTreeMap<(PathBuf, String), Vec<&'a ParsedFile>> {
let mut groups = BTreeMap::<(PathBuf, String), Vec<&ParsedFile>>::new();
for file in files {
let package_name = file
.package_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
let directory = file.path.parent().map(PathBuf::from).unwrap_or_default();
groups
.entry((directory, package_name))
.or_default()
.push(*file);
}
groups
}