use iec61131::analysis::CfgBuilder;
use crate::analysis::ProjectAnalysis;
use crate::config::ComplexityConfig;
use crate::report::{Report, Rule, RuleKind, Severity};
pub struct ComplexityDetector<'a> {
config: &'a ComplexityConfig,
}
impl<'a> ComplexityDetector<'a> {
pub fn new(config: &'a ComplexityConfig) -> Self {
Self { config }
}
pub fn detect(&self, analysis: &ProjectAnalysis, report: &mut Report) {
if !self.config.enabled {
return;
}
for st_routine in &analysis.st_routines {
if self.matches_ignore_pattern(&st_routine.location.routine) {
continue;
}
let Some(ref pou) = st_routine.pou else {
continue;
};
let cfg = CfgBuilder::new().build(&pou.body);
let complexity = cfg.cyclomatic_complexity();
if complexity > self.config.max_complexity {
report.add(Rule::new(
RuleKind::CyclomaticComplexity,
self.severity_for_complexity(complexity),
format!("Program:{}", st_routine.location.program),
st_routine.location.routine.clone(),
format!(
"Routine '{}' has cyclomatic complexity of {} (max: {})",
st_routine.location.routine,
complexity,
self.config.max_complexity
),
));
}
}
}
fn severity_for_complexity(&self, complexity: usize) -> Severity {
let threshold = self.config.max_complexity;
if complexity > threshold * 2 {
Severity::Error
} else if complexity > threshold + threshold / 2 {
Severity::Warning
} else {
Severity::Info
}
}
fn matches_ignore_pattern(&self, routine_name: &str) -> bool {
for pattern in &self.config.ignore_patterns {
if glob_match(pattern, routine_name) {
return true;
}
}
false
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let pattern_chars: Vec<char> = pattern.chars().collect();
let text_chars: Vec<char> = text.chars().collect();
glob_match_recursive(&pattern_chars, &text_chars, 0, 0)
}
fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {
if pi == pattern.len() {
return ti == text.len();
}
match pattern[pi] {
'*' => {
for i in ti..=text.len() {
if glob_match_recursive(pattern, text, pi + 1, i) {
return true;
}
}
false
}
'?' => {
if ti < text.len() {
glob_match_recursive(pattern, text, pi + 1, ti + 1)
} else {
false
}
}
c => {
if ti < text.len() && c.eq_ignore_ascii_case(&text[ti]) {
glob_match_recursive(pattern, text, pi + 1, ti + 1)
} else {
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::{ParsedSTRoutine, STLocation, ParseStats, RoutineSummary};
use std::collections::HashMap;
fn create_test_analysis(st_source: &str) -> ProjectAnalysis {
let wrapped = format!("PROGRAM Test\nVAR\nEND_VAR\n{}\nEND_PROGRAM", st_source);
let pou = crate::analysis::parse_pou(&wrapped).ok();
let st_routine = ParsedSTRoutine {
location: STLocation::new("MainProgram", "TestRoutine"),
source: st_source.to_string(),
pou,
parse_error: None,
};
ProjectAnalysis {
rungs: vec![],
st_routines: vec![st_routine],
tag_references: vec![],
tag_xref: HashMap::new(),
routines: vec![RoutineSummary {
program: "MainProgram".to_string(),
routine: "TestRoutine".to_string(),
routine_type: "ST".to_string(),
rung_count: 0,
rung_indices: vec![],
parse_errors: 0,
tags_used: vec![],
instructions: HashMap::new(),
}],
instruction_usage: HashMap::new(),
aoi_definitions: vec![],
aoi_usage: HashMap::new(),
stats: ParseStats::default(),
}
}
#[test]
fn test_simple_routine_no_violation() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 10,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis("x := 1;");
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert!(report.rules.is_empty(), "Simple routine should not trigger complexity rule");
}
#[test]
fn test_complex_routine_violation() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 2, ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis(
r#"
IF a THEN
x := 1;
ELSIF b THEN
x := 2;
ELSIF c THEN
x := 3;
END_IF;
"#
);
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert_eq!(report.rules.len(), 1);
assert_eq!(report.rules[0].kind, RuleKind::CyclomaticComplexity);
assert!(report.rules[0].message.contains("cyclomatic complexity"));
}
#[test]
fn test_disabled_detector() {
let config = ComplexityConfig {
enabled: false,
max_complexity: 1,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis("IF a THEN x := 1; END_IF;");
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert!(report.rules.is_empty(), "Disabled detector should not report");
}
#[test]
fn test_ignore_pattern() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 1,
ignore_patterns: vec!["Test*".to_string()],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis("IF a THEN x := 1; END_IF;");
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert!(report.rules.is_empty(), "Ignored routine should not be reported");
}
#[test]
fn test_severity_escalation() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 10,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
assert_eq!(detector.severity_for_complexity(12), Severity::Info);
assert_eq!(detector.severity_for_complexity(18), Severity::Warning);
assert_eq!(detector.severity_for_complexity(25), Severity::Error);
}
#[test]
fn test_loop_adds_complexity() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 1,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis(
r#"
FOR i := 1 TO 10 DO
x := x + 1;
END_FOR;
"#
);
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert_eq!(report.rules.len(), 1);
assert_eq!(report.rules[0].kind, RuleKind::CyclomaticComplexity);
}
#[test]
fn test_while_adds_complexity() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 1,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis("WHILE a DO x := x + 1; END_WHILE;");
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert_eq!(report.rules.len(), 1);
}
#[test]
fn test_case_adds_complexity() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 1,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let analysis = create_test_analysis(
r#"
CASE x OF
1: y := 1;
2: y := 2;
3: y := 3;
END_CASE;
"#
);
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert_eq!(report.rules.len(), 1);
}
#[test]
fn test_unparseable_routine_skipped() {
let config = ComplexityConfig {
enabled: true,
max_complexity: 1,
ignore_patterns: vec![],
};
let detector = ComplexityDetector::new(&config);
let st_routine = ParsedSTRoutine {
location: STLocation::new("MainProgram", "BadRoutine"),
source: "this is not valid ST {{{{".to_string(),
pou: None,
parse_error: None,
};
let analysis = ProjectAnalysis {
rungs: vec![],
st_routines: vec![st_routine],
tag_references: vec![],
tag_xref: HashMap::new(),
routines: vec![],
instruction_usage: HashMap::new(),
aoi_definitions: vec![],
aoi_usage: HashMap::new(),
stats: ParseStats::default(),
};
let mut report = Report::new();
detector.detect(&analysis, &mut report);
assert!(report.rules.is_empty(), "Unparseable routine should not trigger rule");
}
}