use std::collections::{HashMap, HashSet};
use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind};
use super::SpecSuggest;
use crate::lint::{LintDetails, LintSuggest};
use crate::{
LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
SuggestLocation, SuggestOpportunity, SuggestResult,
};
pub struct SpecRelationCycle {
spec_suffix: String,
}
impl SpecRelationCycle {
pub fn new() -> Self {
Self {
spec_suffix: "Spec".to_string(),
}
}
pub fn with_suffix(suffix: impl Into<String>) -> Self {
Self {
spec_suffix: suffix.into(),
}
}
fn build_dependency_graph(
&self,
ctx: &AnalysisContext,
symbols: &[SymbolId],
) -> HashMap<String, (SymbolId, Vec<String>)> {
let mut graph: HashMap<String, (SymbolId, Vec<String>)> = HashMap::new();
for &symbol_id in symbols {
let path = match ctx.registry.path(symbol_id) {
Some(p) => p,
None => continue,
};
let alias_name = path.name();
if !self.is_spec_alias(alias_name) {
continue;
}
let base_type = match self.extract_base_type(alias_name) {
Some(bt) => bt.to_string(),
None => continue,
};
let typeflow = ctx.typeflow_graph();
let mut deps = Vec::new();
for used_id in typeflow.types_used_by(symbol_id) {
if let Some(used_path) = ctx.registry.path(used_id) {
let kind = ctx.registry.kind(used_id);
if matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
let dep_name = used_path.name().to_string();
if dep_name != base_type {
deps.push(dep_name);
}
}
}
}
graph.insert(base_type, (symbol_id, deps));
}
graph
}
fn detect_cycles(&self, graph: &HashMap<String, (SymbolId, Vec<String>)>) -> Vec<Vec<String>> {
let mut cycles = Vec::new();
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut path = Vec::new();
for start_node in graph.keys() {
if !visited.contains(start_node) {
self.dfs_find_cycles(
graph,
start_node,
&mut visited,
&mut rec_stack,
&mut path,
&mut cycles,
);
}
}
cycles
}
fn dfs_find_cycles(
&self,
graph: &HashMap<String, (SymbolId, Vec<String>)>,
node: &str,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some((_, deps)) = graph.get(node) {
for dep in deps {
if !graph.contains_key(dep) {
continue;
}
if !visited.contains(dep) {
self.dfs_find_cycles(graph, dep, visited, rec_stack, path, cycles);
} else if rec_stack.contains(dep) {
if let Some(cycle_start) = path.iter().position(|n| n == dep) {
let cycle: Vec<String> = path[cycle_start..].to_vec();
let normalized = self.normalize_cycle(&cycle);
if !cycles.iter().any(|c| self.normalize_cycle(c) == normalized) {
cycles.push(cycle);
}
}
}
}
}
path.pop();
rec_stack.remove(node);
}
fn normalize_cycle(&self, cycle: &[String]) -> Vec<String> {
if cycle.is_empty() {
return Vec::new();
}
let min_idx = cycle
.iter()
.enumerate()
.min_by_key(|(_, s)| s.as_str())
.map(|(i, _)| i)
.unwrap_or(0);
let mut normalized = Vec::with_capacity(cycle.len());
for i in 0..cycle.len() {
normalized.push(cycle[(min_idx + i) % cycle.len()].clone());
}
normalized
}
}
impl SpecSuggest for SpecRelationCycle {
fn spec_suffix(&self) -> &str {
&self.spec_suffix
}
}
impl Default for SpecRelationCycle {
fn default() -> Self {
Self::new()
}
}
impl Suggest for SpecRelationCycle {
fn name(&self) -> &'static str {
"spec-relation-cycle"
}
fn description(&self) -> &str {
"Detects circular dependencies in SpecRelation definitions"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Lint
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Manual }
fn priority_weight(&self) -> f32 {
2.5 }
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
let mut opportunities = Vec::new();
let mut next_id = 0u32;
let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
} else {
symbols.to_vec()
};
let graph = self.build_dependency_graph(ctx, &symbols_to_check);
let cycles = self.detect_cycles(&graph);
for cycle in cycles {
if cycle.is_empty() {
continue;
}
let first_type = &cycle[0];
let (symbol_id, _) = match graph.get(first_type) {
Some(data) => data,
None => continue,
};
let Some(location) = SuggestLocation::from_context(ctx, *symbol_id) else {
continue;
};
let cycle_symbols: Vec<SymbolId> = cycle
.iter()
.filter_map(|t| graph.get(t).map(|(id, _)| *id))
.collect();
let cycle_str = cycle.join(" -> ") + " -> " + &cycle[0];
let opp = self.create_lint_opportunity(
OpportunityId::new(next_id),
cycle_symbols,
location,
format!("Circular dependency detected: {}", cycle_str),
LintDetails {
suggestion: Some(format!(
"Break the cycle by removing one dependency in: {}",
cycle_str
)),
expected: Some("No cycles in Spec dependencies".to_string()),
actual: Some(format!("Cycle found: {}", cycle_str)),
},
);
opportunities.push(opp);
next_id += 1;
}
opportunities
}
fn to_mutation_specs(
&self,
_ctx: &AnalysisContext,
_opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
Ok(Vec::new())
}
}
impl LintSuggest for SpecRelationCycle {
fn code(&self) -> &'static str {
"RS005"
}
fn default_severity(&self) -> LintSeverity {
LintSeverity::Error }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_spec_alias() {
let rule = SpecRelationCycle::new();
assert!(rule.is_spec_alias("TaskSpec"));
assert!(rule.is_spec_alias("UserSpec"));
assert!(!rule.is_spec_alias("Spec"));
assert!(!rule.is_spec_alias("Task"));
}
#[test]
fn test_extract_base_type() {
let rule = SpecRelationCycle::new();
assert_eq!(rule.extract_base_type("TaskSpec"), Some("Task"));
assert_eq!(rule.extract_base_type("UserSpec"), Some("User"));
assert_eq!(rule.extract_base_type("Spec"), None);
}
#[test]
fn test_normalize_cycle() {
let rule = SpecRelationCycle::new();
let cycle = vec!["C".to_string(), "A".to_string(), "B".to_string()];
let normalized = rule.normalize_cycle(&cycle);
assert_eq!(normalized, vec!["A", "B", "C"]);
let cycle2 = vec!["B".to_string(), "C".to_string(), "A".to_string()];
let normalized2 = rule.normalize_cycle(&cycle2);
assert_eq!(normalized2, vec!["A", "B", "C"]);
}
}