ryo_suggest/spec/
group_inconsistency.rs1use std::collections::HashMap;
6
7use ryo_analysis::context::AnalysisContext;
8use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
9
10use super::SpecSuggest;
11use crate::lint::{LintDetails, LintSuggest};
12use crate::{
13 LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
14 SuggestLocation, SuggestOpportunity, SuggestResult,
15};
16
17pub struct SpecGroupInconsistency {
40 spec_suffix: String,
42}
43
44impl SpecGroupInconsistency {
45 pub fn new() -> Self {
46 Self {
47 spec_suffix: "Spec".to_string(),
48 }
49 }
50
51 pub fn with_suffix(suffix: impl Into<String>) -> Self {
53 Self {
54 spec_suffix: suffix.into(),
55 }
56 }
57
58 fn extract_group_name(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<String> {
63 let typeflow = ctx.typeflow_graph();
64
65 for used_id in typeflow.types_used_by(symbol_id) {
67 if let Some(path) = ctx.registry.path(used_id) {
68 let name = path.name();
69 if name.ends_with("Group") {
71 return Some(name.to_string());
72 }
73 }
74 }
75
76 None
77 }
78}
79
80impl SpecSuggest for SpecGroupInconsistency {
81 fn spec_suffix(&self) -> &str {
82 &self.spec_suffix
83 }
84}
85
86impl Default for SpecGroupInconsistency {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92impl Suggest for SpecGroupInconsistency {
93 fn name(&self) -> &'static str {
94 "spec-group-inconsistency"
95 }
96
97 fn description(&self) -> &str {
98 "Detects modules where Spec TypeAliases use different Groups"
99 }
100
101 fn category(&self) -> SuggestCategory {
102 SuggestCategory::Lint
103 }
104
105 fn safety_level(&self) -> SafetyLevel {
106 SafetyLevel::Manual }
108
109 fn priority_weight(&self) -> f32 {
110 1.5 }
112
113 fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
114 let mut opportunities = Vec::new();
115 let mut next_id = 0u32;
116
117 let mut module_specs: HashMap<SymbolPath, Vec<(SymbolId, String, Option<String>)>> =
120 HashMap::new();
121
122 let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
124 ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
125 } else {
126 symbols.to_vec()
127 };
128
129 for symbol_id in symbols_to_check {
130 let path = match ctx.registry.path(symbol_id) {
131 Some(p) => p,
132 None => continue,
133 };
134
135 let alias_name = path.name();
136
137 if !self.is_spec_alias(alias_name) {
139 continue;
140 }
141
142 let module_path = match self.get_module_path(path) {
144 Some(mp) => mp,
145 None => continue,
146 };
147
148 let group_name = self.extract_group_name(ctx, symbol_id);
150
151 module_specs.entry(module_path).or_default().push((
152 symbol_id,
153 alias_name.to_string(),
154 group_name,
155 ));
156 }
157
158 for (module_path, specs) in module_specs {
160 if specs.len() < 2 {
162 continue;
163 }
164
165 let groups: Vec<_> = specs.iter().filter_map(|(_, _, g)| g.as_ref()).collect();
167
168 let unique_groups: std::collections::HashSet<_> = groups.iter().collect();
170 if unique_groups.len() <= 1 {
171 continue; }
173
174 let mut group_counts: HashMap<&String, usize> = HashMap::new();
177 for g in &groups {
178 *group_counts.entry(g).or_insert(0) += 1;
179 }
180 let expected_group = group_counts
181 .iter()
182 .max_by_key(|(_, count)| *count)
183 .map(|(g, _)| (*g).clone());
184
185 for (symbol_id, alias_name, group_name) in &specs {
187 let spec_group = match group_name {
188 Some(g) => g,
189 None => continue,
190 };
191
192 if expected_group.as_ref() == Some(spec_group) {
193 continue; }
195
196 let Some(location) = SuggestLocation::from_context(ctx, *symbol_id) else {
197 continue;
198 };
199
200 let all_groups: Vec<_> = unique_groups.iter().map(|g| g.as_str()).collect();
201
202 let opp = self.create_lint_opportunity(
203 OpportunityId::new(next_id),
204 vec![*symbol_id],
205 location,
206 format!(
207 "Module `{}` has mixed Spec Groups: `{}` uses `{}` but module also has `{}`",
208 module_path,
209 alias_name,
210 spec_group,
211 expected_group.as_deref().unwrap_or("unknown")
212 ),
213 LintDetails {
214 suggestion: Some(format!(
215 "Consider using `{}` for all Specs in this module",
216 expected_group.as_deref().unwrap_or("a consistent Group")
217 )),
218 expected: Some(format!(
219 "All Specs use `{}`",
220 expected_group.as_deref().unwrap_or("same Group")
221 )),
222 actual: Some(format!("Groups found: {}", all_groups.join(", "))),
223 },
224 );
225
226 opportunities.push(opp);
227 next_id += 1;
228 }
229 }
230
231 opportunities
232 }
233
234 fn to_mutation_specs(
235 &self,
236 _ctx: &AnalysisContext,
237 _opportunity: &SuggestOpportunity,
238 ) -> SuggestResult<Vec<MutationSpec>> {
239 Ok(Vec::new())
241 }
242}
243
244impl LintSuggest for SpecGroupInconsistency {
245 fn code(&self) -> &'static str {
246 "RS004"
247 }
248
249 fn default_severity(&self) -> LintSeverity {
250 LintSeverity::Warning
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn test_is_spec_alias() {
260 let rule = SpecGroupInconsistency::new();
261
262 assert!(rule.is_spec_alias("TaskSpec"));
263 assert!(rule.is_spec_alias("UserSpec"));
264 assert!(!rule.is_spec_alias("Spec"));
265 assert!(!rule.is_spec_alias("Task"));
266 }
267
268 #[test]
269 fn test_custom_suffix() {
270 let rule = SpecGroupInconsistency::with_suffix("Domain");
271
272 assert!(rule.is_spec_alias("TaskDomain"));
273 assert!(!rule.is_spec_alias("TaskSpec"));
274 }
275}