ryo-suggest 0.1.0

[experimental] Pattern-based suggestion engine for RYO
Documentation
//! OrphanSpec - Detects unused Spec TypeAliases
//!
//! This rule checks for Spec TypeAliases that are defined but never used.

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, SuggestError,
    SuggestLocation, SuggestOpportunity, SuggestResult,
};

/// OrphanSpec rule
///
/// Detects Spec TypeAliases that are defined but never referenced in the codebase.
///
/// # Rule Code
/// RS002 (Ryo Spec)
///
/// # Detection
/// 1. Find TypeAliases matching `*Spec` pattern
/// 2. Check if the alias is referenced anywhere in the codebase
/// 3. Report if not referenced
///
/// # Example Violation
/// ```ignore
/// // src/domain/task.rs
/// pub type TaskSpec = Spec<DomainGroup, Task>;
/// // WARNING: TaskSpec is never used
/// ```
///
/// # Fix
/// Generates `RemoveSpec` MutationSpec to remove the unused TypeAlias.
pub struct OrphanSpec {
    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
    spec_suffix: String,
}

impl OrphanSpec {
    pub fn new() -> Self {
        Self {
            spec_suffix: "Spec".to_string(),
        }
    }

    /// Create with custom suffix pattern
    pub fn with_suffix(suffix: impl Into<String>) -> Self {
        Self {
            spec_suffix: suffix.into(),
        }
    }

    /// Check if a symbol is referenced anywhere in the codebase
    fn is_referenced(&self, ctx: &AnalysisContext, target_id: SymbolId, target_name: &str) -> bool {
        // Check reference count via code graph (calls)
        let graph = ctx.code_graph();
        if graph.reference_count(target_id) > 0 {
            return true;
        }

        // Check type_users (symbols that use this type, via TypeFlow)
        let typeflow = ctx.typeflow_graph();
        if typeflow.type_users(target_id).next().is_some() {
            return true;
        }

        // Fallback: search for the name in other symbols' paths
        // This is a heuristic - checks if the name appears in any other symbol
        for (id, path) in ctx.registry.iter() {
            if id == target_id {
                continue;
            }

            let path_str = path.to_string();
            // Check if this symbol's path contains the target name
            // (e.g., a function parameter type, a field type, etc.)
            if path_str.contains(target_name) {
                return true;
            }
        }

        false
    }
}

impl SpecSuggest for OrphanSpec {
    fn spec_suffix(&self) -> &str {
        &self.spec_suffix
    }
}

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

impl Suggest for OrphanSpec {
    fn name(&self) -> &'static str {
        "orphan-spec"
    }

    fn description(&self) -> &str {
        "Detects Spec TypeAliases that are defined but never used"
    }

    fn category(&self) -> SuggestCategory {
        SuggestCategory::Refactor
    }

    fn safety_level(&self) -> SafetyLevel {
        SafetyLevel::Confirm // Needs confirmation before removal
    }

    fn priority_weight(&self) -> f32 {
        1.0 // Normal priority
    }

    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
        let mut opportunities = Vec::new();
        let mut next_id = 0u32;

        // If specific symbols provided, check only those
        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
            ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
        } else {
            symbols.to_vec()
        };

        for symbol_id in symbols_to_check {
            let path = match ctx.registry.path(symbol_id) {
                Some(p) => p,
                None => continue,
            };

            let alias_name = path.name();

            // Check if this looks like a Spec TypeAlias
            if !self.is_spec_alias(alias_name) {
                continue;
            }

            // Check if it's referenced
            if self.is_referenced(ctx, symbol_id, alias_name) {
                continue;
            }

            // Get location
            let Some(location) = SuggestLocation::from_context(ctx, symbol_id) else {
                continue;
            };

            let opp = self.create_lint_opportunity(
                OpportunityId::new(next_id),
                vec![symbol_id],
                location,
                format!("Spec TypeAlias `{}` is never used", alias_name),
                LintDetails {
                    suggestion: Some(format!("Remove unused `type {} = ...;`", alias_name)),
                    expected: Some("Referenced somewhere".to_string()),
                    actual: Some("No references found".to_string()),
                },
            );

            opportunities.push(opp);
            next_id += 1;
        }

        opportunities
    }

    fn to_mutation_specs(
        &self,
        ctx: &AnalysisContext,
        opportunity: &SuggestOpportunity,
    ) -> SuggestResult<Vec<MutationSpec>> {
        // Get the target symbol
        let symbol_id = match opportunity.targets.first() {
            Some(id) => *id,
            None => return Ok(Vec::new()),
        };

        let path = match ctx.registry.path(symbol_id) {
            Some(p) => p,
            None => return Ok(Vec::new()),
        };

        let _alias_name = path.name().to_string();
        let module_path =
            self.get_module_path(path)
                .ok_or_else(|| SuggestError::ModulePathResolution {
                    path: path.to_string(),
                })?;

        // Resolve module_id from module_path
        let module_id = match ctx.registry.lookup(&module_path) {
            Some(id) => id,
            None => return Ok(Vec::new()),
        };

        Ok(vec![MutationSpec::RemoveSpec {
            type_id: symbol_id,
            module_id,
        }])
    }
}

impl LintSuggest for OrphanSpec {
    fn code(&self) -> &'static str {
        "RS002"
    }

    fn default_severity(&self) -> LintSeverity {
        LintSeverity::Warning
    }
}

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

    #[test]
    fn test_is_spec_alias() {
        let rule = OrphanSpec::new();

        assert!(rule.is_spec_alias("TaskSpec"));
        assert!(rule.is_spec_alias("UserSpec"));
        assert!(rule.is_spec_alias("ConfigSpec"));

        // Too short or doesn't end with Spec
        assert!(!rule.is_spec_alias("Spec")); // Just "Spec"
        assert!(!rule.is_spec_alias("Task"));
        assert!(!rule.is_spec_alias("SpecTask"));
    }

    #[test]
    fn test_custom_suffix() {
        let rule = OrphanSpec::with_suffix("Domain");

        assert!(rule.is_spec_alias("TaskDomain"));
        assert!(rule.is_spec_alias("UserDomain"));
        assert!(!rule.is_spec_alias("TaskSpec"));
    }
}