ryo_suggest/spec/
missing_spec.rs1use ryo_analysis::context::AnalysisContext;
6use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
7
8use super::SpecSuggest;
9use crate::lint::{LintDetails, LintSuggest};
10use crate::{
11 LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
12 SuggestLocation, SuggestOpportunity, SuggestResult,
13};
14
15pub struct MissingSpecForDomainType {
37 domain_patterns: Vec<String>,
39 default_group: String,
41}
42
43impl MissingSpecForDomainType {
44 pub fn new() -> Self {
45 Self {
46 domain_patterns: vec![
47 "domain".to_string(),
48 "model".to_string(),
49 "entity".to_string(),
50 "aggregate".to_string(),
51 ],
52 default_group: "DomainGroup".to_string(),
53 }
54 }
55
56 pub fn with_patterns(patterns: Vec<String>) -> Self {
58 Self {
59 domain_patterns: patterns,
60 default_group: "DomainGroup".to_string(),
61 }
62 }
63
64 pub fn with_group(mut self, group: impl Into<String>) -> Self {
66 self.default_group = group.into();
67 self
68 }
69
70 fn is_domain_module(&self, path: &SymbolPath) -> bool {
72 let path_str = path.to_string().to_lowercase();
73 self.domain_patterns
74 .iter()
75 .any(|pattern| path_str.contains(&pattern.to_lowercase()))
76 }
77
78 fn has_spec_alias(&self, ctx: &AnalysisContext, type_name: &str) -> bool {
80 let spec_name = format!("{}Spec", type_name);
81
82 for (id, path) in ctx.registry.iter() {
84 if let Some(SymbolKind::TypeAlias) = ctx.registry.kind(id) {
85 if path.name() == spec_name {
86 return true;
87 }
88 }
89 }
90
91 false
92 }
93}
94
95impl SpecSuggest for MissingSpecForDomainType {}
96
97impl Default for MissingSpecForDomainType {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl Suggest for MissingSpecForDomainType {
104 fn name(&self) -> &'static str {
105 "missing-spec-for-domain-type"
106 }
107
108 fn description(&self) -> &str {
109 "Detects domain model types that lack corresponding Spec TypeAliases"
110 }
111
112 fn category(&self) -> SuggestCategory {
113 SuggestCategory::Lint
114 }
115
116 fn safety_level(&self) -> SafetyLevel {
117 SafetyLevel::Confirm }
119
120 fn priority_weight(&self) -> f32 {
121 1.5 }
123
124 fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
125 let mut opportunities = Vec::new();
126 let mut next_id = 0u32;
127
128 let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
130 ctx.registry
132 .iter_by_kind(SymbolKind::Struct)
133 .chain(ctx.registry.iter_by_kind(SymbolKind::Enum))
134 .collect()
135 } else {
136 symbols.to_vec()
137 };
138
139 for symbol_id in symbols_to_check {
140 let path = match ctx.registry.path(symbol_id) {
141 Some(p) => p,
142 None => continue,
143 };
144
145 if !self.is_domain_module(path) {
147 continue;
148 }
149
150 let type_name = path.name();
151
152 if self.has_spec_alias(ctx, type_name) {
154 continue;
155 }
156
157 if type_name.starts_with('_')
159 || type_name.chars().next().is_none_or(|c| c.is_lowercase())
160 {
161 continue;
162 }
163
164 let Some(location) = SuggestLocation::from_context(ctx, symbol_id) else {
166 continue;
167 };
168
169 let opp = self.create_lint_opportunity(
170 OpportunityId::new(next_id),
171 vec![symbol_id],
172 location,
173 format!("Domain type `{}` has no Spec TypeAlias", type_name),
174 LintDetails {
175 suggestion: Some(format!(
176 "Add `type {}Spec = Spec<{}, {}>;`",
177 type_name, self.default_group, type_name
178 )),
179 expected: Some(format!("type {}Spec = Spec<...>;", type_name)),
180 actual: Some("No Spec found".to_string()),
181 },
182 );
183
184 opportunities.push(opp);
185 next_id += 1;
186 }
187
188 opportunities
189 }
190
191 fn to_mutation_specs(
192 &self,
193 ctx: &AnalysisContext,
194 opportunity: &SuggestOpportunity,
195 ) -> SuggestResult<Vec<MutationSpec>> {
196 let symbol_id = match opportunity.targets.first() {
198 Some(id) => *id,
199 None => return Ok(Vec::new()),
200 };
201
202 let path = match ctx.registry.path(symbol_id) {
203 Some(p) => p,
204 None => return Ok(Vec::new()),
205 };
206
207 let type_name = path.name().to_string();
208 let module_path =
209 self.get_module_path(path)
210 .ok_or_else(|| SuggestError::ModulePathResolution {
211 path: path.to_string(),
212 })?;
213
214 let module_id = match ctx.registry.lookup(&module_path) {
216 Some(id) => id,
217 None => return Ok(Vec::new()),
218 };
219
220 Ok(vec![MutationSpec::AddSpec {
221 type_id: symbol_id,
222 module_id,
223 group: self.default_group.clone(),
224 alias_name: Some(format!("{}Spec", type_name)),
225 relations: Vec::new(), }])
227 }
228}
229
230impl LintSuggest for MissingSpecForDomainType {
231 fn code(&self) -> &'static str {
232 "RS001"
233 }
234
235 fn default_severity(&self) -> LintSeverity {
236 LintSeverity::Warning
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn test_is_domain_module() {
246 let rule = MissingSpecForDomainType::new();
247
248 let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
249 assert!(rule.is_domain_module(&domain_path));
250
251 let model_path = SymbolPath::parse("test_crate::model::user::User").unwrap();
252 assert!(rule.is_domain_module(&model_path));
253
254 let entity_path = SymbolPath::parse("test_crate::entity::order::Order").unwrap();
255 assert!(rule.is_domain_module(&entity_path));
256
257 let util_path = SymbolPath::parse("test_crate::util::helper::Helper").unwrap();
258 assert!(!rule.is_domain_module(&util_path));
259 }
260
261 #[test]
262 fn test_custom_patterns() {
263 let rule = MissingSpecForDomainType::with_patterns(vec!["core".to_string()]);
264
265 let core_path = SymbolPath::parse("test_crate::core::config::Config").unwrap();
266 assert!(rule.is_domain_module(&core_path));
267
268 let domain_path = SymbolPath::parse("test_crate::domain::task::Task").unwrap();
269 assert!(!rule.is_domain_module(&domain_path));
270 }
271
272 #[test]
273 fn test_with_group() {
274 let rule = MissingSpecForDomainType::new().with_group("MyGroup");
275 assert_eq!(rule.default_group, "MyGroup");
276 }
277}