use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind};
use super::{is_framework_type, SpecSuggest};
use crate::{
MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestLocation,
SuggestOpportunity, SuggestResult,
};
use ryo_executor::Visibility;
pub struct SpecRelationToField {
suffix: String,
}
impl SpecRelationToField {
pub fn new() -> Self {
Self {
suffix: "Spec".to_string(),
}
}
pub fn with_suffix(suffix: impl Into<String>) -> Self {
Self {
suffix: suffix.into(),
}
}
fn find_struct_for_type(&self, ctx: &AnalysisContext, type_name: &str) -> Option<SymbolId> {
for symbol_id in ctx.registry.iter_by_kind(SymbolKind::Struct) {
if let Some(path) = ctx.registry.path(symbol_id) {
if path.name() == type_name {
return Some(symbol_id);
}
}
}
None
}
fn get_struct_fields(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
let graph = ctx.code_graph();
let mut fields = Vec::new();
for child_id in graph.children_of(struct_id) {
if let Some(SymbolKind::Field) = ctx.registry.kind(child_id) {
if let Some(path) = ctx.registry.path(child_id) {
fields.push(path.name().to_string());
}
}
}
fields
}
fn get_struct_field_types(&self, ctx: &AnalysisContext, struct_id: SymbolId) -> Vec<String> {
let typeflow = ctx.typeflow_graph();
let mut types = Vec::new();
for used_id in typeflow.types_used_by(struct_id) {
if let Some(path) = ctx.registry.path(used_id) {
let kind = ctx.registry.kind(used_id);
if matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
types.push(path.name().to_string());
}
}
}
types
}
fn extract_relation_targets(
&self,
ctx: &AnalysisContext,
spec_id: SymbolId,
base_type: &str,
) -> Vec<String> {
let typeflow = ctx.typeflow_graph();
let mut targets = Vec::new();
for used_id in typeflow.types_used_by(spec_id) {
if let Some(path) = ctx.registry.path(used_id) {
let kind = ctx.registry.kind(used_id);
if !matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
continue;
}
let used_name = path.name();
if is_framework_type(used_name) || used_name == base_type {
continue;
}
targets.push(used_name.to_string());
}
}
targets
}
fn has_field_for_relation(
&self,
ctx: &AnalysisContext,
struct_id: SymbolId,
target_type: &str,
) -> bool {
let fields = self.get_struct_fields(ctx, struct_id);
let field_types = self.get_struct_field_types(ctx, struct_id);
let target_lower = target_type.to_lowercase();
let has_field_name = fields
.iter()
.any(|f| f.to_lowercase().contains(&target_lower));
let has_field_type = field_types.iter().any(|t| {
t == target_type || t == &format!("{}Id", target_type) || t.contains(target_type)
});
has_field_name || has_field_type
}
fn suggest_field_name(&self, target_type: &str) -> String {
let mut result = String::new();
for (i, c) in target_type.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
}
format!("{}_id", result)
}
fn suggest_field_type(&self, target_type: &str) -> String {
format!("{}Id", target_type)
}
}
impl Default for SpecRelationToField {
fn default() -> Self {
Self::new()
}
}
const RULE_CODE: &str = "RS007";
impl SpecSuggest for SpecRelationToField {
fn spec_suffix(&self) -> &str {
&self.suffix
}
}
impl Suggest for SpecRelationToField {
fn name(&self) -> &'static str {
"spec-relation-to-field"
}
fn description(&self) -> &str {
"Suggests struct fields based on Spec relations"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Pattern
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Confirm }
fn priority_weight(&self) -> f32 {
1.0 }
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
use super::{create_spec_opportunity, SpecDetails};
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()
};
for spec_id in symbols_to_check {
let path = match ctx.registry.path(spec_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 struct_id = match self.find_struct_for_type(ctx, &base_type) {
Some(id) => id,
None => continue,
};
let relation_targets = self.extract_relation_targets(ctx, spec_id, &base_type);
for target_type in relation_targets {
if self.has_field_for_relation(ctx, struct_id, &target_type) {
continue;
}
let Some(location) = SuggestLocation::from_context(ctx, struct_id) else {
continue;
};
let suggested_field = self.suggest_field_name(&target_type);
let suggested_type = self.suggest_field_type(&target_type);
let opp = create_spec_opportunity(
RULE_CODE,
OpportunityId::new(next_id),
vec![struct_id, spec_id],
location,
format!(
"Struct `{}` has Spec relation to `{}` but no corresponding field",
base_type, target_type
),
0.8, SpecDetails {
alias_name: Some(alias_name.to_string()),
base_type: Some(base_type.clone()),
group: None,
related_types: vec![target_type.clone()],
suggestion: Some(format!(
"Add field `{}: {}`",
suggested_field, suggested_type
)),
},
);
opportunities.push(opp);
next_id += 1;
}
}
opportunities
}
fn to_mutation_specs(
&self,
ctx: &AnalysisContext,
opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
let struct_id = match opportunity.targets.first() {
Some(id) => *id,
None => return Ok(Vec::new()),
};
let struct_path = match ctx.registry.path(struct_id) {
Some(p) => p,
None => return Ok(Vec::new()),
};
let _struct_name = struct_path.name().to_string();
let related_type = match &opportunity.context {
crate::OpportunityContext::Spec { related_types, .. } => related_types.first().cloned(),
_ => None,
};
let target_type = match related_type {
Some(t) => t,
None => return Ok(Vec::new()),
};
let field_name = self.suggest_field_name(&target_type);
let field_type = self.suggest_field_type(&target_type);
Ok(vec![MutationSpec::AddField {
target: ryo_executor::MutationTargetSymbol::ById(struct_id),
field_name,
field_type,
visibility: Visibility::Pub,
}])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_suggest_field_name() {
let rule = SpecRelationToField::new();
assert_eq!(rule.suggest_field_name("Order"), "order_id");
assert_eq!(rule.suggest_field_name("User"), "user_id");
assert_eq!(rule.suggest_field_name("OrderItem"), "order_item_id");
}
#[test]
fn test_suggest_field_type() {
let rule = SpecRelationToField::new();
assert_eq!(rule.suggest_field_type("Order"), "OrderId");
assert_eq!(rule.suggest_field_type("User"), "UserId");
}
#[test]
fn test_is_spec_alias() {
let rule = SpecRelationToField::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"));
}
}