debtmap 0.21.0

Code complexity and technical debt analyzer
Documentation
//! Solidity analyzer implementation.

use crate::analyzers::Analyzer;
use crate::analyzers::solidity::orchestration::analyze_solidity_file;
use crate::analyzers::solidity::parser::parse_source;
use crate::config::SolidityLanguageConfig;
use crate::core::ast::Ast;
use crate::core::{ComplexityMetrics, FileMetrics, Language};
use anyhow::Result;
use std::path::PathBuf;
use tracing::{debug, debug_span};

pub struct SolidityAnalyzer {
    complexity_threshold: u32,
    config: SolidityLanguageConfig,
}

impl SolidityAnalyzer {
    pub fn new() -> Self {
        Self {
            complexity_threshold: 10,
            config: crate::config::get_solidity_config(),
        }
    }

    pub fn with_threshold(mut self, threshold: u32) -> Self {
        self.complexity_threshold = threshold;
        self
    }

    pub fn with_config(mut self, config: SolidityLanguageConfig) -> Self {
        self.config = config;
        self
    }
}

impl Default for SolidityAnalyzer {
    fn default() -> Self {
        Self::new()
    }
}

impl Analyzer for SolidityAnalyzer {
    fn parse(&self, content: &str, path: PathBuf) -> Result<Ast> {
        let _span = debug_span!("parse_solidity_file", path = %path.display()).entered();
        let solidity_ast = parse_source(content, &path)?;

        debug!(
            path = %path.display(),
            bytes = content.len(),
            "Parsed Solidity file"
        );

        Ok(Ast::Solidity(solidity_ast))
    }

    fn analyze(&self, ast: &Ast) -> FileMetrics {
        match ast {
            Ast::Solidity(solidity_ast) => {
                analyze_solidity_file(solidity_ast, self.complexity_threshold, &self.config)
            }
            _ => empty_solidity_metrics(),
        }
    }

    fn language(&self) -> Language {
        Language::Solidity
    }
}

