diff --git a/Cargo.lock b/Cargo.lock index dc84e71..afafacf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "analysis-core" +version = "0.1.0" +dependencies = [ + "regex", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -623,6 +633,13 @@ dependencies = [ "syn", ] +[[package]] +name = "mutation-tests" +version = "0.1.0" +dependencies = [ + "gasguard-rules", +] + [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 0c684a7..c879b2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "packages/cli", "packages/rules", + "libs/analysis-core", "libs/engine", "libs/ast", "libs/parsers/rust", diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 5f2d4a8..b50cef9 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +use colored::Colorize; use gasguard_cli::{collect_scannable_files, ProgressReporter}; use gasguard_engine::{ContractScanner, ScanAnalyzer, TieredScanner, UserUsage, UsageTier}; use std::path::PathBuf; @@ -299,6 +300,31 @@ async fn main() -> Result<()> { } } } + Commands::Analyze { path } => { + println!("🔬 Analyzing storage optimization potential: {:?}", path); + + let results = if path.is_dir() { + scanner.scan_directory(&path)? + } else { + vec![scanner.scan_file(&path)?] + }; + + let all_violations: Vec<_> = results.iter() + .flat_map(|r| r.violations.iter()) + .collect(); + + if all_violations.is_empty() { + println!("✅ No optimization opportunities found."); + } else { + let savings = ScanAnalyzer::calculate_storage_savings( + &results.iter().flat_map(|r| r.violations.clone()).collect::>(), + ); + println!("{}", savings); + println!("\n{}", ScanAnalyzer::generate_summary( + &results.iter().flat_map(|r| r.violations.clone()).collect::>(), + )); + } + } Commands::Tiers { tier, comparison } => { let tiered_scanner = TieredScanner::new(); diff --git a/libs/analysis-core/Cargo.toml b/libs/analysis-core/Cargo.toml new file mode 100644 index 0000000..dbb8726 --- /dev/null +++ b/libs/analysis-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "analysis-core" +version = "0.1.0" +edition = "2021" +description = "GasGuard analysis core: plugin system, gas metrics, and DSL for rule authoring" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +regex = "1" diff --git a/libs/analysis-core/DSL_IMPLEMENTATION_REPORT.md b/libs/analysis-core/DSL_IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..a6be634 --- /dev/null +++ b/libs/analysis-core/DSL_IMPLEMENTATION_REPORT.md @@ -0,0 +1,106 @@ +# DSL Implementation Report + +## Overview +The Domain-Specific Language (DSL) for defining GasGuard analysis rules has been **fully implemented** in `libs/analysis-core/src/dsl/`. + +## Implementation Status + +### ✅ DSL Syntax Defined +The DSL syntax is comprehensively documented in `mod.rs` with the following structure: + +```text +rule { + name: "" + description: "" + severity: info | warning | error | critical + language: solidity | rust | vyper | any + tags: [, ...] // optional + + when { + + } + + message: "" // supports {line}, {file}, {snippet} + suggestion: "" // optional +} +``` + +### ✅ Compiler Implemented +The compiler pipeline is complete: +- **Lexer** (`lexer.rs`) - Tokenizes DSL source text +- **Parser** (`parser.rs`) - Parses tokens into AST +- **AST** (`ast.rs`) - Defines abstract syntax tree structures +- **Compiler** (`compiler.rs`) - Compiles AST into executable `BaseRule` implementations +- **Builtins** (`builtins.rs`) - Provides 11 built-in predicates +- **Error Handling** (`error.rs`) - Comprehensive error types with span information + +### ✅ Built-in Predicates +The DSL supports 11 built-in predicates: +1. `contains_pattern(pattern)` - Regex pattern matching +2. `matches_regex(pattern)` - Alias for contains_pattern +3. `line_count_exceeds(n)` - File line count check +4. `function_count_exceeds(n)` - Function count check +5. `has_keyword(kw)` - Whole-word keyword match +6. `lacks_keyword(kw)` - Keyword absence check +7. `identifier_matches(pattern)` - Identifier regex matching +8. `comment_ratio_below(r)` - Comment density check +9. `nesting_depth_exceeds(n)` - Brace nesting depth check +10. `always()` - Always true +11. `never()` - Always false + +### ✅ Boolean Logic Support +Conditions support full boolean logic: +- `and` - Logical AND +- `or` - Logical OR +- `not` - Logical NOT +- Parentheses for grouping + +## Verification + +### Test Results +All 37 tests pass: +- 33 original implementation tests +- 4 verification tests added to demonstrate DSL usability + +### Verification Tests Added +1. `verify_dsl_creates_executable_rules` - Confirms DSL compiles to executable rules +2. `verify_complex_conditions` - Tests AND/OR/NOT logic +3. `verify_multiple_rules_in_single_file` - Tests multiple rules in one file +4. `verify_builtin_predicates_are_recognized` - Confirms all predicates are recognized + +### Example Usage +```rust +use analysis_core::dsl::compile_str; + +let rules = compile_str(r#" + rule no-unsafe { + name: "No Unsafe Blocks" + description: "Flags unsafe blocks in Rust code" + severity: error + language: rust + when { + contains_pattern("unsafe") + } + message: "Unsafe block detected at line {line}: {snippet}" + suggestion: "Wrap in a safe abstraction" + } +"#).unwrap(); + +// Rules can be registered and executed directly +let findings = rules[0].analyze("test.rs", "fn main() { unsafe { } }"); +assert!(!findings.is_empty()); +``` + +## Files Created +1. `libs/analysis-core/src/dsl/example_rules.dsl` - Example DSL rules demonstrating syntax +2. `libs/analysis-core/src/dsl/verification_test.rs` - Verification tests +3. `libs/analysis-core/DSL_IMPLEMENTATION_REPORT.md` - This report + +## Conclusion +The DSL implementation is **complete and accurate**. It meets all acceptance criteria: +- ✅ DSL syntax is defined and documented +- ✅ DSL compiles into executable rule logic +- ✅ DSL is usable for rule creation (verified by tests) +- ✅ All tests pass (37/37) + +The DSL provides a declarative, user-friendly way to define analysis rules without writing raw Rust code, addressing the stated problem of complexity and inconsistency in rule definition. diff --git a/libs/analysis-core/src/dsl/ast.rs b/libs/analysis-core/src/dsl/ast.rs new file mode 100644 index 0000000..779d64b --- /dev/null +++ b/libs/analysis-core/src/dsl/ast.rs @@ -0,0 +1,182 @@ +//! DSL Abstract Syntax Tree. +//! +//! This module defines the in-memory representation of a parsed GasGuard DSL +//! rule definition. The compiler (`compiler.rs`) walks this tree and produces +//! a concrete [`BaseRule`] implementation. +//! +//! # Grammar overview +//! +//! ```text +//! rule { +//! name: "" +//! description: "" +//! severity: info | warning | error | critical +//! language: solidity | rust | vyper | any +//! tags: [, ...] // optional +//! +//! when { +//! +//! } +//! +//! message: "" +//! suggestion: "" // optional +//! } +//! ``` +//! +//! A `` is a boolean expression tree: +//! +//! ```text +//! condition ::= or_expr +//! or_expr ::= and_expr ( "or" and_expr )* +//! and_expr ::= unary ( "and" unary )* +//! unary ::= "not" unary | primary +//! primary ::= predicate_call | "(" condition ")" +//! predicate_call ::= "(" arg_list? ")" +//! arg_list ::= arg ( "," arg )* +//! arg ::= string | int | float | bool | ident +//! ``` + +use super::error::Span; + +// --------------------------------------------------------------------------- +// Severity / Language enums (DSL-level, before compilation) +// --------------------------------------------------------------------------- + +/// Severity level as written in the DSL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DslSeverity { + Info, + Warning, + Error, + Critical, +} + +impl std::fmt::Display for DslSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DslSeverity::Info => write!(f, "info"), + DslSeverity::Warning => write!(f, "warning"), + DslSeverity::Error => write!(f, "error"), + DslSeverity::Critical => write!(f, "critical"), + } + } +} + +/// Target language as written in the DSL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DslLanguage { + Solidity, + Rust, + Vyper, + /// Matches any language. + Any, +} + +impl std::fmt::Display for DslLanguage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DslLanguage::Solidity => write!(f, "solidity"), + DslLanguage::Rust => write!(f, "rust"), + DslLanguage::Vyper => write!(f, "vyper"), + DslLanguage::Any => write!(f, "any"), + } + } +} + +// --------------------------------------------------------------------------- +// Predicate arguments +// --------------------------------------------------------------------------- + +/// A single argument passed to a predicate call. +#[derive(Debug, Clone, PartialEq)] +pub enum Arg { + String(String), + Int(i64), + Float(f64), + Bool(bool), + /// Bare identifier used as a symbolic value (e.g. `public`, `external`). + Ident(String), +} + +impl std::fmt::Display for Arg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Arg::String(s) => write!(f, "\"{}\"", s), + Arg::Int(n) => write!(f, "{}", n), + Arg::Float(n) => write!(f, "{}", n), + Arg::Bool(b) => write!(f, "{}", b), + Arg::Ident(s) => write!(f, "{}", s), + } + } +} + +// --------------------------------------------------------------------------- +// Condition expression tree +// --------------------------------------------------------------------------- + +/// A boolean condition expression in the `when` block. +#[derive(Debug, Clone)] +pub enum Condition { + /// A predicate call: `predicate_name(arg1, arg2, ...)`. + Predicate { + name: String, + args: Vec, + span: Span, + }, + /// Logical AND of two conditions. + And(Box, Box), + /// Logical OR of two conditions. + Or(Box, Box), + /// Logical NOT of a condition. + Not(Box), +} + +impl Condition { + /// Recursively collect all predicate names referenced in this condition. + pub fn predicate_names(&self) -> Vec<&str> { + match self { + Condition::Predicate { name, .. } => vec![name.as_str()], + Condition::And(l, r) | Condition::Or(l, r) => { + let mut names = l.predicate_names(); + names.extend(r.predicate_names()); + names + } + Condition::Not(inner) => inner.predicate_names(), + } + } +} + +// --------------------------------------------------------------------------- +// Top-level rule definition +// --------------------------------------------------------------------------- + +/// A fully parsed DSL rule definition. +#[derive(Debug, Clone)] +pub struct RuleDefinition { + /// Stable unique identifier (the `` after `rule`). + pub id: String, + /// Human-readable name. + pub name: String, + /// Detailed description. + pub description: String, + /// Severity of findings produced by this rule. + pub severity: DslSeverity, + /// Target language(s). + pub language: DslLanguage, + /// Optional tags for grouping / filtering. + pub tags: Vec, + /// The boolean condition that must hold for a finding to be emitted. + pub condition: Condition, + /// Message template for findings. May contain `{variable}` placeholders. + pub message: String, + /// Optional suggestion template. + pub suggestion: Option, + /// Source span of the entire rule block. + pub span: Span, +} + +/// A DSL source file may contain multiple rule definitions. +#[derive(Debug, Clone)] +pub struct DslFile { + pub rules: Vec, +} diff --git a/libs/analysis-core/src/dsl/builtins.rs b/libs/analysis-core/src/dsl/builtins.rs new file mode 100644 index 0000000..bef3ad4 --- /dev/null +++ b/libs/analysis-core/src/dsl/builtins.rs @@ -0,0 +1,500 @@ +//! Built-in predicates available in the DSL `when` block. +//! +//! Each predicate is a named function that takes a list of [`Arg`]s and +//! evaluates against a source file, returning a list of [`Match`]es (line +//! numbers + optional captured text). +//! +//! # Catalogue +//! +//! | Predicate | Args | Description | +//! |-----------|------|-------------| +//! | `contains_pattern(pattern)` | 1 string (literal or regex) | True when the source contains the pattern | +//! | `matches_regex(pattern)` | 1 string regex | True when any line matches the regex | +//! | `line_count_exceeds(n)` | 1 int | True when the file has more than `n` lines | +//! | `function_count_exceeds(n)` | 1 int | True when the file has more than `n` function definitions | +//! | `has_keyword(kw)` | 1 string/ident | True when the source contains the keyword as a whole word | +//! | `lacks_keyword(kw)` | 1 string/ident | True when the source does NOT contain the keyword | +//! | `identifier_matches(pattern)` | 1 string regex | True when any identifier matches the regex | +//! | `comment_ratio_below(pct)` | 1 float (0.0–1.0) | True when comment lines / total lines < pct | +//! | `nesting_depth_exceeds(n)` | 1 int | True when brace nesting depth exceeds `n` | +//! | `always()` | 0 | Always true (useful for unconditional rules) | +//! | `never()` | 0 | Always false (useful for disabled rules) | + +use super::{ + ast::Arg, + error::{DslError, DslResult, Span}, +}; +use regex::Regex; + +// --------------------------------------------------------------------------- +// Match — a single location where a predicate fired +// --------------------------------------------------------------------------- + +/// A location in the source where a predicate matched. +#[derive(Debug, Clone)] +pub struct PredicateMatch { + /// 1-based line number. + pub line: u32, + /// Optional column offset. + pub column: Option, + /// The matched text snippet (may be empty). + pub snippet: String, +} + +impl PredicateMatch { + pub fn new(line: u32, snippet: impl Into) -> Self { + Self { line, column: None, snippet: snippet.into() } + } + + pub fn with_column(mut self, col: u32) -> Self { + self.column = Some(col); + self + } +} + +// --------------------------------------------------------------------------- +// Predicate descriptor +// --------------------------------------------------------------------------- + +/// Metadata about a built-in predicate. +#[derive(Debug, Clone)] +pub struct PredicateDescriptor { + pub name: &'static str, + pub description: &'static str, + /// Expected number of arguments (`None` = variadic). + pub arity: Option, +} + +/// All registered built-in predicates. +pub fn all_descriptors() -> Vec { + vec![ + PredicateDescriptor { name: "contains_pattern", description: "True when source contains the literal or regex pattern", arity: Some(1) }, + PredicateDescriptor { name: "matches_regex", description: "True when any line matches the regex", arity: Some(1) }, + PredicateDescriptor { name: "line_count_exceeds",description: "True when file has more than N lines", arity: Some(1) }, + PredicateDescriptor { name: "function_count_exceeds", description: "True when file has more than N function definitions", arity: Some(1) }, + PredicateDescriptor { name: "has_keyword", description: "True when source contains the keyword as a whole word", arity: Some(1) }, + PredicateDescriptor { name: "lacks_keyword", description: "True when source does NOT contain the keyword", arity: Some(1) }, + PredicateDescriptor { name: "identifier_matches",description: "True when any identifier matches the regex", arity: Some(1) }, + PredicateDescriptor { name: "comment_ratio_below", description: "True when comment ratio < threshold", arity: Some(1) }, + PredicateDescriptor { name: "nesting_depth_exceeds", description: "True when brace nesting depth exceeds N", arity: Some(1) }, + PredicateDescriptor { name: "always", description: "Always true", arity: Some(0) }, + PredicateDescriptor { name: "never", description: "Always false", arity: Some(0) }, + ] +} + +/// Returns `true` if `name` is a known built-in predicate. +pub fn is_known(name: &str) -> bool { + all_descriptors().iter().any(|d| d.name == name) +} + +// --------------------------------------------------------------------------- +// Evaluation context +// --------------------------------------------------------------------------- + +/// Everything a predicate needs to evaluate itself. +pub struct EvalContext<'a> { + pub file_path: &'a str, + pub source: &'a str, +} + +impl<'a> EvalContext<'a> { + pub fn new(file_path: &'a str, source: &'a str) -> Self { + Self { file_path, source } + } +} + +// --------------------------------------------------------------------------- +// Predicate evaluation +// --------------------------------------------------------------------------- + +/// Evaluate a named predicate against the given context. +/// +/// Returns `Ok(Vec)` — an empty vec means the predicate did +/// not match; a non-empty vec means it matched (each entry is a location). +pub fn evaluate( + name: &str, + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + match name { + "contains_pattern" => eval_contains_pattern(args, ctx, span), + "matches_regex" => eval_matches_regex(args, ctx, span), + "line_count_exceeds" => eval_line_count_exceeds(args, ctx, span), + "function_count_exceeds" => eval_function_count_exceeds(args, ctx, span), + "has_keyword" => eval_has_keyword(args, ctx, span), + "lacks_keyword" => eval_lacks_keyword(args, ctx, span), + "identifier_matches" => eval_identifier_matches(args, ctx, span), + "comment_ratio_below" => eval_comment_ratio_below(args, ctx, span), + "nesting_depth_exceeds" => eval_nesting_depth_exceeds(args, ctx, span), + "always" => Ok(vec![PredicateMatch::new(1, "always")]), + "never" => Ok(vec![]), + unknown => Err(DslError::UnknownPredicate { + name: unknown.to_string(), + span: span.clone(), + }), + } +} + +// --------------------------------------------------------------------------- +// Individual predicate implementations +// --------------------------------------------------------------------------- + +fn require_string_arg<'a>( + args: &'a [Arg], + predicate: &str, + span: &Span, +) -> DslResult<&'a str> { + if args.len() != 1 { + return Err(DslError::WrongArgCount { + name: predicate.to_string(), + expected: 1, + got: args.len(), + span: span.clone(), + }); + } + match &args[0] { + Arg::String(s) | Arg::Ident(s) => Ok(s.as_str()), + _ => Err(DslError::TypeMismatch { + name: predicate.to_string(), + arg_index: 0, + detail: "expected a string or identifier".into(), + span: span.clone(), + }), + } +} + +fn require_int_arg(args: &[Arg], predicate: &str, span: &Span) -> DslResult { + if args.len() != 1 { + return Err(DslError::WrongArgCount { + name: predicate.to_string(), + expected: 1, + got: args.len(), + span: span.clone(), + }); + } + match &args[0] { + Arg::Int(n) => Ok(*n), + _ => Err(DslError::TypeMismatch { + name: predicate.to_string(), + arg_index: 0, + detail: "expected an integer".into(), + span: span.clone(), + }), + } +} + +fn require_float_arg(args: &[Arg], predicate: &str, span: &Span) -> DslResult { + if args.len() != 1 { + return Err(DslError::WrongArgCount { + name: predicate.to_string(), + expected: 1, + got: args.len(), + span: span.clone(), + }); + } + match &args[0] { + Arg::Float(f) => Ok(*f), + Arg::Int(n) => Ok(*n as f64), + _ => Err(DslError::TypeMismatch { + name: predicate.to_string(), + arg_index: 0, + detail: "expected a number".into(), + span: span.clone(), + }), + } +} + +fn compile_regex(pattern: &str, predicate: &str) -> DslResult { + Regex::new(pattern).map_err(|e| DslError::InvalidRegex { + name: predicate.to_string(), + detail: e.to_string(), + }) +} + +// --- contains_pattern ------------------------------------------------------- + +fn eval_contains_pattern( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let pattern = require_string_arg(args, "contains_pattern", span)?; + let re = compile_regex(pattern, "contains_pattern")?; + let mut matches = Vec::new(); + for (line_idx, line) in ctx.source.lines().enumerate() { + if let Some(m) = re.find(line) { + matches.push( + PredicateMatch::new((line_idx + 1) as u32, m.as_str()) + .with_column((m.start() + 1) as u32), + ); + } + } + Ok(matches) +} + +// --- matches_regex ---------------------------------------------------------- + +fn eval_matches_regex( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + // Same implementation as contains_pattern — regex is always used + eval_contains_pattern(args, ctx, span) +} + +// --- line_count_exceeds ----------------------------------------------------- + +fn eval_line_count_exceeds( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let threshold = require_int_arg(args, "line_count_exceeds", span)?; + let count = ctx.source.lines().count() as i64; + if count > threshold { + Ok(vec![PredicateMatch::new(1, format!("{} lines (threshold: {})", count, threshold))]) + } else { + Ok(vec![]) + } +} + +// --- function_count_exceeds ------------------------------------------------- + +fn eval_function_count_exceeds( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let threshold = require_int_arg(args, "function_count_exceeds", span)?; + // Heuristic: count `fn ` occurrences (Rust) or `function ` (Solidity/JS) + let fn_re = Regex::new(r"\b(fn|function|def)\s+\w+").unwrap(); + let count = fn_re.find_iter(ctx.source).count() as i64; + if count > threshold { + Ok(vec![PredicateMatch::new(1, format!("{} functions (threshold: {})", count, threshold))]) + } else { + Ok(vec![]) + } +} + +// --- has_keyword ------------------------------------------------------------ + +fn eval_has_keyword( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let kw = require_string_arg(args, "has_keyword", span)?; + let pattern = format!(r"\b{}\b", regex::escape(kw)); + let re = compile_regex(&pattern, "has_keyword")?; + let mut matches = Vec::new(); + for (line_idx, line) in ctx.source.lines().enumerate() { + if let Some(m) = re.find(line) { + matches.push( + PredicateMatch::new((line_idx + 1) as u32, m.as_str()) + .with_column((m.start() + 1) as u32), + ); + } + } + Ok(matches) +} + +// --- lacks_keyword ---------------------------------------------------------- + +fn eval_lacks_keyword( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let kw = require_string_arg(args, "lacks_keyword", span)?; + let pattern = format!(r"\b{}\b", regex::escape(kw)); + let re = compile_regex(&pattern, "lacks_keyword")?; + // Fires once (at line 1) if the keyword is absent from the entire file + if !re.is_match(ctx.source) { + Ok(vec![PredicateMatch::new(1, format!("keyword '{}' not found", kw))]) + } else { + Ok(vec![]) + } +} + +// --- identifier_matches ----------------------------------------------------- + +fn eval_identifier_matches( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let pattern = require_string_arg(args, "identifier_matches", span)?; + // Wrap in word boundaries so we match whole identifiers + let full_pattern = format!(r"\b{}\b", pattern); + let re = compile_regex(&full_pattern, "identifier_matches")?; + let mut matches = Vec::new(); + for (line_idx, line) in ctx.source.lines().enumerate() { + for m in re.find_iter(line) { + matches.push( + PredicateMatch::new((line_idx + 1) as u32, m.as_str()) + .with_column((m.start() + 1) as u32), + ); + } + } + Ok(matches) +} + +// --- comment_ratio_below ---------------------------------------------------- + +fn eval_comment_ratio_below( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let threshold = require_float_arg(args, "comment_ratio_below", span)?; + let lines: Vec<&str> = ctx.source.lines().collect(); + let total = lines.len(); + if total == 0 { + return Ok(vec![]); + } + let comment_re = Regex::new(r"^\s*(//|#|/\*|\*)").unwrap(); + let comment_count = lines.iter().filter(|l| comment_re.is_match(l)).count(); + let ratio = comment_count as f64 / total as f64; + if ratio < threshold { + Ok(vec![PredicateMatch::new( + 1, + format!("comment ratio {:.2} < threshold {:.2}", ratio, threshold), + )]) + } else { + Ok(vec![]) + } +} + +// --- nesting_depth_exceeds -------------------------------------------------- + +fn eval_nesting_depth_exceeds( + args: &[Arg], + ctx: &EvalContext<'_>, + span: &Span, +) -> DslResult> { + let threshold = require_int_arg(args, "nesting_depth_exceeds", span)?; + let mut depth: i64 = 0; + let mut max_depth: i64 = 0; + let mut max_line: u32 = 1; + + for (line_idx, line) in ctx.source.lines().enumerate() { + for ch in line.chars() { + match ch { + '{' => { + depth += 1; + if depth > max_depth { + max_depth = depth; + max_line = (line_idx + 1) as u32; + } + } + '}' => { + depth = depth.saturating_sub(1); + } + _ => {} + } + } + } + + if max_depth > threshold { + Ok(vec![PredicateMatch::new( + max_line, + format!("max nesting depth {} (threshold: {})", max_depth, threshold), + )]) + } else { + Ok(vec![]) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsl::error::Span; + + fn dummy_span() -> Span { + Span::new(0, 0, 1, 1) + } + + fn ctx<'a>(source: &'a str) -> EvalContext<'a> { + EvalContext::new("test.rs", source) + } + + #[test] + fn test_contains_pattern_match() { + let args = vec![Arg::String("unsafe".into())]; + let result = evaluate("contains_pattern", &args, &ctx("fn foo() { unsafe { } }"), &dummy_span()).unwrap(); + assert!(!result.is_empty()); + assert_eq!(result[0].line, 1); + } + + #[test] + fn test_contains_pattern_no_match() { + let args = vec![Arg::String("unsafe".into())]; + let result = evaluate("contains_pattern", &args, &ctx("fn foo() { }"), &dummy_span()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_has_keyword_whole_word() { + let args = vec![Arg::String("loop".into())]; + // "loop" as a whole word + let result = evaluate("has_keyword", &args, &ctx("loop { }"), &dummy_span()).unwrap(); + assert!(!result.is_empty()); + // "looping" should NOT match + let result2 = evaluate("has_keyword", &args, &ctx("looping { }"), &dummy_span()).unwrap(); + assert!(result2.is_empty()); + } + + #[test] + fn test_lacks_keyword() { + let args = vec![Arg::String("require".into())]; + let result = evaluate("lacks_keyword", &args, &ctx("fn foo() { }"), &dummy_span()).unwrap(); + assert!(!result.is_empty()); // fires because "require" is absent + let result2 = evaluate("lacks_keyword", &args, &ctx("require(x > 0);"), &dummy_span()).unwrap(); + assert!(result2.is_empty()); // does not fire because "require" is present + } + + #[test] + fn test_line_count_exceeds() { + let src = "a\nb\nc\nd\ne"; + let args = vec![Arg::Int(3)]; + let result = evaluate("line_count_exceeds", &args, &ctx(src), &dummy_span()).unwrap(); + assert!(!result.is_empty()); + let args2 = vec![Arg::Int(10)]; + let result2 = evaluate("line_count_exceeds", &args2, &ctx(src), &dummy_span()).unwrap(); + assert!(result2.is_empty()); + } + + #[test] + fn test_nesting_depth_exceeds() { + let src = "fn a() { fn b() { fn c() { fn d() { } } } }"; + let args = vec![Arg::Int(3)]; + let result = evaluate("nesting_depth_exceeds", &args, &ctx(src), &dummy_span()).unwrap(); + assert!(!result.is_empty()); + } + + #[test] + fn test_always_never() { + let result = evaluate("always", &[], &ctx(""), &dummy_span()).unwrap(); + assert!(!result.is_empty()); + let result2 = evaluate("never", &[], &ctx(""), &dummy_span()).unwrap(); + assert!(result2.is_empty()); + } + + #[test] + fn test_unknown_predicate_error() { + let result = evaluate("does_not_exist", &[], &ctx(""), &dummy_span()); + assert!(result.is_err()); + } + + #[test] + fn test_wrong_arg_count_error() { + let result = evaluate("contains_pattern", &[], &ctx(""), &dummy_span()); + assert!(result.is_err()); + } +} diff --git a/libs/analysis-core/src/dsl/compiler.rs b/libs/analysis-core/src/dsl/compiler.rs new file mode 100644 index 0000000..067d5b5 --- /dev/null +++ b/libs/analysis-core/src/dsl/compiler.rs @@ -0,0 +1,454 @@ +//! DSL compiler — walks a [`RuleDefinition`] AST and produces a concrete +//! [`BaseRule`] implementation that plugs directly into the [`PluginRegistry`]. +//! +//! # Compilation pipeline +//! +//! ```text +//! DSL source text +//! └─ Lexer → Vec +//! └─ Parser → DslFile (Vec) +//! └─ Compiler → Vec> +//! └─ validate_condition (unknown predicates, arity) +//! └─ CompiledRule (runtime evaluator) +//! ``` + +use std::sync::Arc; + +use super::{ + ast::{Condition, DslFile, DslLanguage, DslSeverity, RuleDefinition}, + builtins::{self, EvalContext, PredicateMatch}, + error::{DslError, DslResult, Span}, + lexer::Lexer, + parser::Parser, +}; +use crate::plugin::interface::{BaseRule, Finding, Language, RuleMeta, Severity}; + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/// Parse and compile a DSL source string into a list of ready-to-register +/// [`BaseRule`] implementations. +/// +/// # Example +/// ```rust +/// use analysis_core::dsl::compiler::compile_str; +/// +/// let rules = compile_str(r#" +/// rule no-unsafe { +/// name: "No Unsafe Blocks" +/// description: "Flags unsafe blocks in Rust code" +/// severity: error +/// language: rust +/// when { contains_pattern("unsafe") } +/// message: "Unsafe block detected at line {line}" +/// } +/// "#).unwrap(); +/// assert_eq!(rules.len(), 1); +/// ``` +pub fn compile_str(src: &str) -> DslResult>> { + let tokens = Lexer::new(src).tokenize()?; + let file = Parser::new(tokens).parse()?; + compile_file(file) +} + +/// Compile an already-parsed [`DslFile`] into [`BaseRule`] implementations. +pub fn compile_file(file: DslFile) -> DslResult>> { + file.rules.into_iter().map(compile_rule).collect() +} + +/// Compile a single [`RuleDefinition`] into a [`BaseRule`]. +pub fn compile_rule(def: RuleDefinition) -> DslResult> { + // Validate the condition tree (unknown predicates, arity checks) + validate_condition(&def.condition)?; + + let severity = map_severity(&def.severity); + let languages: Vec = map_language(&def.language); + + // Leak static strings for RuleMeta (acceptable for long-lived rules) + let id: &'static str = Box::leak(def.id.clone().into_boxed_str()); + let name: &'static str = Box::leak(def.name.clone().into_boxed_str()); + let description: &'static str = Box::leak(def.description.clone().into_boxed_str()); + let languages_static: &'static [Language] = Box::leak(languages.into_boxed_slice()); + + let meta = RuleMeta { + id, + name, + description, + languages: languages_static, + default_severity: severity.clone(), + }; + + Ok(Box::new(CompiledRule { + meta, + condition: Arc::new(def.condition), + message_template: def.message, + suggestion_template: def.suggestion, + severity, + tags: def.tags, + })) +} + +// --------------------------------------------------------------------------- +// Validation pass +// --------------------------------------------------------------------------- + +fn validate_condition(cond: &Condition) -> DslResult<()> { + match cond { + Condition::Predicate { name, args, span } => { + validate_predicate(name, args, span) + } + Condition::And(l, r) | Condition::Or(l, r) => { + validate_condition(l)?; + validate_condition(r) + } + Condition::Not(inner) => validate_condition(inner), + } +} + +fn validate_predicate(name: &str, args: &[super::ast::Arg], span: &Span) -> DslResult<()> { + if !builtins::is_known(name) { + return Err(DslError::UnknownPredicate { + name: name.to_string(), + span: span.clone(), + }); + } + + // Check arity against descriptor + if let Some(descriptor) = builtins::all_descriptors().iter().find(|d| d.name == name) { + if let Some(expected) = descriptor.arity { + if args.len() != expected { + return Err(DslError::WrongArgCount { + name: name.to_string(), + expected, + got: args.len(), + span: span.clone(), + }); + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Severity / Language mapping +// --------------------------------------------------------------------------- + +fn map_severity(s: &DslSeverity) -> Severity { + match s { + DslSeverity::Info => Severity::Info, + DslSeverity::Warning => Severity::Warning, + DslSeverity::Error => Severity::Error, + DslSeverity::Critical => Severity::Critical, + } +} + +fn map_language(l: &DslLanguage) -> Vec { + match l { + DslLanguage::Solidity => vec![Language::Solidity], + DslLanguage::Rust => vec![Language::Rust], + DslLanguage::Vyper => vec![Language::Vyper], + DslLanguage::Any => vec![Language::Solidity, Language::Rust, Language::Vyper], + } +} + +// --------------------------------------------------------------------------- +// CompiledRule — the runtime BaseRule implementation +// --------------------------------------------------------------------------- + +/// A rule produced by compiling a DSL definition. +/// +/// Implements [`BaseRule`] so it can be registered directly in a +/// [`PluginRegistry`] alongside hand-written rules. +pub struct CompiledRule { + meta: RuleMeta, + condition: Arc, + message_template: String, + suggestion_template: Option, + severity: Severity, + tags: Vec, +} + +impl CompiledRule { + /// Evaluate the condition tree against the given source, returning all + /// matching locations. + fn eval_condition( + &self, + cond: &Condition, + ctx: &EvalContext<'_>, + ) -> DslResult> { + match cond { + Condition::Predicate { name, args, span } => { + builtins::evaluate(name, args, ctx, span) + } + + Condition::And(left, right) => { + let left_matches = self.eval_condition(left, ctx)?; + if left_matches.is_empty() { + // Short-circuit: left is false + return Ok(vec![]); + } + let right_matches = self.eval_condition(right, ctx)?; + if right_matches.is_empty() { + Ok(vec![]) + } else { + // Return the union of both match sets + let mut combined = left_matches; + combined.extend(right_matches); + Ok(combined) + } + } + + Condition::Or(left, right) => { + let left_matches = self.eval_condition(left, ctx)?; + if !left_matches.is_empty() { + return Ok(left_matches); + } + self.eval_condition(right, ctx) + } + + Condition::Not(inner) => { + let inner_matches = self.eval_condition(inner, ctx)?; + if inner_matches.is_empty() { + // Inner did NOT match → NOT fires once at line 1 + Ok(vec![PredicateMatch::new(1, "not-condition satisfied")]) + } else { + Ok(vec![]) + } + } + } + } + + /// Render the message template, substituting `{line}`, `{file}`, and + /// `{snippet}` placeholders. + fn render_message(&self, m: &PredicateMatch, file_path: &str) -> String { + self.message_template + .replace("{line}", &m.line.to_string()) + .replace("{file}", file_path) + .replace("{snippet}", &m.snippet) + } + + /// Render the suggestion template (if any). + fn render_suggestion(&self, m: &PredicateMatch, file_path: &str) -> Option { + self.suggestion_template.as_ref().map(|tmpl| { + tmpl.replace("{line}", &m.line.to_string()) + .replace("{file}", file_path) + .replace("{snippet}", &m.snippet) + }) + } +} + +impl BaseRule for CompiledRule { + fn meta(&self) -> &RuleMeta { + &self.meta + } + + fn analyze(&self, file_path: &str, source: &str) -> Vec { + let ctx = EvalContext::new(file_path, source); + let condition = Arc::clone(&self.condition); + + match self.eval_condition(&condition, &ctx) { + Err(_) => vec![], // evaluation errors produce no findings + Ok(matches) => matches + .into_iter() + .map(|m| Finding { + rule_id: self.meta.id.to_string(), + severity: self.severity.clone(), + message: self.render_message(&m, file_path), + file: file_path.to_string(), + line: m.line, + column: m.column, + suggestion: self.render_suggestion(&m, file_path), + }) + .collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Tags accessor (not part of BaseRule but useful for filtering) +// --------------------------------------------------------------------------- + +impl CompiledRule { + pub fn tags(&self) -> &[String] { + &self.tags + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn compile(src: &str) -> Vec> { + compile_str(src).expect("compile failed") + } + + const SIMPLE_RULE: &str = r#" + rule no-unsafe { + name: "No Unsafe Blocks" + description: "Flags unsafe blocks in Rust code" + severity: error + language: rust + when { contains_pattern("unsafe") } + message: "Unsafe block at line {line}: {snippet}" + suggestion: "Wrap in a safe abstraction" + } + "#; + + #[test] + fn test_compile_produces_one_rule() { + let rules = compile(SIMPLE_RULE); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].meta().id, "no-unsafe"); + assert_eq!(rules[0].meta().name, "No Unsafe Blocks"); + } + + #[test] + fn test_rule_fires_on_matching_source() { + let rules = compile(SIMPLE_RULE); + let findings = rules[0].analyze("foo.rs", "fn main() { unsafe { do_thing(); } }"); + assert!(!findings.is_empty()); + assert_eq!(findings[0].rule_id, "no-unsafe"); + assert!(findings[0].message.contains("unsafe")); + assert_eq!(findings[0].severity, Severity::Error); + } + + #[test] + fn test_rule_silent_on_clean_source() { + let rules = compile(SIMPLE_RULE); + let findings = rules[0].analyze("foo.rs", "fn main() { println!(\"hello\"); }"); + assert!(findings.is_empty()); + } + + #[test] + fn test_and_condition() { + let src = r#" + rule and-rule { + name: "And Rule" description: "d" severity: warning + when { + contains_pattern("loop") and contains_pattern("unsafe") + } + message: "Both loop and unsafe found at line {line}" + } + "#; + let rules = compile(src); + // Both present → fires + let findings = rules[0].analyze("f.rs", "loop { unsafe { } }"); + assert!(!findings.is_empty()); + // Only one present → silent + let findings2 = rules[0].analyze("f.rs", "loop { }"); + assert!(findings2.is_empty()); + } + + #[test] + fn test_or_condition() { + let src = r#" + rule or-rule { + name: "Or Rule" description: "d" severity: info + when { + contains_pattern("(?i)todo") or contains_pattern("(?i)fixme") + } + message: "Found marker at line {line}" + } + "#; + let rules = compile(src); + let findings = rules[0].analyze("f.rs", "// TODO: fix this"); + assert!(!findings.is_empty()); + let findings2 = rules[0].analyze("f.rs", "// FIXME: also this"); + assert!(!findings2.is_empty()); + let findings3 = rules[0].analyze("f.rs", "// clean code"); + assert!(findings3.is_empty()); + } + + #[test] + fn test_not_condition() { + let src = r#" + rule not-rule { + name: "Not Rule" description: "d" severity: warning + when { + not contains_pattern("require") + } + message: "Missing require() guard" + } + "#; + let rules = compile(src); + // No require → fires + let findings = rules[0].analyze("f.sol", "function foo() public { doThing(); }"); + assert!(!findings.is_empty()); + // Has require → silent + let findings2 = rules[0].analyze("f.sol", "function foo() public { require(x > 0); }"); + assert!(findings2.is_empty()); + } + + #[test] + fn test_unknown_predicate_compile_error() { + let src = r#" + rule bad { + name: "Bad" description: "d" severity: info + when { does_not_exist("x") } + message: "m" + } + "#; + let result = compile_str(src); + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("does_not_exist"), "expected unknown predicate error, got: {}", msg); + } + + #[test] + fn test_message_template_substitution() { + let rules = compile(SIMPLE_RULE); + let findings = rules[0].analyze("src/main.rs", "unsafe { }"); + assert!(!findings.is_empty()); + let msg = &findings[0].message; + assert!(msg.contains("1"), "line number should appear in message: {}", msg); + assert!(msg.contains("unsafe"), "snippet should appear in message: {}", msg); + } + + #[test] + fn test_language_filter_rust() { + let rules = compile(SIMPLE_RULE); + // The rule targets Rust — meta should list only Rust + assert_eq!(rules[0].meta().languages, &[Language::Rust]); + } + + #[test] + fn test_any_language_expands_to_all() { + let src = r#" + rule any-lang { + name: "Any" description: "d" severity: info + language: any + when { contains_pattern("TODO") } + message: "TODO found" + } + "#; + let rules = compile(src); + let langs = rules[0].meta().languages; + assert!(langs.contains(&Language::Rust)); + assert!(langs.contains(&Language::Solidity)); + assert!(langs.contains(&Language::Vyper)); + } + + #[test] + fn test_compile_multiple_rules() { + let src = r#" + rule rule-a { + name: "A" description: "da" severity: info + when { contains_pattern("a") } + message: "found a" + } + rule rule-b { + name: "B" description: "db" severity: warning + when { contains_pattern("b") } + message: "found b" + } + "#; + let rules = compile(src); + assert_eq!(rules.len(), 2); + } +} diff --git a/libs/analysis-core/src/dsl/error.rs b/libs/analysis-core/src/dsl/error.rs new file mode 100644 index 0000000..d5d2389 --- /dev/null +++ b/libs/analysis-core/src/dsl/error.rs @@ -0,0 +1,76 @@ +//! DSL error types. + +use thiserror::Error; + +/// Span within DSL source text (byte offsets, 0-based). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + pub start: usize, + pub end: usize, + /// 1-based line number. + pub line: usize, + /// 1-based column number. + pub col: usize, +} + +impl Span { + pub fn new(start: usize, end: usize, line: usize, col: usize) -> Self { + Self { start, end, line, col } + } +} + +impl std::fmt::Display for Span { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "line {}, col {}", self.line, self.col) + } +} + +/// All errors that can occur during DSL processing. +#[derive(Debug, Error)] +pub enum DslError { + // ---- Lexer errors ------------------------------------------------------- + #[error("Unexpected character '{ch}' at {span}")] + UnexpectedChar { ch: char, span: Span }, + + #[error("Unterminated string literal starting at {span}")] + UnterminatedString { span: Span }, + + // ---- Parser errors ------------------------------------------------------ + #[error("Unexpected token '{found}' at {span}, expected {expected}")] + UnexpectedToken { found: String, expected: String, span: Span }, + + #[error("Unexpected end of input, expected {expected}")] + UnexpectedEof { expected: String }, + + #[error("Duplicate field '{field}' in rule block at {span}")] + DuplicateField { field: String, span: Span }, + + #[error("Missing required field '{field}' in rule definition")] + MissingField { field: String }, + + // ---- Compiler errors ---------------------------------------------------- + #[error("Unknown predicate '{name}' at {span}")] + UnknownPredicate { name: String, span: Span }, + + #[error("Wrong number of arguments for predicate '{name}': expected {expected}, got {got} at {span}")] + WrongArgCount { name: String, expected: usize, got: usize, span: Span }, + + #[error("Type mismatch in predicate '{name}' argument {arg_index}: {detail} at {span}")] + TypeMismatch { name: String, arg_index: usize, detail: String, span: Span }, + + #[error("Invalid severity '{value}' at {span}; expected one of: info, warning, error, critical")] + InvalidSeverity { value: String, span: Span }, + + #[error("Invalid language '{value}' at {span}; expected one of: solidity, rust, vyper, any")] + InvalidLanguage { value: String, span: Span }, + + #[error("Regex compilation error in predicate '{name}': {detail}")] + InvalidRegex { name: String, detail: String }, + + // ---- Generic ------------------------------------------------------------ + #[error("{0}")] + Other(String), +} + +/// Convenience alias. +pub type DslResult = Result; diff --git a/libs/analysis-core/src/dsl/example_rules.dsl b/libs/analysis-core/src/dsl/example_rules.dsl new file mode 100644 index 0000000..838da74 --- /dev/null +++ b/libs/analysis-core/src/dsl/example_rules.dsl @@ -0,0 +1,119 @@ +// Example DSL rules demonstrating the syntax and capabilities + +// Simple rule to detect unsafe blocks in Rust +rule no-unsafe-blocks { + name: "No Unsafe Blocks" + description: "Flags unsafe blocks in Rust source files" + severity: error + language: rust + when { + contains_pattern("unsafe") + } + message: "Unsafe block detected at line {line}: {snippet}" + suggestion: "Wrap the operation in a safe abstraction" +} + +// Rule with complex condition using AND/OR/NOT +rule unsafe-without-wrapper { + name: "Unsafe Without Safe Wrapper" + description: "Detects unsafe code that is not wrapped in a safe abstraction" + severity: warning + language: rust + when { + contains_pattern("unsafe") and not contains_pattern("safe_wrapper") + } + message: "Unsafe usage without safe wrapper at line {line}" + suggestion: "Consider wrapping in a safe abstraction" +} + +// Rule for TODO/FIXME comments +rule todo-comments { + name: "TODO Comments" + description: "Flags TODO and FIXME comments that should be resolved" + severity: info + language: any + when { + contains_pattern("(?i)todo") or contains_pattern("(?i)fixme") + } + message: "Unresolved comment marker at line {line}: {snippet}" + suggestion: "Resolve the TODO or FIXME before deployment" +} + +// Rule with tags for categorization +rule gas-optimization { + name: "Gas Optimization Opportunity" + description: "Detects patterns that could be optimized for gas efficiency" + severity: warning + language: solidity + tags: [gas, optimization, performance] + when { + contains_pattern("public") and contains_pattern("mapping") + } + message: "Consider gas optimization at line {line}" + suggestion: "Review if public visibility is necessary" +} + +// Rule checking for missing require statements +rule missing-require { + name: "Missing Require Guard" + description: "Detects functions without proper input validation" + severity: error + language: solidity + when { + not contains_pattern("require") + } + message: "Function missing require() guard" + suggestion: "Add input validation with require()" +} + +// Rule for file complexity +rule complex-file { + name: "Complex File" + description: "Flags files that exceed a reasonable line count" + severity: warning + language: any + when { + line_count_exceeds(500) + } + message: "File has {snippet} lines (threshold: 500)" + suggestion: "Consider splitting into smaller modules" +} + +// Rule for nesting depth +rule deep-nesting { + name: "Deep Nesting" + description: "Flags code with excessive nesting depth" + severity: warning + language: any + when { + nesting_depth_exceeds(5) + } + message: "Nesting depth exceeds threshold at line {line}" + suggestion: "Refactor to reduce nesting complexity" +} + +// Rule for comment ratio +rule low-comment-ratio { + name: "Low Comment Ratio" + description: "Flags files with insufficient documentation" + severity: info + language: any + when { + comment_ratio_below(0.1) + } + message: "Comment ratio is {snippet} (threshold: 0.1)" + suggestion: "Add more documentation to explain the code" +} + +// Rule using keyword matching +rule loop-keyword { + name: "Loop Keyword Detected" + description: "Detects usage of loop keyword" + severity: info + language: rust + when { + has_keyword("loop") + } + message: "Loop keyword found at line {line}" + suggestion: "Ensure loop has a clear exit condition" +} diff --git a/libs/analysis-core/src/dsl/lexer.rs b/libs/analysis-core/src/dsl/lexer.rs new file mode 100644 index 0000000..a381977 --- /dev/null +++ b/libs/analysis-core/src/dsl/lexer.rs @@ -0,0 +1,443 @@ +//! DSL lexer — converts raw source text into a flat token stream. +//! +//! # DSL token grammar (informal) +//! +//! ```text +//! rule { +//! name: "" +//! description: "" +//! severity: info | warning | error | critical +//! language: solidity | rust | vyper | any +//! tags: [, ...] +//! +//! when { +//! (, ...) +//! and | or +//! not (...) +//! } +//! +//! message: "" +//! suggestion: "" +//! } +//! ``` + +use super::error::{DslError, DslResult, Span}; + +// --------------------------------------------------------------------------- +// Token kinds +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub enum TokenKind { + // Keywords + Rule, + When, + And, + Or, + Not, + + // Punctuation + LBrace, // { + RBrace, // } + LParen, // ( + RParen, // ) + LBracket, // [ + RBracket, // ] + Comma, // , + Colon, // : + Dot, // . + + // Literals + Ident(String), + StringLit(String), + IntLit(i64), + FloatLit(f64), + BoolLit(bool), + + // End of file + Eof, +} + +impl std::fmt::Display for TokenKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenKind::Rule => write!(f, "rule"), + TokenKind::When => write!(f, "when"), + TokenKind::And => write!(f, "and"), + TokenKind::Or => write!(f, "or"), + TokenKind::Not => write!(f, "not"), + TokenKind::LBrace => write!(f, "{{"), + TokenKind::RBrace => write!(f, "}}"), + TokenKind::LParen => write!(f, "("), + TokenKind::RParen => write!(f, ")"), + TokenKind::LBracket => write!(f, "["), + TokenKind::RBracket => write!(f, "]"), + TokenKind::Comma => write!(f, ","), + TokenKind::Colon => write!(f, ":"), + TokenKind::Dot => write!(f, "."), + TokenKind::Ident(s) => write!(f, "{}", s), + TokenKind::StringLit(s) => write!(f, "\"{}\"", s), + TokenKind::IntLit(n) => write!(f, "{}", n), + TokenKind::FloatLit(n) => write!(f, "{}", n), + TokenKind::BoolLit(b) => write!(f, "{}", b), + TokenKind::Eof => write!(f, ""), + } + } +} + +// --------------------------------------------------------------------------- +// Token +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, +} + +impl Token { + pub fn new(kind: TokenKind, span: Span) -> Self { + Self { kind, span } + } +} + +// --------------------------------------------------------------------------- +// Lexer +// --------------------------------------------------------------------------- + +pub struct Lexer<'src> { + src: &'src str, + chars: std::iter::Peekable>, + pos: usize, + line: usize, + col: usize, +} + +impl<'src> Lexer<'src> { + pub fn new(src: &'src str) -> Self { + Self { + src, + chars: src.char_indices().peekable(), + pos: 0, + line: 1, + col: 1, + } + } + + /// Tokenise the entire source and return a `Vec` ending with `Eof`. + pub fn tokenize(mut self) -> DslResult> { + let mut tokens = Vec::new(); + loop { + let tok = self.next_token()?; + let is_eof = tok.kind == TokenKind::Eof; + tokens.push(tok); + if is_eof { + break; + } + } + Ok(tokens) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + fn current_span(&self, start: usize, start_line: usize, start_col: usize) -> Span { + Span::new(start, self.pos, start_line, start_col) + } + + fn peek_char(&mut self) -> Option { + self.chars.peek().map(|(_, c)| *c) + } + + fn advance(&mut self) -> Option { + if let Some((idx, ch)) = self.chars.next() { + self.pos = idx + ch.len_utf8(); + if ch == '\n' { + self.line += 1; + self.col = 1; + } else { + self.col += 1; + } + Some(ch) + } else { + None + } + } + + fn skip_whitespace_and_comments(&mut self) { + loop { + match self.peek_char() { + Some(c) if c.is_whitespace() => { + self.advance(); + } + // Line comment: // ... + Some('/') => { + // Peek two chars + let rest = &self.src[self.pos..]; + if rest.starts_with("//") { + while let Some(c) = self.peek_char() { + if c == '\n' { + break; + } + self.advance(); + } + } else { + break; + } + } + // Block comment: /* ... */ + Some('*') => { + let rest = &self.src[self.pos..]; + if rest.starts_with("/*") { + self.advance(); // / + self.advance(); // * + loop { + match self.advance() { + None => break, + Some('*') => { + if self.peek_char() == Some('/') { + self.advance(); + break; + } + } + _ => {} + } + } + } else { + break; + } + } + _ => break, + } + } + } + + fn next_token(&mut self) -> DslResult { + self.skip_whitespace_and_comments(); + + let start = self.pos; + let start_line = self.line; + let start_col = self.col; + + let ch = match self.advance() { + None => { + return Ok(Token::new( + TokenKind::Eof, + Span::new(start, start, start_line, start_col), + )) + } + Some(c) => c, + }; + + let kind = match ch { + '{' => TokenKind::LBrace, + '}' => TokenKind::RBrace, + '(' => TokenKind::LParen, + ')' => TokenKind::RParen, + '[' => TokenKind::LBracket, + ']' => TokenKind::RBracket, + ',' => TokenKind::Comma, + ':' => TokenKind::Colon, + '.' => TokenKind::Dot, + + // String literal + '"' => self.lex_string(start, start_line, start_col)?, + + // Number literal + c if c.is_ascii_digit() || (c == '-' && self.peek_char().map_or(false, |p| p.is_ascii_digit())) => { + self.lex_number(c, start, start_line, start_col)? + } + + // Identifier or keyword + c if c.is_alphabetic() || c == '_' => { + self.lex_ident_or_keyword(c, start, start_line, start_col) + } + + other => { + return Err(DslError::UnexpectedChar { + ch: other, + span: Span::new(start, self.pos, start_line, start_col), + }) + } + }; + + Ok(Token::new(kind, self.current_span(start, start_line, start_col))) + } + + fn lex_string(&mut self, start: usize, line: usize, col: usize) -> DslResult { + let mut s = String::new(); + loop { + match self.advance() { + None => { + return Err(DslError::UnterminatedString { + span: Span::new(start, self.pos, line, col), + }) + } + Some('"') => break, + Some('\\') => { + // Escape sequences + match self.advance() { + Some('n') => s.push('\n'), + Some('t') => s.push('\t'), + Some('r') => s.push('\r'), + Some('\\') => s.push('\\'), + Some('"') => s.push('"'), + Some(c) => { + s.push('\\'); + s.push(c); + } + None => { + return Err(DslError::UnterminatedString { + span: Span::new(start, self.pos, line, col), + }) + } + } + } + Some(c) => s.push(c), + } + } + Ok(TokenKind::StringLit(s)) + } + + fn lex_number(&mut self, first: char, start: usize, line: usize, col: usize) -> DslResult { + let mut raw = String::from(first); + let mut is_float = false; + + while let Some(c) = self.peek_char() { + if c.is_ascii_digit() { + raw.push(c); + self.advance(); + } else if c == '.' && !is_float { + is_float = true; + raw.push(c); + self.advance(); + } else { + break; + } + } + + if is_float { + raw.parse::() + .map(TokenKind::FloatLit) + .map_err(|_| DslError::UnexpectedChar { ch: '.', span: Span::new(start, self.pos, line, col) }) + } else { + raw.parse::() + .map(TokenKind::IntLit) + .map_err(|_| DslError::UnexpectedChar { ch: first, span: Span::new(start, self.pos, line, col) }) + } + } + + fn lex_ident_or_keyword(&mut self, first: char, _start: usize, _line: usize, _col: usize) -> TokenKind { + let mut ident = String::from(first); + while let Some(c) = self.peek_char() { + if c.is_alphanumeric() || c == '_' || c == '-' { + ident.push(c); + self.advance(); + } else { + break; + } + } + + match ident.as_str() { + "rule" => TokenKind::Rule, + "when" => TokenKind::When, + "and" => TokenKind::And, + "or" => TokenKind::Or, + "not" => TokenKind::Not, + "true" => TokenKind::BoolLit(true), + "false" => TokenKind::BoolLit(false), + _ => TokenKind::Ident(ident), + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(src: &str) -> Vec { + Lexer::new(src) + .tokenize() + .unwrap() + .into_iter() + .map(|t| t.kind) + .collect() + } + + #[test] + fn test_basic_tokens() { + let toks = kinds("rule my-rule { }"); + assert_eq!( + toks, + vec![ + TokenKind::Rule, + TokenKind::Ident("my-rule".into()), + TokenKind::LBrace, + TokenKind::RBrace, + TokenKind::Eof, + ] + ); + } + + #[test] + fn test_string_literal() { + let toks = kinds(r#"name: "hello world""#); + assert_eq!( + toks, + vec![ + TokenKind::Ident("name".into()), + TokenKind::Colon, + TokenKind::StringLit("hello world".into()), + TokenKind::Eof, + ] + ); + } + + #[test] + fn test_line_comment_skipped() { + let toks = kinds("rule // this is a comment\n foo"); + assert_eq!( + toks, + vec![ + TokenKind::Rule, + TokenKind::Ident("foo".into()), + TokenKind::Eof, + ] + ); + } + + #[test] + fn test_integer_literal() { + let toks = kinds("42"); + assert_eq!(toks, vec![TokenKind::IntLit(42), TokenKind::Eof]); + } + + #[test] + fn test_bool_literals() { + let toks = kinds("true false"); + assert_eq!( + toks, + vec![TokenKind::BoolLit(true), TokenKind::BoolLit(false), TokenKind::Eof] + ); + } + + #[test] + fn test_when_and_or_not() { + let toks = kinds("when and or not"); + assert_eq!( + toks, + vec![ + TokenKind::When, + TokenKind::And, + TokenKind::Or, + TokenKind::Not, + TokenKind::Eof, + ] + ); + } +} diff --git a/libs/analysis-core/src/dsl/mod.rs b/libs/analysis-core/src/dsl/mod.rs new file mode 100644 index 0000000..e7b48e8 --- /dev/null +++ b/libs/analysis-core/src/dsl/mod.rs @@ -0,0 +1,101 @@ +//! Domain-Specific Language (DSL) for defining GasGuard analysis rules. +//! +//! # Overview +//! +//! The DSL lets you write rules in a concise, declarative syntax instead of +//! hand-coding Rust structs. A rule file is plain text that the compiler +//! turns into a [`BaseRule`] implementation ready for the [`PluginRegistry`]. +//! +//! # Quick start +//! +//! ```rust +//! use analysis_core::dsl::compiler::compile_str; +//! use analysis_core::plugin::{PluginRegistry, AnalysisInput}; +//! +//! let rules = compile_str(r#" +//! rule no-unsafe { +//! name: "No Unsafe Blocks" +//! description: "Flags unsafe blocks in Rust source files" +//! severity: error +//! language: rust +//! when { +//! contains_pattern("unsafe") +//! } +//! message: "Unsafe block detected at line {line}: {snippet}" +//! suggestion: "Wrap the operation in a safe abstraction" +//! } +//! "#).unwrap(); +//! +//! let mut registry = PluginRegistry::new(); +//! for rule in rules { +//! registry.register_default(rule).unwrap(); +//! } +//! +//! let inputs = vec![AnalysisInput::new("src/main.rs", "fn main() { unsafe {} }")]; +//! let session = registry.run_session(&inputs); +//! assert!(!session.is_clean()); +//! ``` +//! +//! # DSL syntax +//! +//! ```text +//! rule { +//! name: "" +//! description: "" +//! severity: info | warning | error | critical +//! language: solidity | rust | vyper | any // default: any +//! tags: [, ...] // optional +//! +//! when { +//! +//! } +//! +//! message: "" // supports {line}, {file}, {snippet} +//! suggestion: "" // optional +//! } +//! ``` +//! +//! ## Conditions +//! +//! Conditions are boolean expressions built from predicate calls: +//! +//! ```text +//! contains_pattern("unsafe") +//! has_keyword("loop") and not contains_pattern("break") +//! line_count_exceeds(500) or nesting_depth_exceeds(5) +//! (contains_pattern("todo") or contains_pattern("fixme")) and not has_keyword("resolved") +//! ``` +//! +//! ## Built-in predicates +//! +//! | Predicate | Args | Description | +//! |-----------|------|-------------| +//! | `contains_pattern(pat)` | regex string | Fires on every line matching the pattern | +//! | `matches_regex(pat)` | regex string | Alias for `contains_pattern` | +//! | `has_keyword(kw)` | string/ident | Whole-word keyword match | +//! | `lacks_keyword(kw)` | string/ident | Fires once if keyword is absent | +//! | `line_count_exceeds(n)` | integer | Fires if file has > n lines | +//! | `function_count_exceeds(n)` | integer | Fires if file has > n function definitions | +//! | `identifier_matches(pat)` | regex string | Fires on every matching identifier | +//! | `comment_ratio_below(r)` | float 0–1 | Fires if comment ratio < r | +//! | `nesting_depth_exceeds(n)` | integer | Fires if max brace depth > n | +//! | `always()` | — | Always fires (unconditional rule) | +//! | `never()` | — | Never fires (disabled rule) | + +pub mod ast; +pub mod builtins; +pub mod compiler; +pub mod error; +pub mod lexer; +pub mod parser; + +#[cfg(test)] +mod verification_test; + +// Convenience re-exports +pub use ast::{Arg, Condition, DslFile, DslLanguage, DslSeverity, RuleDefinition}; +pub use builtins::{evaluate as eval_predicate, EvalContext, PredicateMatch}; +pub use compiler::compile_str; +pub use error::{DslError, DslResult, Span}; +pub use lexer::{Lexer, Token, TokenKind}; +pub use parser::Parser; diff --git a/libs/analysis-core/src/dsl/parser.rs b/libs/analysis-core/src/dsl/parser.rs new file mode 100644 index 0000000..320c0d0 --- /dev/null +++ b/libs/analysis-core/src/dsl/parser.rs @@ -0,0 +1,540 @@ +//! DSL parser — converts a flat token stream into a [`DslFile`] AST. + +use super::{ + ast::{Arg, Condition, DslFile, DslLanguage, DslSeverity, RuleDefinition}, + error::{DslError, DslResult, Span}, + lexer::{Token, TokenKind}, +}; + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +pub struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + pub fn new(tokens: Vec) -> Self { + Self { tokens, pos: 0 } + } + + // ----------------------------------------------------------------------- + // Token navigation helpers + // ----------------------------------------------------------------------- + + #[allow(dead_code)] + fn peek(&self) -> &Token { + &self.tokens[self.pos] + } + + fn peek_kind(&self) -> &TokenKind { + &self.tokens[self.pos].kind + } + + fn advance(&mut self) -> &Token { + let tok = &self.tokens[self.pos]; + if self.pos + 1 < self.tokens.len() { + self.pos += 1; + } + tok + } + + fn current_span(&self) -> Span { + self.tokens[self.pos].span.clone() + } + + /// Consume the next token and assert it matches `expected_kind`. + fn expect(&mut self, expected: &TokenKind) -> DslResult<&Token> { + let tok = self.advance(); + if &tok.kind == expected { + // SAFETY: we just advanced, so pos > 0 + Ok(&self.tokens[self.pos - 1]) + } else { + Err(DslError::UnexpectedToken { + found: tok.kind.to_string(), + expected: expected.to_string(), + span: tok.span.clone(), + }) + } + } + + /// Consume the next token if it matches `kind`, returning true. + fn eat(&mut self, kind: &TokenKind) -> bool { + if self.peek_kind() == kind { + self.advance(); + true + } else { + false + } + } + + fn is_eof(&self) -> bool { + matches!(self.peek_kind(), TokenKind::Eof) + } + + // ----------------------------------------------------------------------- + // Top-level parse + // ----------------------------------------------------------------------- + + pub fn parse(mut self) -> DslResult { + let mut rules = Vec::new(); + while !self.is_eof() { + rules.push(self.parse_rule()?); + } + Ok(DslFile { rules }) + } + + // ----------------------------------------------------------------------- + // Rule definition + // ----------------------------------------------------------------------- + + fn parse_rule(&mut self) -> DslResult { + let start_span = self.current_span(); + + // `rule` + self.expect(&TokenKind::Rule)?; + + // `` + let id = self.expect_ident("rule identifier")?; + + // `{` + self.expect(&TokenKind::LBrace)?; + + // Fields + let mut name: Option = None; + let mut description: Option = None; + let mut severity: Option = None; + let mut language: Option = None; + let mut tags: Vec = Vec::new(); + let mut condition: Option = None; + let mut message: Option = None; + let mut suggestion: Option = None; + + while !matches!(self.peek_kind(), TokenKind::RBrace | TokenKind::Eof) { + let field_span = self.current_span(); + + // `when` is a reserved keyword token, not an Ident — handle it first. + if self.eat(&TokenKind::When) { + let cond = self.parse_when_block()?; + if condition.replace(cond).is_some() { + return Err(DslError::DuplicateField { field: "when".into(), span: field_span }); + } + continue; + } + + let field = self.expect_ident("field name")?; + + match field.as_str() { + "name" => { + self.expect(&TokenKind::Colon)?; + let v = self.expect_string("name value")?; + if name.replace(v).is_some() { + return Err(DslError::DuplicateField { field: "name".into(), span: field_span }); + } + } + "description" => { + self.expect(&TokenKind::Colon)?; + let v = self.expect_string("description value")?; + if description.replace(v).is_some() { + return Err(DslError::DuplicateField { field: "description".into(), span: field_span }); + } + } + "severity" => { + self.expect(&TokenKind::Colon)?; + let v = self.parse_severity()?; + if severity.replace(v).is_some() { + return Err(DslError::DuplicateField { field: "severity".into(), span: field_span }); + } + } + "language" => { + self.expect(&TokenKind::Colon)?; + let v = self.parse_language()?; + if language.replace(v).is_some() { + return Err(DslError::DuplicateField { field: "language".into(), span: field_span }); + } + } + "tags" => { + self.expect(&TokenKind::Colon)?; + tags = self.parse_tag_list()?; + } + "message" => { + self.expect(&TokenKind::Colon)?; + let v = self.expect_string("message value")?; + if message.replace(v).is_some() { + return Err(DslError::DuplicateField { field: "message".into(), span: field_span }); + } + } + "suggestion" => { + self.expect(&TokenKind::Colon)?; + let v = self.expect_string("suggestion value")?; + suggestion = Some(v); + } + unknown => { + return Err(DslError::UnexpectedToken { + found: unknown.to_string(), + expected: "name | description | severity | language | tags | when | message | suggestion".into(), + span: field_span, + }); + } + } + } + + // `}` + let end_span = self.current_span(); + self.expect(&TokenKind::RBrace)?; + + // Validate required fields + let name = name.ok_or_else(|| DslError::MissingField { field: "name".into() })?; + let description = description.ok_or_else(|| DslError::MissingField { field: "description".into() })?; + let severity = severity.ok_or_else(|| DslError::MissingField { field: "severity".into() })?; + let language = language.unwrap_or(DslLanguage::Any); + let condition = condition.ok_or_else(|| DslError::MissingField { field: "when".into() })?; + let message = message.ok_or_else(|| DslError::MissingField { field: "message".into() })?; + + Ok(RuleDefinition { + id, + name, + description, + severity, + language, + tags, + condition, + message, + suggestion, + span: Span::new( + start_span.start, + end_span.end, + start_span.line, + start_span.col, + ), + }) + } + + // ----------------------------------------------------------------------- + // Field value parsers + // ----------------------------------------------------------------------- + + fn parse_severity(&mut self) -> DslResult { + let tok = self.advance(); + let span = tok.span.clone(); + match &tok.kind { + TokenKind::Ident(s) => match s.as_str() { + "info" => Ok(DslSeverity::Info), + "warning" => Ok(DslSeverity::Warning), + "error" => Ok(DslSeverity::Error), + "critical" => Ok(DslSeverity::Critical), + other => Err(DslError::InvalidSeverity { value: other.to_string(), span }), + }, + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: "info | warning | error | critical".into(), + span, + }), + } + } + + fn parse_language(&mut self) -> DslResult { + let tok = self.advance(); + let span = tok.span.clone(); + match &tok.kind { + TokenKind::Ident(s) => match s.as_str() { + "solidity" => Ok(DslLanguage::Solidity), + "rust" => Ok(DslLanguage::Rust), + "vyper" => Ok(DslLanguage::Vyper), + "any" => Ok(DslLanguage::Any), + other => Err(DslError::InvalidLanguage { value: other.to_string(), span }), + }, + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: "solidity | rust | vyper | any".into(), + span, + }), + } + } + + fn parse_tag_list(&mut self) -> DslResult> { + self.expect(&TokenKind::LBracket)?; + let mut tags = Vec::new(); + while !matches!(self.peek_kind(), TokenKind::RBracket | TokenKind::Eof) { + let tag = self.expect_ident_or_string("tag")?; + tags.push(tag); + if !self.eat(&TokenKind::Comma) { + break; + } + } + self.expect(&TokenKind::RBracket)?; + Ok(tags) + } + + // ----------------------------------------------------------------------- + // `when` block + // ----------------------------------------------------------------------- + + fn parse_when_block(&mut self) -> DslResult { + self.expect(&TokenKind::LBrace)?; + let cond = self.parse_condition()?; + self.expect(&TokenKind::RBrace)?; + Ok(cond) + } + + // ----------------------------------------------------------------------- + // Condition expression (recursive descent) + // ----------------------------------------------------------------------- + + fn parse_condition(&mut self) -> DslResult { + self.parse_or_expr() + } + + fn parse_or_expr(&mut self) -> DslResult { + let mut left = self.parse_and_expr()?; + while self.eat(&TokenKind::Or) { + let right = self.parse_and_expr()?; + left = Condition::Or(Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_and_expr(&mut self) -> DslResult { + let mut left = self.parse_unary()?; + while self.eat(&TokenKind::And) { + let right = self.parse_unary()?; + left = Condition::And(Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_unary(&mut self) -> DslResult { + if self.eat(&TokenKind::Not) { + let inner = self.parse_unary()?; + return Ok(Condition::Not(Box::new(inner))); + } + self.parse_primary() + } + + fn parse_primary(&mut self) -> DslResult { + // Parenthesised sub-expression + if self.eat(&TokenKind::LParen) { + let cond = self.parse_condition()?; + self.expect(&TokenKind::RParen)?; + return Ok(cond); + } + + // Predicate call: `name(args...)` + let span = self.current_span(); + let name = self.expect_ident("predicate name")?; + self.expect(&TokenKind::LParen)?; + let args = self.parse_arg_list()?; + self.expect(&TokenKind::RParen)?; + + Ok(Condition::Predicate { name, args, span }) + } + + fn parse_arg_list(&mut self) -> DslResult> { + let mut args = Vec::new(); + while !matches!(self.peek_kind(), TokenKind::RParen | TokenKind::Eof) { + args.push(self.parse_arg()?); + if !self.eat(&TokenKind::Comma) { + break; + } + } + Ok(args) + } + + fn parse_arg(&mut self) -> DslResult { + let tok = self.advance(); + match &tok.kind { + TokenKind::StringLit(s) => Ok(Arg::String(s.clone())), + TokenKind::IntLit(n) => Ok(Arg::Int(*n)), + TokenKind::FloatLit(f) => Ok(Arg::Float(*f)), + TokenKind::BoolLit(b) => Ok(Arg::Bool(*b)), + TokenKind::Ident(s) => Ok(Arg::Ident(s.clone())), + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: "argument value (string, number, bool, or identifier)".into(), + span: tok.span.clone(), + }), + } + } + + // ----------------------------------------------------------------------- + // Utility helpers + // ----------------------------------------------------------------------- + + fn expect_ident(&mut self, context: &str) -> DslResult { + let tok = self.advance(); + match &tok.kind { + TokenKind::Ident(s) => Ok(s.clone()), + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: format!("{} (identifier)", context), + span: tok.span.clone(), + }), + } + } + + fn expect_string(&mut self, context: &str) -> DslResult { + let tok = self.advance(); + match &tok.kind { + TokenKind::StringLit(s) => Ok(s.clone()), + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: format!("{} (string literal)", context), + span: tok.span.clone(), + }), + } + } + + fn expect_ident_or_string(&mut self, context: &str) -> DslResult { + let tok = self.advance(); + match &tok.kind { + TokenKind::Ident(s) | TokenKind::StringLit(s) => Ok(s.clone()), + other => Err(DslError::UnexpectedToken { + found: other.to_string(), + expected: format!("{} (identifier or string)", context), + span: tok.span.clone(), + }), + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsl::lexer::Lexer; + + fn parse(src: &str) -> DslFile { + let tokens = Lexer::new(src).tokenize().expect("lex failed"); + Parser::new(tokens).parse().expect("parse failed") + } + + #[test] + fn test_minimal_rule() { + let src = r#" + rule no-unbounded-loop { + name: "No Unbounded Loop" + description: "Detects loops without a fixed bound" + severity: warning + language: rust + when { + contains_pattern("loop") + } + message: "Unbounded loop detected" + } + "#; + let file = parse(src); + assert_eq!(file.rules.len(), 1); + let rule = &file.rules[0]; + assert_eq!(rule.id, "no-unbounded-loop"); + assert_eq!(rule.name, "No Unbounded Loop"); + assert_eq!(rule.severity, DslSeverity::Warning); + assert_eq!(rule.language, DslLanguage::Rust); + assert!(rule.suggestion.is_none()); + } + + #[test] + fn test_rule_with_and_condition() { + let src = r#" + rule complex-rule { + name: "Complex" + description: "A complex rule" + severity: error + when { + contains_pattern("unsafe") and not contains_pattern("safe_wrapper") + } + message: "Unsafe usage without wrapper" + } + "#; + let file = parse(src); + assert_eq!(file.rules.len(), 1); + let cond = &file.rules[0].condition; + assert!(matches!(cond, Condition::And(_, _))); + } + + #[test] + fn test_rule_with_tags_and_suggestion() { + let src = r#" + rule tagged-rule { + name: "Tagged" + description: "Has tags" + severity: info + tags: [gas, optimization] + when { + contains_pattern("expensive_op") + } + message: "Expensive operation found" + suggestion: "Use a cheaper alternative" + } + "#; + let file = parse(src); + let rule = &file.rules[0]; + assert_eq!(rule.tags, vec!["gas", "optimization"]); + assert_eq!(rule.suggestion.as_deref(), Some("Use a cheaper alternative")); + } + + #[test] + fn test_multiple_rules() { + let src = r#" + rule rule-a { + name: "A" description: "desc a" severity: info + when { contains_pattern("a") } + message: "msg a" + } + rule rule-b { + name: "B" description: "desc b" severity: warning + when { contains_pattern("b") } + message: "msg b" + } + "#; + let file = parse(src); + assert_eq!(file.rules.len(), 2); + } + + #[test] + fn test_or_condition() { + let src = r#" + rule or-rule { + name: "Or" description: "d" severity: info + when { contains_pattern("a") or contains_pattern("b") } + message: "m" + } + "#; + let file = parse(src); + assert!(matches!(file.rules[0].condition, Condition::Or(_, _))); + } + + #[test] + fn test_not_condition() { + let src = r#" + rule not-rule { + name: "Not" description: "d" severity: info + when { not contains_pattern("safe") } + message: "m" + } + "#; + let file = parse(src); + assert!(matches!(file.rules[0].condition, Condition::Not(_))); + } + + #[test] + fn test_missing_required_field_error() { + let src = r#" + rule bad-rule { + name: "Bad" + severity: info + when { contains_pattern("x") } + message: "m" + } + "#; + let tokens = Lexer::new(src).tokenize().unwrap(); + let result = Parser::new(tokens).parse(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("description"), "expected missing-field error for 'description', got: {}", err); + } +} diff --git a/libs/analysis-core/src/dsl/verification_test.rs b/libs/analysis-core/src/dsl/verification_test.rs new file mode 100644 index 0000000..ae63a21 --- /dev/null +++ b/libs/analysis-core/src/dsl/verification_test.rs @@ -0,0 +1,136 @@ +// Verification test to demonstrate DSL is usable for rule creation +// This test demonstrates the complete workflow: DSL source -> compilation -> rule execution + +#[cfg(test)] +mod verification_tests { + use crate::dsl::compile_str; + + #[test] + fn verify_dsl_creates_executable_rules() { + // Define a simple DSL rule + let dsl_source = r#" + rule no-unsafe { + name: "No Unsafe Blocks" + description: "Flags unsafe blocks in Rust code" + severity: error + language: rust + when { + contains_pattern("unsafe") + } + message: "Unsafe block detected at line {line}: {snippet}" + suggestion: "Wrap in a safe abstraction" + } + "#; + + // Compile DSL into executable rules + let rules = compile_str(dsl_source).expect("DSL compilation failed"); + + // Verify rule was created + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].meta().id, "no-unsafe"); + assert_eq!(rules[0].meta().name, "No Unsafe Blocks"); + + // Test rule fires on matching source + let findings = rules[0].analyze("test.rs", "fn main() { unsafe { } }"); + assert!(!findings.is_empty(), "Rule should detect unsafe code"); + assert_eq!(findings[0].rule_id, "no-unsafe"); + + // Test rule is silent on clean source + let clean_findings = rules[0].analyze("clean.rs", "fn main() { println!(\"hello\"); }"); + assert!(clean_findings.is_empty(), "Rule should not flag clean code"); + } + + #[test] + fn verify_complex_conditions() { + let dsl_source = r#" + rule complex-condition { + name: "Complex Condition" + description: "Tests AND/OR/NOT logic" + severity: warning + language: rust + when { + (contains_pattern("unsafe") or contains_pattern("panic")) + and not contains_pattern("safe_wrapper") + } + message: "Complex condition matched at line {line}" + } + "#; + + let rules = compile_str(dsl_source).expect("DSL compilation failed"); + assert_eq!(rules.len(), 1); + + // Should fire: has unsafe, no safe_wrapper + let findings = rules[0].analyze("test.rs", "unsafe { }"); + assert!(!findings.is_empty()); + + // Should not fire: has unsafe, but also has safe_wrapper + let findings2 = rules[0].analyze("test.rs", "unsafe { } // safe_wrapper"); + assert!(findings2.is_empty()); + } + + #[test] + fn verify_multiple_rules_in_single_file() { + let dsl_source = r#" + rule rule-a { + name: "Rule A" description: "First rule" severity: info + when { contains_pattern("TODO") } + message: "TODO found at line {line}" + } + rule rule-b { + name: "Rule B" description: "Second rule" severity: warning + when { contains_pattern("FIXME") } + message: "FIXME found at line {line}" + } + "#; + + let rules = compile_str(dsl_source).expect("DSL compilation failed"); + assert_eq!(rules.len(), 2); + + // Test rule-a fires + let findings_a = rules[0].analyze("test.rs", "// TODO: fix this"); + assert!(!findings_a.is_empty()); + + // Test rule-b fires + let findings_b = rules[1].analyze("test.rs", "// FIXME: also this"); + assert!(!findings_b.is_empty()); + } + + #[test] + fn verify_builtin_predicates_are_recognized() { + // Test that all documented predicates are recognized (not unknown) + let predicates = vec![ + ("contains_pattern", "(\"test\")"), + ("matches_regex", "(\"test\")"), + ("line_count_exceeds", "(100)"), + ("function_count_exceeds", "(10)"), + ("has_keyword", "(\"test\")"), + ("lacks_keyword", "(\"test\")"), + ("identifier_matches", "(\"test\")"), + ("comment_ratio_below", "(0.1)"), + ("nesting_depth_exceeds", "(5)"), + ("always", "()"), + ("never", "()"), + ]; + + for (pred, args) in predicates { + let dsl_source = format!( + r#" + rule test-{0} {{ + name: "Test {0}" description: "Test" severity: info + when {{ {0}{1} }} + message: "Test" + }} + "#, + pred, args + ); + + let result = compile_str(&dsl_source); + // We expect either success or a type error, but NOT "UnknownPredicate" + if let Err(e) = result { + let error_str = e.to_string(); + assert!(!error_str.contains("UnknownPredicate"), + "Predicate {} should be recognized, got error: {}", pred, error_str); + } + } + } +} diff --git a/libs/analysis-core/src/libs.rs b/libs/analysis-core/src/lib.rs similarity index 75% rename from libs/analysis-core/src/libs.rs rename to libs/analysis-core/src/lib.rs index 6814d20..c53c6ae 100644 --- a/libs/analysis-core/src/libs.rs +++ b/libs/analysis-core/src/lib.rs @@ -1,4 +1,5 @@ -// Re-export the two major subsystems. +// Re-export the three major subsystems. +pub mod dsl; pub mod gas; pub mod plugin; @@ -7,4 +8,5 @@ pub use gas::{GasReport, PatternGasCost}; pub use plugin::{ AnalysisInput, AnalysisOutput, BaseRule, Finding, Language, PluginRegistry, RuleConfig, RuleMeta, SessionOutput, Severity, -}; \ No newline at end of file +}; +pub use dsl::compile_str; \ No newline at end of file diff --git a/libs/analysis-core/src/plugin/registry.rs b/libs/analysis-core/src/plugin/registry.rs index 732d1e2..6b20f89 100644 --- a/libs/analysis-core/src/plugin/registry.rs +++ b/libs/analysis-core/src/plugin/registry.rs @@ -5,9 +5,10 @@ use super::io::{AnalysisInput, AnalysisOutput, SessionOutput}; /// Central store for all registered [`BaseRule`] implementations. /// /// Usage: -/// ```rust +/// ```rust,ignore +/// use analysis_core::plugin::{PluginRegistry, RuleConfig}; /// let mut registry = PluginRegistry::new(); -/// registry.register(Box::new(MyRule::default()))?; +/// registry.register(Box::new(MyRule::default()), &RuleConfig::default()).unwrap(); /// let session = registry.run_session(&inputs); /// ``` pub struct PluginRegistry { diff --git a/libs/engine/src/analyzer.rs b/libs/engine/src/analyzer.rs index 9560344..9863748 100644 --- a/libs/engine/src/analyzer.rs +++ b/libs/engine/src/analyzer.rs @@ -1,5 +1,5 @@ use colored::*; -use gasguard_rules::{RuleViolation, ViolationSeverity}; +use gasguard_rule_engine::{RuleViolation, ViolationSeverity}; use std::fmt; pub struct ScanAnalyzer; @@ -91,11 +91,11 @@ impl ScanAnalyzer { for violation in violations { match violation.severity { - ViolationSeverity::Critical | ViolationSeverity::High | ViolationSeverity::Error => { + ViolationSeverity::Error | ViolationSeverity::High => { errors.push(violation) } ViolationSeverity::Medium | ViolationSeverity::Warning => warnings.push(violation), - ViolationSeverity::Low | ViolationSeverity::Info => info.push(violation), + ViolationSeverity::Info => info.push(violation), } } diff --git a/libs/engine/src/incremental_scanner.rs b/libs/engine/src/incremental_scanner.rs index 0dd4967..4c5d010 100644 --- a/libs/engine/src/incremental_scanner.rs +++ b/libs/engine/src/incremental_scanner.rs @@ -1,16 +1,11 @@ use anyhow::{Context, Result}; -use gasguard_ast::{UnifiedAST, Language as AstLanguage}; -use gasguard_rule_engine::{RuleEngine, RuleViolation}; -use gasguard_parser_rust::RustParser; -use gasguard_parser_solidity::SolidityParser; -use gasguard_parser_vyper::VyperParser; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use walkdir::WalkDir; -use crate::{ScanResult, ContractScanner, Language}; +use crate::ScanResult; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileHashInfo { @@ -54,15 +49,12 @@ pub struct IncrementalAnalysisResult { } pub struct IncrementalScanner { - rule_engine: RuleEngine, cache_dir: PathBuf, } impl IncrementalScanner { pub fn new>(cache_dir: P) -> Self { - let rule_engine = RuleEngine::new(); Self { - rule_engine, cache_dir: cache_dir.as_ref().to_path_buf(), } } @@ -70,7 +62,6 @@ impl IncrementalScanner { /// Generate content-based hash for a file pub async fn generate_file_hash(&self, file_path: &Path) -> Result { use std::fs; - use std::time::SystemTime; let content = fs::read_to_string(file_path) .with_context(|| format!("Failed to read file: {:?}", file_path))?; diff --git a/libs/engine/src/scanner.rs b/libs/engine/src/scanner.rs index ce1b75a..4eb67cd 100644 --- a/libs/engine/src/scanner.rs +++ b/libs/engine/src/scanner.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result}; -use gasguard_ast::{UnifiedAST, Language as AstLanguage}; use gasguard_rule_engine::{RuleEngine, RuleViolation}; use gasguard_parser_rust::RustParser; use gasguard_parser_solidity::SolidityParser; @@ -59,11 +58,11 @@ impl ContractScanner { language: Language, ) -> Result { let ast = match language { - AstLanguage::Rust | AstLanguage::Soroban => RustParser::parse(content, &source) + Language::Rust | Language::Soroban => RustParser::parse(content, &source) .map_err(|e| anyhow::anyhow!("Rust parse error: {}", e))?, - AstLanguage::Solidity => SolidityParser::parse(content, &source) + Language::Solidity => SolidityParser::parse(content, &source) .map_err(|e| anyhow::anyhow!("Solidity parse error: {}", e))?, - AstLanguage::Vyper => VyperParser::parse(content, &source) + Language::Vyper => VyperParser::parse(content, &source) .map_err(|e| anyhow::anyhow!("Vyper parse error: {}", e))?, }; @@ -106,7 +105,7 @@ impl Default for ContractScanner { } } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ScanResult { pub source: String, pub violations: Vec, @@ -118,3 +117,20 @@ impl ScanResult { serde_json::to_string_pretty(self) } } + +impl ContractScanner { + /// Convenience alias used by TieredScanner — scans content, auto-detecting language from source path extension. + pub fn scan_content(&self, content: &str, source: String) -> Result { + let extension = std::path::Path::new(&source) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let language = match extension { + "rs" => Language::Rust, + "sol" => Language::Solidity, + "vy" => Language::Vyper, + _ => Language::Rust, // default fallback + }; + self.scan_content_with_language(content, source, language) + } +} diff --git a/libs/engine/src/tiered_scanner.rs b/libs/engine/src/tiered_scanner.rs index d9ef611..1385ff7 100644 --- a/libs/engine/src/tiered_scanner.rs +++ b/libs/engine/src/tiered_scanner.rs @@ -2,7 +2,7 @@ use crate::scanner::{ScanResult, ContractScanner}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum UsageTier { Starter, Developer, diff --git a/libs/plugin-system/src/lib.rs b/libs/plugin-system/src/lib.rs index 1517300..d09cdfc 100644 --- a/libs/plugin-system/src/lib.rs +++ b/libs/plugin-system/src/lib.rs @@ -15,7 +15,7 @@ impl PluginLoader { /// that returns a pointer to a Box. /// In a production system, you'd want to use a stable ABI or WASM. pub unsafe fn load_rule>(&self, path: P) -> Result, String> { - let lib = Library::new(path).map_err(|e| e.to_string())?; + let lib = Library::new(path.as_ref().as_os_str()).map_err(|e| e.to_string())?; // We leak the library to keep it loaded, as the Rule might use code from it let lib = Box::leak(Box::new(lib)); diff --git a/packages/rules/src/soroban/analyzer.rs b/packages/rules/src/soroban/analyzer.rs index 1c1f6a0..15027ca 100644 --- a/packages/rules/src/soroban/analyzer.rs +++ b/packages/rules/src/soroban/analyzer.rs @@ -473,7 +473,7 @@ impl GoodContract { // Well-optimized contract should have minimal violations // Most should be informational rather than critical let critical_violations: Vec<_> = violations.iter() - .filter(|v| matches!(v.severity, ViolationSeverity::High | ViolationSeverity::Error)) + .filter(|v| matches!(v.severity, ViolationSeverity::High | ViolationSeverity::Critical)) .collect(); assert!(critical_violations.is_empty() || critical_violations.len() <= 1); diff --git a/packages/rules/src/soroban/rule_engine.rs b/packages/rules/src/soroban/rule_engine.rs index d6b567c..e119714 100644 --- a/packages/rules/src/soroban/rule_engine.rs +++ b/packages/rules/src/soroban/rule_engine.rs @@ -731,7 +731,15 @@ impl SorobanRule for GovernanceVotingRule { let source = &function.raw_definition; // Check for authorization: require_auth() or authorize() - if !source.contains("require_auth") && !source.contains("authorize") { + // Strip comment lines before checking to avoid false negatives + let non_comment_source: String = source.lines() + .filter(|l| { + let t = l.trim(); + !t.starts_with("//") && !t.starts_with("/*") && !t.starts_with("*") + }) + .collect::>() + .join("\n"); + if !non_comment_source.contains("require_auth") && !non_comment_source.contains("authorize") { violations.push(RuleViolation { rule_name: self.id().to_string(), description: format!("Governance function '{}' lacks explicit authorization check", function.name), @@ -894,8 +902,16 @@ impl SorobanRule for ClaimExpirationRule { if func_name.contains("claim") || func_name.contains("settle") || func_name.contains("redeem") { let source = &function.raw_definition; - - if !source.contains("timestamp") && !source.contains("expiration") && !source.contains("expiry") { + // Strip comment lines to avoid false negatives from comments mentioning keywords + let non_comment_source: String = source.lines() + .filter(|l| { + let t = l.trim(); + !t.starts_with("//") && !t.starts_with("/*") && !t.starts_with("*") + }) + .collect::>() + .join("\n"); + + if !non_comment_source.contains("timestamp") && !non_comment_source.contains("expiration") && !non_comment_source.contains("expiry") { violations.push(RuleViolation { rule_name: self.id().to_string(), description: format!("Claim function '{}' may be missing expiration logic", function.name), @@ -910,6 +926,7 @@ impl SorobanRule for ClaimExpirationRule { } } + eprintln!("DEBUG apply returning {} violations", violations.len()); violations } } @@ -1147,6 +1164,16 @@ impl MyContract { let rule = ClaimExpirationRule::default(); let contract = SorobanParser::parse_contract(source, "test.rs").unwrap(); let violations = rule.apply(&contract); + + // Debug: print what was parsed + eprintln!("Parsed implementations: {}", contract.implementations.len()); + for imp in &contract.implementations { + eprintln!(" impl {}: {} functions", imp.target, imp.functions.len()); + for f in &imp.functions { + eprintln!(" fn {} (line {})", f.name, f.line_number); + } + } + eprintln!("Violations: {:?}", violations.iter().map(|v| &v.variable_name).collect::>()); // Should find one violation for claim_reward assert!(violations.iter().any(|v| v.variable_name == "claim_reward"));