use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
use super::SpecSuggest;
use crate::lint::{LintDetails, LintSuggest};
use crate::{
LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
SuggestLocation, SuggestOpportunity, SuggestResult,
};
pub struct MissingSpecForDomainType {
domain_patterns: Vec<String>,
default_group: String,
}
impl MissingSpecForDomainType {
pub fn new() -> Self {
Self {
domain_patterns: vec![
"domain".to_string(),
"model".to_string(),
"entity".to_string(),
"aggregate".to_string(),
],
default_group: "DomainGroup".to_string(),
}
}
pub fn with_patterns(patterns: Vec<String>) -> Self {
Self {
domain_patterns: patterns,
default_group: "DomainGroup".to_string(),
}
}
pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.default_group = group.into();
self
}
fn is_domain_module(&self, path: &SymbolPath) -> bool {
let path_str = path.to_string().to_lowercase();
self.domain_patterns
.iter()
.any(|pattern| path_str.contains(&pattern.to_lowercase()))
}
fn has_spec_alias(&self, ctx: &AnalysisContext, type_name: &str) -> bool {
let spec_name = format!("{}Spec", type_name);
for (id, path) in ctx.registry.iter() {
if let Some(SymbolKind::TypeAlias) = ctx.registry.kind(id) {
if path.name() == spec_name {
return true;
}
}
}
false
}
}
impl SpecSuggest for MissingSpecForDomainType {}
impl Default for MissingSpecForDomainType {
fn default() -> Self {
Self::new()
}
}
impl Suggest for MissingSpecForDomainType {
fn name(&self) -> &'static str {
"missing-spec-for-domain-type"
}
fn description(&self) -> &str {
"Detects domain model types that lack corresponding Spec TypeAliases"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Lint
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Confirm }
fn priority_weight(&self) -> f32 {
1.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::Struct)
.chain(ctx.registry.iter_by_kind(SymbolKind::Enum))
.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,
};
if !self.is_domain_module(path) {
continue;
}
let type_name = path.name();
if self.has_spec_alias(ctx, type_name) {
continue;
}
if type_name.starts_with('_')
|| type_name.chars().next().is_none_or(|c| c.is_lowercase())
{
continue;
}
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!("Domain type `{}` has no Spec TypeAlias", type_name),
LintDetails {
suggestion: Some(format!(
"Add `type {}Spec = Spec<{}, {}>;`",
type_name, self.default_group, type_name
)),
expected: Some(format!("type {}Spec = Spec<...>;", type_name)),
actual: Some("No Spec found".to_string()),
},
);
opportunities.push(opp);
next_id += 1;
}
opportunities
}
fn to_mutation_specs(
&self,
ctx: &AnalysisContext,
opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
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 type_name = path.name().to_string();
let module_path =
self.get_module_path(path)
.ok_or_else(|| SuggestError::ModulePathResolution {
path: path.to_string(),
})?;
let module_id = match ctx.registry.lookup(&module_path) {
Some(id) => id,
None => return Ok(Vec::new()),
};
Ok(vec![MutationSpec::AddSpec {
type_id: symbol_id,
module_id,
group: self.default_group.clone(),
alias_name: Some(format!("{}Spec", type_name)),
relations: Vec::new(), }])
}
}
impl LintSuggest for MissingSpecForDomainType {
fn code(&self) -> &'static str {
"RS001"
}
fn default_severity(&self) -> LintSeverity {
LintSeverity::Warning
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_domain_module() {
let rule = MissingSpecForDomainType::new();
let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
assert!(rule.is_domain_module(&domain_path));
let model_path = SymbolPath::parse("test_crate::model::user::User").unwrap();
assert!(rule.is_domain_module(&model_path));
let entity_path = SymbolPath::parse("test_crate::entity::order::Order").unwrap();
assert!(rule.is_domain_module(&entity_path));
let util_path = SymbolPath::parse("test_crate::util::helper::Helper").unwrap();
assert!(!rule.is_domain_module(&util_path));
}
#[test]
fn test_custom_patterns() {
let rule = MissingSpecForDomainType::with_patterns(vec!["core".to_string()]);
let core_path = SymbolPath::parse("test_crate::core::config::Config").unwrap();
assert!(rule.is_domain_module(&core_path));
let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
assert!(!rule.is_domain_module(&domain_path));
}
#[test]
fn test_with_group() {
let rule = MissingSpecForDomainType::new().with_group("MyGroup");
assert_eq!(rule.default_group, "MyGroup");
}
}