fn empty_solidity_metrics() -> FileMetrics {
    FileMetrics {
        path: PathBuf::new(),
        language: Language::Solidity,
        complexity: ComplexityMetrics::default(),
        debt_items: vec![],
        dependencies: vec![],
        duplications: vec![],
        total_lines: 0,
        module_scope: None,
        classes: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analyzers::get_analyzer;

    const SIMPLE: &str = r#"pragma solidity ^0.8.0;

contract Token {
    function transfer(address to) public {
        require(to != address(0));
    }
}
"#;

    #[test]
    fn test_solidity_analyzer_language() {
        let analyzer = SolidityAnalyzer::new();
        assert_eq!(analyzer.language(), Language::Solidity);
    }

    #[test]
    fn test_solidity_analyzer_factory() {
        let analyzer = get_analyzer(Language::Solidity);
        assert_eq!(analyzer.language(), Language::Solidity);
    }

    #[test]
    fn test_analyze_simple_solidity_file() {
        let analyzer = SolidityAnalyzer::new();
        let ast = analyzer.parse(SIMPLE, PathBuf::from("Token.sol")).unwrap();
        let metrics = analyzer.analyze(&ast);

        assert_eq!(metrics.language, Language::Solidity);
        assert!(!metrics.complexity.functions.is_empty());
    }

    #[test]
    fn test_foundry_test_file_skips_debt() {
        let analyzer = SolidityAnalyzer::new().with_threshold(1);
        let source = r#"pragma solidity ^0.8.0;
import "forge-std/Test.sol";

contract TokenTest is Test {
    function testTransfer() public {
        if (true) {
            revert("failed");
        }
    }
}
"#;
        let ast = analyzer
            .parse(source, PathBuf::from("Token.t.sol"))
            .unwrap();
        let metrics = analyzer.analyze(&ast);

        assert!(metrics.complexity.functions[0].is_test);
        assert!(metrics.debt_items.is_empty());
    }

    #[test]
    fn test_tx_origin_pattern_detected() {
        let analyzer = SolidityAnalyzer::new();
        let source = r#"pragma solidity ^0.8.0;
contract Auth {
    function check() public view returns (bool) {
        return tx.origin == msg.sender;
    }
}
"#;
        let ast = analyzer.parse(source, PathBuf::from("Auth.sol")).unwrap();
        let metrics = analyzer.analyze(&ast);
        let function = &metrics.complexity.functions[0];

        assert!(
            function
                .detected_patterns
                .as_ref()
                .is_some_and(|patterns| patterns.contains(&"tx-origin-usage".to_string()))
        );
    }

    #[test]
    fn test_config_disables_tx_origin_pattern() {
        let mut config = SolidityLanguageConfig::default();
        config.security.tx_origin = false;
        let analyzer = SolidityAnalyzer::new().with_config(config);
        let source = r#"pragma solidity ^0.8.0;
contract Auth {
    function check() public view returns (bool) {
        return tx.origin == msg.sender;
    }
}
"#;
        let ast = analyzer.parse(source, PathBuf::from("Auth.sol")).unwrap();
        let metrics = analyzer.analyze(&ast);
        let function = &metrics.complexity.functions[0];

        assert!(
            !function
                .detected_patterns
                .as_ref()
                .is_some_and(|patterns| patterns.contains(&"tx-origin-usage".to_string()))
        );
        assert!(!metrics.debt_items.iter().any(|item| {
            matches!(
                &item.debt_type,
                crate::core::DebtType::CodeSmell { smell_type }
                    if smell_type.as_deref() == Some("tx-origin-usage")
            )
        }));
    }

    #[test]
    fn test_config_large_contract_threshold() {
        let config = SolidityLanguageConfig {
            large_contract_threshold: 1,
            ..Default::default()
        };
        let analyzer = SolidityAnalyzer::new().with_config(config);
        let source = r#"pragma solidity ^0.8.0;
contract ManyFunctions {
    function a() public {}
    function b() public {}
}
"#;
        let ast = analyzer
            .parse(source, PathBuf::from("ManyFunctions.sol"))
            .unwrap();
        let metrics = analyzer.analyze(&ast);

        assert!(metrics.debt_items.iter().any(|item| {
            matches!(
                &item.debt_type,
                crate::core::DebtType::CodeSmell { smell_type }
                    if smell_type.as_deref() == Some("large-contract")
            )
        }));
    }

    #[test]
    fn test_solidity_line_suppression_filters_security_debt() {
        let analyzer = SolidityAnalyzer::new();
        let source = r#"pragma solidity ^0.8.0;
contract Auth {
    function check() public view returns (bool) { // debtmap:ignore[smell] -- reviewed legacy auth
        return tx.origin == msg.sender;
    }
}
"#;
        let ast = analyzer.parse(source, PathBuf::from("Auth.sol")).unwrap();
        let metrics = analyzer.analyze(&ast);

        assert!(!metrics.debt_items.iter().any(|item| {
            matches!(
                &item.debt_type,
                crate::core::DebtType::CodeSmell { smell_type }
                    if smell_type.as_deref() == Some("tx-origin-usage")
            )
        }));
    }

    #[test]
    fn test_solidity_function_suppression_filters_complexity_debt() {
        let analyzer = SolidityAnalyzer::new().with_threshold(1);
        let source = r#"pragma solidity ^0.8.0;
contract Branchy {
    // debtmap:ignore[complexity] -- compact state machine
    function decide(uint256 value) public returns (uint256) {
        if (value > 10) {
            return 1;
        }
        if (value > 5) {
            return 2;
        }
        return 3;
    }
}
"#;
        let ast = analyzer
            .parse(source, PathBuf::from("Branchy.sol"))
            .unwrap();
        let metrics = analyzer.analyze(&ast);

        assert!(
            !metrics
                .debt_items
                .iter()
                .any(|item| matches!(item.debt_type, crate::core::DebtType::Complexity { .. }))
        );
    }
}