use std::collections::{HashMap, HashSet};
use std::path::Path;
use tree_sitter::Node;
use crate::analyzers::solidity::parser::{node_line, node_text};
use crate::core::ast::SolidityAst;
use crate::core::{DebtItem, DebtType, FunctionMetrics, Priority};
const MAX_NATSPEC_LOOKBACK: usize = 20;
const MAX_BLANK_GAP: usize = 1;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct NatSpecDoc {
pub notice: Option<String>,
pub dev: Option<String>,
pub params: HashMap<String, String>,
pub returns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FunctionSignature {
pub params: Vec<String>,
pub return_slots: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NatSpecIssue {
Missing,
MissingParam(String),
StaleParam(String),
MissingReturn,
PlaceholderNotice,
PlaceholderDev,
}
pub fn detect_natspec_debt(
path: &Path,
ast: &SolidityAst,
functions: &[FunctionMetrics],
) -> Vec<DebtItem> {
let lines = ast.source.lines().collect::<Vec<_>>();
let signatures = function_signatures_by_line(ast);
functions
.iter()
.filter(|function| !function.is_test)
.filter(|function| matches!(function.visibility.as_deref(), Some("public" | "external")))
.flat_map(|function| {
let doc = parse_natspec_before(&lines, function.line);
let signature = signatures.get(&function.line).cloned().unwrap_or_default();
issues_for_function(&doc, &signature)
.into_iter()
.map(|issue| debt_item_for_issue(path, function, issue))
})
.collect()
}
fn issues_for_function(
doc: &Option<NatSpecDoc>,
signature: &FunctionSignature,
) -> Vec<NatSpecIssue> {
let Some(doc) = doc else {
return vec![NatSpecIssue::Missing];
};
let mut issues = Vec::new();
push_placeholder_issues(doc, &mut issues);
push_param_issues(doc, signature, &mut issues);
push_return_issues(doc, signature, &mut issues);
issues
}
fn push_placeholder_issues(doc: &NatSpecDoc, issues: &mut Vec<NatSpecIssue>) {
if doc
.notice
.as_ref()
.is_some_and(|text| is_placeholder_text(text))
{
issues.push(NatSpecIssue::PlaceholderNotice);
}
if doc
.dev
.as_ref()
.is_some_and(|text| is_placeholder_text(text))
{
issues.push(NatSpecIssue::PlaceholderDev);
}
if doc.notice.is_none() && doc.dev.is_none() {
issues.push(NatSpecIssue::Missing);
}
}
fn push_param_issues(
doc: &NatSpecDoc,
signature: &FunctionSignature,
issues: &mut Vec<NatSpecIssue>,
) {
let documented = doc.params.keys().cloned().collect::<HashSet<_>>();
let actual = signature.params.iter().cloned().collect::<HashSet<_>>();
for param in &signature.params {
if !documented.contains(param) {
issues.push(NatSpecIssue::MissingParam(param.clone()));
}
}
for param in documented {
if !actual.contains(¶m) {
issues.push(NatSpecIssue::StaleParam(param));
}
}
}
fn push_return_issues(
doc: &NatSpecDoc,
signature: &FunctionSignature,
issues: &mut Vec<NatSpecIssue>,
) {
if signature.return_slots == 0 {
return;
}
let documented = doc
.returns
.iter()
.filter(|value| !is_placeholder_text(value))
.count();
if documented < signature.return_slots {
issues.push(NatSpecIssue::MissingReturn);
}
}
pub fn parse_natspec_before(lines: &[&str], function_line: usize) -> Option<NatSpecDoc> {
let raw_lines = collect_doc_lines(lines, function_line)?;
(!raw_lines.is_empty()).then(|| parse_doc_lines(&raw_lines))
}
fn collect_doc_lines(lines: &[&str], function_line: usize) -> Option<Vec<String>> {
if function_line == 0 {
return None;
}
let start = function_line.saturating_sub(1);
let mut doc_lines = Vec::new();
let mut blank_gap = 0;
for index in (0..start).rev() {
if start - index > MAX_NATSPEC_LOOKBACK {
break;
}
let trimmed = lines[index].trim();
if let Some(content) = trimmed.strip_prefix("///") {
doc_lines.insert(0, content.trim().to_string());
blank_gap = 0;
continue;
}
if trimmed.is_empty() {
blank_gap += 1;
if blank_gap > MAX_BLANK_GAP {
break;
}
continue;
}
break;
}
(!doc_lines.is_empty()).then_some(doc_lines)
}
fn parse_doc_lines(lines: &[String]) -> NatSpecDoc {
let mut doc = NatSpecDoc::default();
let mut current_tag: Option<String> = None;
for line in lines {
if let Some(tag) = parse_tag_line(line) {
apply_tag(&mut doc, &tag);
current_tag = Some(tag.kind);
continue;
}
if let Some(kind) = ¤t_tag {
append_continuation(&mut doc, kind, line);
}
}
doc
}
struct ParsedTag {
kind: String,
name: Option<String>,
text: String,
}
fn parse_tag_line(line: &str) -> Option<ParsedTag> {
let trimmed = line.trim();
if !trimmed.starts_with('@') {
return None;
}
let rest = trimmed.trim_start_matches('@');
let (tag, remainder) = rest.split_once(char::is_whitespace)?;
let kind = tag.to_ascii_lowercase();
match kind.as_str() {
"param" => {
let (name, text) = remainder.split_once(char::is_whitespace)?;
Some(ParsedTag {
kind,
name: Some(name.to_string()),
text: text.trim().to_string(),
})
}
"return" => Some(ParsedTag {
kind,
name: None,
text: remainder.trim().to_string(),
}),
"notice" | "dev" => Some(ParsedTag {
kind,
name: None,
text: remainder.trim().to_string(),
}),
_ => None,
}
}
fn apply_tag(doc: &mut NatSpecDoc, tag: &ParsedTag) {
match tag.kind.as_str() {
"notice" => doc.notice = Some(tag.text.clone()),
"dev" => doc.dev = Some(tag.text.clone()),
"param" => {
if let Some(name) = &tag.name {
doc.params.insert(name.clone(), tag.text.clone());
}
}
"return" => doc.returns.push(tag.text.clone()),
_ => {}
}
}
fn append_continuation(doc: &mut NatSpecDoc, kind: &str, line: &str) {
let text = line.trim();
if text.is_empty() {
return;
}
match kind {
"notice" => append_text(&mut doc.notice, text),
"dev" => append_text(&mut doc.dev, text),
"return" => append_last(&mut doc.returns, text),
_ => {}
}
}
fn append_text(target: &mut Option<String>, text: &str) {
match target {
Some(existing) => {
existing.push(' ');
existing.push_str(text);
}
None => *target = Some(text.to_string()),
}
}
fn append_last(values: &mut Vec<String>, text: &str) {
if let Some(last) = values.last_mut() {
last.push(' ');
last.push_str(text);
} else {
values.push(text.to_string());
}
}
fn is_placeholder_text(text: &str) -> bool {
let normalized = text.trim().trim_matches('.').to_ascii_lowercase();
normalized.is_empty()
|| matches!(
normalized.as_str(),
"todo"
| "tbd"
| "fixme"
| "n/a"
| "na"
| "none"
| "placeholder"
| "xxx"
| "..."
| "tbc"
)
}
fn function_signatures_by_line(ast: &SolidityAst) -> HashMap<usize, FunctionSignature> {
let mut signatures = HashMap::new();
collect_signatures(ast.tree.root_node(), &ast.source, &mut signatures);
signatures
}
fn collect_signatures(
node: Node,
source: &str,
signatures: &mut HashMap<usize, FunctionSignature>,
) {
if node.kind() == "function_definition" {
let line = node_line(&node);
signatures.insert(line, signature_from_function(node, source));
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_signatures(child, source, signatures);
}
}
fn signature_from_function(node: Node, source: &str) -> FunctionSignature {
let return_type = node.child_by_field_name("return_type");
FunctionSignature {
params: parameter_names(node, source, return_type),
return_slots: return_slot_count(return_type, source),
}
}
fn parameter_names(function: Node, source: &str, return_type: Option<Node>) -> Vec<String> {
let mut names = Vec::new();
walk_nodes(function, &mut |node| {
if node.kind() != "parameter" {
return;
}
if is_under_node(node, return_type) {
return;
}
if let Some(name) = node.child_by_field_name("name") {
names.push(node_text(&name, source).to_string());
}
});
names
}
fn return_slot_count(return_type: Option<Node>, _source: &str) -> usize {
let Some(return_type) = return_type else {
return 0;
};
let mut count = 0;
walk_nodes(return_type, &mut |node| {
if matches!(node.kind(), "parameter" | "return_parameter") {
count += 1;
}
});
if count > 0 {
return count;
}
if return_type
.children(&mut return_type.walk())
.any(|child| child.kind() == "type_name")
{
1
} else {
0
}
}
fn is_under_node(node: Node, ancestor: Option<Node>) -> bool {
let Some(ancestor) = ancestor else {
return false;
};
let mut current = Some(node);
while let Some(parent) = current {
if parent == ancestor {
return true;
}
current = parent.parent();
}
false
}
fn walk_nodes(node: Node, visit: &mut impl FnMut(Node)) {
visit(node);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_nodes(child, visit);
}
}
fn debt_item_for_issue(path: &Path, function: &FunctionMetrics, issue: NatSpecIssue) -> DebtItem {
let (pattern, message, context) = issue_details(&issue, function);
DebtItem {
id: format!("solidity-{pattern}-{}-{}", path.display(), function.line),
debt_type: DebtType::CodeSmell {
smell_type: Some(pattern.to_string()),
},
priority: Priority::Low,
file: path.to_path_buf(),
line: function.line,
column: None,
message,
context: Some(context),
}
}
fn issue_details(
issue: &NatSpecIssue,
function: &FunctionMetrics,
) -> (&'static str, String, String) {
match issue {
NatSpecIssue::Missing => (
"missing-natspec",
format!(
"Function '{}' is public/external without NatSpec",
function.name
),
"Add /// @notice or /// @dev documentation for external callers.".to_string(),
),
NatSpecIssue::MissingParam(param) => (
"missing-natspec-param",
format!("Function '{}' is missing @param {param}", function.name),
"Document each parameter with /// @param name description.".to_string(),
),
NatSpecIssue::StaleParam(param) => (
"stale-natspec-param",
format!(
"Function '{}' documents stale @param {param}",
function.name
),
"Remove or rename stale NatSpec params to match the function signature.".to_string(),
),
NatSpecIssue::MissingReturn => (
"missing-natspec-return",
format!(
"Function '{}' is missing @return documentation",
function.name
),
"Document return values with /// @return description.".to_string(),
),
NatSpecIssue::PlaceholderNotice => (
"placeholder-natspec",
format!("Function '{}' has placeholder @notice text", function.name),
"Replace placeholder NatSpec with a meaningful description.".to_string(),
),
NatSpecIssue::PlaceholderDev => (
"placeholder-natspec",
format!("Function '{}' has placeholder @dev text", function.name),
"Replace placeholder NatSpec with a meaningful description.".to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzers::solidity::parser::parse_source;
use std::path::Path;
fn lines(source: &str) -> Vec<&str> {
source.lines().collect()
}
#[test]
fn test_parse_multiline_natspec_block() {
let source = "/// @notice Transfers tokens\n/// to the recipient.\n/// @param to Recipient\ncontract C { function f(address to) public {} }";
let doc = parse_natspec_before(&lines(source), 4).expect("doc");
assert_eq!(
doc.notice.as_deref(),
Some("Transfers tokens to the recipient.")
);
assert_eq!(doc.params.get("to").map(String::as_str), Some("Recipient"));
}
#[test]
fn test_detects_missing_param_and_stale_param() {
let doc = NatSpecDoc {
notice: Some("Does something".to_string()),
params: HashMap::from([
("old".to_string(), "Old param".to_string()),
("amount".to_string(), "Amount".to_string()),
]),
..Default::default()
};
let signature = FunctionSignature {
params: vec!["to".to_string(), "amount".to_string()],
return_slots: 0,
};
let issues = issues_for_function(&Some(doc), &signature);
assert!(issues.contains(&NatSpecIssue::MissingParam("to".to_string())));
assert!(issues.contains(&NatSpecIssue::StaleParam("old".to_string())));
}
#[test]
fn test_detects_missing_return_for_named_returns() {
let doc = NatSpecDoc {
notice: Some("Returns values".to_string()),
returns: vec!["The amount".to_string()],
..Default::default()
};
let signature = FunctionSignature {
params: vec![],
return_slots: 2,
};
let issues = issues_for_function(&Some(doc), &signature);
assert!(issues.contains(&NatSpecIssue::MissingReturn));
}
#[test]
fn test_detects_placeholder_notice() {
let doc = NatSpecDoc {
notice: Some("TODO".to_string()),
..Default::default()
};
let issues = issues_for_function(&Some(doc), &FunctionSignature::default());
assert!(issues.contains(&NatSpecIssue::PlaceholderNotice));
}
#[test]
fn test_ignores_internal_functions_in_debt_detection() {
let source = r#"pragma solidity 0.8.20;
contract C {
function hidden(uint256 value) internal {}
}
"#;
let ast = parse_source(source, Path::new("Internal.sol")).expect("parse");
let function = FunctionMetrics {
name: "C.hidden".to_string(),
file: ast.path.clone(),
line: 3,
cyclomatic: 1,
cognitive: 0,
nesting: 0,
length: 1,
is_test: false,
visibility: Some("internal".to_string()),
is_trait_method: false,
in_test_module: false,
entropy_score: None,
is_pure: None,
purity_confidence: None,
purity_reason: None,
call_dependencies: None,
detected_patterns: None,
upstream_callers: None,
downstream_callees: None,
mapping_pattern_result: None,
adjusted_complexity: None,
composition_metrics: None,
language_specific: None,
purity_level: None,
error_swallowing_count: None,
error_swallowing_patterns: None,
entropy_analysis: None,
};
assert!(detect_natspec_debt(Path::new("Internal.sol"), &ast, &[function]).is_empty());
}
#[test]
fn test_signature_extraction_for_returns() {
let source = r#"pragma solidity 0.8.20;
contract C {
function f(address to, uint256 amount) public returns (uint256 value, bool ok) {}
}
"#;
let ast = parse_source(source, Path::new("Returns.sol")).expect("parse");
let signatures = function_signatures_by_line(&ast);
let signature = signatures.get(&3).expect("signature");
assert_eq!(signature.params, vec!["to", "amount"]);
assert_eq!(signature.return_slots, 2);
}
}