Skip to main content

ryo_suggest/pattern/
pattern_suggest.rs

1//! PatternBasedSuggest - Rule → Suggest adapter
2//!
3//! Bridges `ryo-pattern` rules with the Suggest framework.
4
5use ryo_analysis::context::AnalysisContext;
6use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
7use ryo_pattern::{BodyScanner, MatchResult, Rule, Severity as PatternSeverity};
8use ryo_source::pure::{
9    PureBlock, PureExpr, PureItem, PureMatchArm, PurePattern, PureStmt, PureVis,
10};
11
12use crate::lint::{LintDetails, LintSuggest};
13use crate::suggest::SymbolScope;
14use crate::{
15    EnumToTraitStrategy, LintSeverity, MatchHandling, MutationSpec, OpportunityId, SafetyLevel,
16    Suggest, SuggestCategory, SuggestLocation, SuggestOpportunity, SuggestResult,
17};
18
19/// Adapter: Rule → Suggest trait
20///
21/// Wraps a `ryo-pattern` Rule and implements the Suggest trait,
22/// enabling pattern-based lint rules to integrate with the suggestion system.
23///
24/// # Example
25///
26/// ```ignore
27/// use ryo_suggest::pattern::{RuleStore, PatternBasedSuggest};
28///
29/// let store = RuleStore::builtin_only()?;
30/// for rule in store.all_rules() {
31///     let suggest = PatternBasedSuggest::new(rule.clone());
32///     registry.register(Box::new(suggest));
33/// }
34/// ```
35pub struct PatternBasedSuggest {
36    rule: Rule,
37    /// Cached name for Suggest trait (leaked &'static str)
38    name: &'static str,
39    /// Cached description string (rule.name)
40    description: String,
41}
42
43impl PatternBasedSuggest {
44    /// Create a new PatternBasedSuggest from a Rule
45    pub fn new(rule: Rule) -> Self {
46        // Leak the rule ID as &'static str for Suggest::name()
47        // This is acceptable for long-lived rule objects
48        let name = Box::leak(rule.id.clone().into_boxed_str());
49        let description = rule.name.clone();
50        Self {
51            rule,
52            name,
53            description,
54        }
55    }
56
57    /// Get the underlying rule
58    pub fn rule(&self) -> &Rule {
59        &self.rule
60    }
61
62    /// Convert PatternSeverity to LintSeverity
63    fn to_lint_severity(severity: PatternSeverity) -> LintSeverity {
64        match severity {
65            PatternSeverity::Error => LintSeverity::Error,
66            PatternSeverity::Warning => LintSeverity::Warning,
67            PatternSeverity::Info | PatternSeverity::Hint => LintSeverity::Info,
68        }
69    }
70
71    /// Convert PatternSeverity to SafetyLevel
72    fn to_safety_level(severity: PatternSeverity) -> SafetyLevel {
73        match severity {
74            PatternSeverity::Error => SafetyLevel::Manual,
75            PatternSeverity::Warning => SafetyLevel::Confirm,
76            PatternSeverity::Info | PatternSeverity::Hint => SafetyLevel::Auto,
77        }
78    }
79
80    /// Check if a symbol matches the query's kind filter
81    fn matches_kind(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> bool {
82        let query_kind = match &self.rule.query.kind {
83            Some(k) => k,
84            None => return true, // No kind filter = match all
85        };
86
87        let symbol_kind = match ctx.registry.kind(symbol_id) {
88            Some(k) => k,
89            None => return false,
90        };
91
92        // Convert ryo_pattern::SymbolKind to ryo_symbol::SymbolKind
93        use ryo_pattern::SymbolKind as PatternKind;
94        matches!(
95            (query_kind, symbol_kind),
96            (PatternKind::Function, SymbolKind::Function)
97                | (PatternKind::Struct, SymbolKind::Struct)
98                | (PatternKind::Enum, SymbolKind::Enum)
99                | (PatternKind::Trait, SymbolKind::Trait)
100                | (PatternKind::Impl, SymbolKind::Impl)
101                | (PatternKind::Mod, SymbolKind::Mod)
102                | (PatternKind::Const, SymbolKind::Const)
103                | (PatternKind::Static, SymbolKind::Static)
104                | (PatternKind::TypeAlias, SymbolKind::TypeAlias)
105                | (PatternKind::Field, SymbolKind::Field)
106                | (PatternKind::Variant, SymbolKind::Variant)
107        )
108    }
109
110    /// Check if a symbol matches the query's attribute filter (vis, name, etc.)
111    fn matches_attrs(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> bool {
112        let attrs = match &self.rule.query.r#match {
113            Some(a) => a,
114            None => return true, // No attr filter = match all
115        };
116
117        // Check name pattern
118        if let Some(ref name_pattern) = attrs.name {
119            if let Some(path) = ctx.registry.path(symbol_id) {
120                let name = path.name();
121                if name != name_pattern {
122                    return false;
123                }
124            }
125        }
126
127        // Check glob pattern
128        if let Some(ref pattern) = attrs.pattern {
129            if let Some(path) = ctx.registry.path(symbol_id) {
130                let name = path.name();
131                if !self.matches_glob(pattern, name) {
132                    return false;
133                }
134            }
135        }
136
137        // Check visibility using the actual AST
138        if let Some(ref vis) = attrs.vis {
139            let ast_vis = Self::get_ast_visibility(ctx, symbol_id);
140            use ryo_pattern::Visibility;
141            match vis {
142                Visibility::Public => {
143                    if ast_vis != PureVis::Public {
144                        return false;
145                    }
146                }
147                Visibility::Crate => {
148                    if ast_vis != PureVis::Crate {
149                        return false;
150                    }
151                }
152                Visibility::Super => {
153                    if ast_vis != PureVis::Super {
154                        return false;
155                    }
156                }
157                Visibility::Private => {
158                    if ast_vis != PureVis::Private {
159                        return false;
160                    }
161                }
162            }
163        }
164
165        true
166    }
167
168    /// Extract visibility from the AST for a given symbol.
169    /// Falls back to Private if the symbol is not found or has no visibility field.
170    fn get_ast_visibility(ctx: &AnalysisContext, symbol_id: SymbolId) -> PureVis {
171        if let Some(item) = ctx.ast_registry.get(symbol_id) {
172            match item {
173                PureItem::Fn(f) => return f.vis.clone(),
174                PureItem::Struct(s) => return s.vis.clone(),
175                PureItem::Enum(e) => return e.vis.clone(),
176                PureItem::Const(c) => return c.vis.clone(),
177                PureItem::Static(s) => return s.vis.clone(),
178                PureItem::Trait(t) => return t.vis.clone(),
179                PureItem::Type(t) => return t.vis.clone(),
180                PureItem::Mod(m) => return m.vis.clone(),
181                _ => {}
182            }
183        }
184        PureVis::Private
185    }
186
187    /// Simple glob matching (supports * wildcard)
188    fn matches_glob(&self, pattern: &str, name: &str) -> bool {
189        if pattern == "*" {
190            return true;
191        }
192        if let Some(prefix) = pattern.strip_suffix('*') {
193            return name.starts_with(prefix);
194        }
195        if let Some(suffix) = pattern.strip_prefix('*') {
196            return name.ends_with(suffix);
197        }
198        pattern == name
199    }
200
201    /// Dynamic checks for rules that need runtime analysis.
202    ///
203    /// Returns (passes_check, custom_message) where custom_message overrides
204    /// the default rule message with more specific information.
205    fn matches_dynamic_checks(
206        &self,
207        ctx: &AnalysisContext,
208        symbol_id: SymbolId,
209    ) -> (bool, Option<String>) {
210        match self.rule.id.as_str() {
211            // RL060: trait-inline-candidate - only suggest if exactly 1 implementor
212            "RL060" => {
213                let impl_count = ctx.code_graph().impl_count(symbol_id);
214                if impl_count == 1 {
215                    let impl_id = match ctx.code_graph().implementors_of(symbol_id).next() {
216                        Some(id) => id,
217                        None => return (false, None),
218                    };
219
220                    // Skip blanket impls (e.g. `impl<T: Foo> Bar for T`)
221                    // where self_ty is a generic type parameter.
222                    if let Some(detail) = ctx.detail_store().impl_(impl_id) {
223                        if !detail.generics.type_params.is_empty()
224                            && detail
225                                .generics
226                                .type_params
227                                .iter()
228                                .any(|tp| tp == &detail.self_ty)
229                        {
230                            return (false, None);
231                        }
232                    }
233
234                    let implementor_name = ctx
235                        .registry
236                        .path(impl_id)
237                        .map(|p| p.name().to_string())
238                        .unwrap_or_else(|| "unknown".to_string());
239
240                    let trait_name = ctx
241                        .registry
242                        .path(symbol_id)
243                        .map(|p| p.name().to_string())
244                        .unwrap_or_else(|| "unknown".to_string());
245
246                    let message = format!(
247                        "Trait `{}` has exactly 1 implementor (`{}`). Consider InlineTrait to simplify.",
248                        trait_name, implementor_name
249                    );
250                    (true, Some(message))
251                } else {
252                    // Multiple implementors - not a good candidate for inlining
253                    (false, None)
254                }
255            }
256
257            // RL061: impl-extract-candidate
258            // Only suggest for inherent impls with >= 5 methods AND no existing trait impls
259            "RL061" => {
260                const MIN_METHODS: usize = 5;
261
262                let detail = ctx.detail_store().impl_(symbol_id);
263
264                // Must be an inherent impl (trait_ is None)
265                let is_inherent = detail.map(|d| d.trait_.is_none()).unwrap_or(false);
266                if !is_inherent {
267                    return (false, None);
268                }
269
270                let self_ty = detail.map(|d| d.self_ty.as_str()).unwrap_or("unknown");
271                let method_count = detail.map(|d| d.methods.len()).unwrap_or(0);
272
273                if method_count < MIN_METHODS {
274                    return (false, None);
275                }
276
277                // Skip if the type already has trait implementations
278                let has_trait_impl = ctx
279                    .registry
280                    .iter_by_kind(SymbolKind::Impl)
281                    .filter(|&id| id != symbol_id)
282                    .any(|id| {
283                        ctx.detail_store()
284                            .impl_(id)
285                            .map(|d| d.trait_.is_some() && d.self_ty == self_ty)
286                            .unwrap_or(false)
287                    });
288
289                if has_trait_impl {
290                    return (false, None);
291                }
292
293                let message = format!(
294                    "Inherent impl for `{}` with {} methods, no trait impls. Consider ExtractTrait.",
295                    self_ty, method_count
296                );
297                (true, Some(message))
298            }
299
300            // RL043: needless-return - only suggest if last statement is a return
301            "RL043" => {
302                let has_trailing_return = ctx
303                    .ast_registry
304                    .get(symbol_id)
305                    .and_then(|item| match item {
306                        PureItem::Fn(f) => Some(is_trailing_return(&f.body)),
307                        _ => None,
308                    })
309                    .unwrap_or(false);
310
311                if has_trailing_return {
312                    let fn_name = ctx
313                        .registry
314                        .path(symbol_id)
315                        .map(|p| p.name().to_string())
316                        .unwrap_or_else(|| "unknown".to_string());
317
318                    let message = format!(
319                        "Function `{}` has a redundant return at the end. Remove for idiomatic Rust.",
320                        fn_name
321                    );
322                    (true, Some(message))
323                } else {
324                    (false, None)
325                }
326            }
327
328            // RL090: large-function - only suggest if function has many statements
329            "RL090" => {
330                const STMT_THRESHOLD: usize = 30;
331
332                let stmt_count = ctx
333                    .ast_registry
334                    .get(symbol_id)
335                    .and_then(|item| match item {
336                        PureItem::Fn(f) => Some(count_statements(&f.body)),
337                        _ => None,
338                    })
339                    .unwrap_or(0);
340
341                if stmt_count >= STMT_THRESHOLD {
342                    let fn_name = ctx
343                        .registry
344                        .path(symbol_id)
345                        .map(|p| p.name().to_string())
346                        .unwrap_or_else(|| "unknown".to_string());
347
348                    let message = format!(
349                        "Function `{}` has {} statements (threshold: {}). Consider splitting.",
350                        fn_name, stmt_count, STMT_THRESHOLD
351                    );
352                    (true, Some(message))
353                } else {
354                    (false, None)
355                }
356            }
357
358            // RL091: too-many-args - only suggest if function has many parameters
359            "RL091" => {
360                const PARAM_THRESHOLD: usize = 5;
361
362                let param_count = ctx
363                    .ast_registry
364                    .get(symbol_id)
365                    .and_then(|item| match item {
366                        PureItem::Fn(f) => Some(f.params.len()),
367                        _ => None,
368                    })
369                    .unwrap_or(0);
370
371                if param_count >= PARAM_THRESHOLD {
372                    let fn_name = ctx
373                        .registry
374                        .path(symbol_id)
375                        .map(|p| p.name().to_string())
376                        .unwrap_or_else(|| "unknown".to_string());
377
378                    let message = format!(
379                        "Function `{}` has {} parameters (threshold: {}). Consider grouping into a struct.",
380                        fn_name, param_count, PARAM_THRESHOLD
381                    );
382                    (true, Some(message))
383                } else {
384                    (false, None)
385                }
386            }
387
388            // RL004: unwrap-or-default - only suggest if unwrap_or(Default::default()) exists
389            "RL004" => {
390                let unwrap_or_default_count = ctx
391                    .ast_registry
392                    .get(symbol_id)
393                    .and_then(|item| match item {
394                        PureItem::Fn(f) => Some(count_unwrap_or_default_calls(&f.body)),
395                        _ => None,
396                    })
397                    .unwrap_or(0);
398
399                if unwrap_or_default_count > 0 {
400                    let fn_name = ctx
401                        .registry
402                        .path(symbol_id)
403                        .map(|p| p.name().to_string())
404                        .unwrap_or_else(|| "unknown".to_string());
405
406                    let message = format!(
407                        "`{}` has {} unwrap_or(Default::default()) call{} that can be simplified to unwrap_or_default()",
408                        fn_name,
409                        unwrap_or_default_count,
410                        if unwrap_or_default_count == 1 { "" } else { "s" }
411                    );
412                    (true, Some(message))
413                } else {
414                    (false, None)
415                }
416            }
417
418            // RL040: collapsible-if - only suggest if actually collapsible nested ifs exist
419            "RL040" => {
420                let collapsible_count = ctx
421                    .ast_registry
422                    .get(symbol_id)
423                    .and_then(|item| match item {
424                        PureItem::Fn(f) => Some(count_collapsible_ifs(&f.body)),
425                        _ => None,
426                    })
427                    .unwrap_or(0);
428
429                if collapsible_count > 0 {
430                    let fn_name = ctx
431                        .registry
432                        .path(symbol_id)
433                        .map(|p| p.name().to_string())
434                        .unwrap_or_else(|| "unknown".to_string());
435
436                    let message = format!(
437                        "Nested if statements may be collapsible in `{}` ({} occurrence{})",
438                        fn_name,
439                        collapsible_count,
440                        if collapsible_count == 1 { "" } else { "s" }
441                    );
442                    (true, Some(message))
443                } else {
444                    (false, None)
445                }
446            }
447
448            // RL050: underscore-noop-arm - only suggest if match actually has `_ => {}` arm
449            "RL050" => {
450                let empty_wild_count = ctx
451                    .ast_registry
452                    .get(symbol_id)
453                    .and_then(|item| match item {
454                        PureItem::Fn(f) => Some(count_empty_wildcard_arms(&f.body)),
455                        _ => None,
456                    })
457                    .unwrap_or(0);
458
459                if empty_wild_count > 0 {
460                    let fn_name = ctx
461                        .registry
462                        .path(symbol_id)
463                        .map(|p| p.name().to_string())
464                        .unwrap_or_else(|| "unknown".to_string());
465
466                    let message = format!(
467                        "`{}` has {} empty wildcard arm{} (`_ => {{}}`). Expand into explicit variant arms.",
468                        fn_name,
469                        empty_wild_count,
470                        if empty_wild_count == 1 { "" } else { "s" }
471                    );
472                    (true, Some(message))
473                } else {
474                    (false, None)
475                }
476            }
477
478            // RL070: enum-to-trait-candidate - only suggest for enums with enough variants and impl blocks
479            "RL070" => {
480                const MIN_VARIANTS: usize = 4;
481
482                let variant_count = ctx
483                    .detail_store()
484                    .enum_(symbol_id)
485                    .map(|d| d.variants.len())
486                    .unwrap_or(0);
487
488                if variant_count < MIN_VARIANTS {
489                    return (false, None);
490                }
491
492                // Check if the enum has inherent impls (= has behavior, not just data)
493                let impl_count = ctx.code_graph().impl_count(symbol_id);
494                if impl_count == 0 {
495                    return (false, None);
496                }
497
498                let enum_name = ctx
499                    .registry
500                    .path(symbol_id)
501                    .map(|p| p.name().to_string())
502                    .unwrap_or_else(|| "unknown".to_string());
503
504                let message = format!(
505                    "Enum `{}` has {} variants and {} impl block{} - consider EnumToTrait for extensibility",
506                    enum_name,
507                    variant_count,
508                    impl_count,
509                    if impl_count == 1 { "" } else { "s" }
510                );
511                (true, Some(message))
512            }
513
514            // RL003: no-direct-indexing - skip if all index ops are bounds-guarded
515            "RL003" => {
516                let func = match ctx.ast_registry.get(symbol_id) {
517                    Some(PureItem::Fn(f)) => f,
518                    _ => return (true, None),
519                };
520
521                let mut indexed_collections = Vec::new();
522                collect_indexed_collections(&func.body, &mut indexed_collections);
523
524                if indexed_collections.is_empty() {
525                    return (false, None);
526                }
527
528                // Build len-alias map: `let len = x.len()` → "len" → "x"
529                let mut len_aliases = std::collections::HashMap::new();
530                collect_len_aliases(&func.body, &mut len_aliases);
531
532                let mut len_checked_collections = Vec::new();
533                collect_len_checked_collections(
534                    &func.body,
535                    &mut len_checked_collections,
536                    &len_aliases,
537                );
538
539                // Check if every indexed collection has a corresponding bounds check
540                let all_guarded = indexed_collections
541                    .iter()
542                    .all(|coll| len_checked_collections.contains(coll));
543
544                if all_guarded {
545                    (false, None)
546                } else {
547                    let fn_name = ctx
548                        .registry
549                        .path(symbol_id)
550                        .map(|p| p.name().to_string())
551                        .unwrap_or_else(|| "unknown".to_string());
552
553                    let unguarded: Vec<_> = indexed_collections
554                        .iter()
555                        .filter(|c| !len_checked_collections.contains(c))
556                        .collect();
557
558                    let message = format!(
559                        "Function `{}` has unguarded index on: {}",
560                        fn_name,
561                        unguarded
562                            .iter()
563                            .map(|s| s.as_str())
564                            .collect::<Vec<_>>()
565                            .join(", "),
566                    );
567                    (true, Some(message))
568                }
569            }
570
571            // Other rules: no dynamic checks needed
572            _ => (true, None),
573        }
574    }
575
576    /// Scan function body for pattern matches
577    fn scan_body(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Vec<MatchResult> {
578        // RL003: per-collection filtering (only flag unguarded collections)
579        if self.rule.id == "RL003" {
580            return self.scan_body_rl003(ctx, symbol_id);
581        }
582
583        let body_match = match &self.rule.query.body {
584            Some(b) => b,
585            None => return vec![], // No body pattern = no matches
586        };
587
588        // Get function from AST registry
589        let func = match ctx.ast_registry.get(symbol_id) {
590            Some(PureItem::Fn(f)) => f,
591            _ => return vec![],
592        };
593
594        let mut results = Vec::new();
595
596        // Check contains patterns
597        if let Some(ref patterns) = body_match.contains {
598            for pattern in patterns {
599                let scanner = BodyScanner::new(pattern);
600                let matches = scanner.scan_fn(func);
601                results.extend(matches);
602            }
603        }
604
605        // Check all_of patterns (all must match)
606        if let Some(ref patterns) = body_match.all_of {
607            let all_matched = patterns.iter().all(|pattern| {
608                let scanner = BodyScanner::new(pattern);
609                !scanner.scan_fn(func).is_empty()
610            });
611            if !all_matched {
612                // If not all patterns matched, clear results
613                return vec![];
614            }
615        }
616
617        // Check not_contains patterns (none should match)
618        if let Some(ref patterns) = body_match.not_contains {
619            for pattern in patterns {
620                let scanner = BodyScanner::new(pattern);
621                if !scanner.scan_fn(func).is_empty() {
622                    // Found a pattern that should not exist
623                    return vec![];
624                }
625            }
626        }
627
628        results
629    }
630
631    /// RL003-specific: scan body and only return Index matches on unguarded collections.
632    fn scan_body_rl003(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Vec<MatchResult> {
633        let func = match ctx.ast_registry.get(symbol_id) {
634            Some(PureItem::Fn(f)) => f,
635            _ => return vec![],
636        };
637
638        let mut indexed = Vec::new();
639        collect_indexed_collections(&func.body, &mut indexed);
640        if indexed.is_empty() {
641            return vec![];
642        }
643
644        let mut aliases = std::collections::HashMap::new();
645        collect_len_aliases(&func.body, &mut aliases);
646
647        let mut checked = Vec::new();
648        collect_len_checked_collections(&func.body, &mut checked, &aliases);
649
650        // split().collect() results always have ≥1 element
651        collect_split_derived(&func.body, &mut checked);
652
653        let unguarded: Vec<String> = indexed
654            .into_iter()
655            .filter(|c| !checked.contains(c))
656            .collect();
657        if unguarded.is_empty() {
658            return vec![];
659        }
660
661        let mut results = Vec::new();
662        scan_unguarded_indexes_block(&func.body, &unguarded, &mut results);
663        results
664    }
665
666    /// Interpolate message with captures
667    fn interpolate_message(&self, match_result: &MatchResult) -> String {
668        let mut message = self.rule.message.clone();
669
670        // Replace {$VAR.text} with captured text
671        for (var_name, captured) in &match_result.captures {
672            let placeholder = format!("{{{}.text}}", var_name);
673            message = message.replace(&placeholder, &captured.text);
674
675            // Also support {$VAR} shorthand
676            message = message.replace(&format!("{{{}}}", var_name), &captured.text);
677        }
678
679        message
680    }
681
682    /// Get location for a symbol
683    fn get_location(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<SuggestLocation> {
684        SuggestLocation::from_context(ctx, symbol_id)
685    }
686
687    /// Get the module SymbolPath containing the primary target symbol.
688    ///
689    /// Returns the parent module path for targeting MutationSpecs.
690    fn get_target_module_path(
691        &self,
692        ctx: &AnalysisContext,
693        opportunity: &SuggestOpportunity,
694    ) -> Option<SymbolPath> {
695        let symbol_id = opportunity.primary_target()?;
696        let symbol_path = ctx.registry.path(symbol_id)?;
697
698        // Get parent module path (remove the function/item name)
699        symbol_path.parent()
700    }
701
702    /// Generate MutationSpecs from Rule.fix or dynamic logic.
703    ///
704    /// Priority:
705    /// 1. Rule.fix (YAML-defined MutationSpec) - filled with runtime context
706    /// 2. Dynamic generation for rules requiring runtime info (RL060, RL061, RL070)
707    fn generate_specs_for_rule(
708        &self,
709        ctx: &AnalysisContext,
710        opportunity: &SuggestOpportunity,
711    ) -> SuggestResult<Vec<MutationSpec>> {
712        let target_module = self.get_target_module_path(ctx, opportunity);
713        let target_fn = Some(opportunity.location.symbol_name().to_string());
714
715        // Try Rule.fix first (YAML-defined MutationSpec)
716        if let Some(fix_json) = &self.rule.fix {
717            if let Ok(mut spec) = serde_json::from_value::<MutationSpec>(fix_json.clone()) {
718                // Fill in runtime context (target module and function)
719                Self::fill_mutation_spec_context(&mut spec, target_module, target_fn);
720                return Ok(vec![spec]);
721            }
722        }
723
724        // Dynamic generation for rules that need runtime info
725        match self.rule.id.as_str() {
726            // RL060: trait-inline-candidate → InlineTrait
727            // Dynamic check already verified exactly 1 implementor exists
728            "RL060" => {
729                let symbol_id = opportunity.primary_target();
730                if let Some(trait_id) = symbol_id {
731                    // Get trait name for diagnostics
732                    let _trait_name = ctx
733                        .registry
734                        .path(trait_id)
735                        .map(|p| p.name().to_string())
736                        .unwrap_or_default();
737
738                    // Get the single implementor (we know there's exactly 1 from dynamic check)
739                    let implementor = ctx.code_graph().implementors_of(trait_id).next();
740                    let struct_name = implementor
741                        .and_then(|id| ctx.registry.path(id))
742                        .map(|p| p.name().to_string())
743                        .unwrap_or_default();
744
745                    if !struct_name.is_empty() {
746                        Ok(vec![MutationSpec::InlineTrait {
747                            target: ryo_executor::MutationTargetSymbol::ById(trait_id),
748                            struct_name,
749                            remove_trait: true,
750                        }])
751                    } else {
752                        Ok(Vec::new())
753                    }
754                } else {
755                    Ok(Vec::new())
756                }
757            }
758
759            // RL061: impl-extract-candidate → ExtractTrait
760            // Dynamic check already verified this is an inherent impl with methods
761            "RL061" => {
762                let symbol_id = opportunity.primary_target();
763                if let Some(impl_id) = symbol_id {
764                    // Get self_ty (struct name) from detail store
765                    let struct_name = ctx
766                        .detail_store()
767                        .impl_(impl_id)
768                        .map(|d| d.self_ty.clone())
769                        .unwrap_or_default();
770
771                    if !struct_name.is_empty() {
772                        // Generate a suggested trait name based on struct name
773                        let trait_name = format!("{}Trait", struct_name);
774
775                        Ok(vec![MutationSpec::ExtractTrait {
776                            target: ryo_executor::MutationTargetSymbol::ById(impl_id),
777                            trait_name,
778                            methods: None, // Extract all methods
779                        }])
780                    } else {
781                        Ok(Vec::new())
782                    }
783                } else {
784                    Ok(Vec::new())
785                }
786            }
787
788            // RL070: enum-to-trait-candidate → EnumToTrait
789            "RL070" => {
790                let symbol_id = opportunity.primary_target();
791                if let Some(enum_id) = symbol_id {
792                    self.generate_enum_to_trait_specs(ctx, enum_id)
793                } else {
794                    Ok(Vec::new())
795                }
796            }
797
798            // Rules without automatic fixes
799            _ => Ok(Vec::new()),
800        }
801    }
802
803    /// Fill in runtime context for MutationSpec.
804    ///
805    /// Sets target (module path) and target_fn (function name) if they are None.
806    fn fill_mutation_spec_context(
807        spec: &mut MutationSpec,
808        target_module: Option<SymbolPath>,
809        target_fn: Option<String>,
810    ) {
811        // NOTE: This function is deprecated as MutationSpec now uses SymbolId instead of SymbolPath/String
812        // The context filling logic needs to be refactored to work with SymbolIds
813        // For now, we skip filling to avoid type mismatches
814        let _ = (spec, target_module, target_fn);
815    }
816
817    /// Generate MutationSpecs for EnumToTrait refactoring (RL070).
818    ///
819    /// Converts enum variants to trait + struct implementations:
820    /// - Creates a marker trait with the enum name
821    /// - Creates a struct for each variant
822    /// - Creates impl Trait for each struct
823    /// - Updates all usage sites (Status::Running -> Running)
824    /// - Removes the original enum
825    fn generate_enum_to_trait_specs(
826        &self,
827        _ctx: &AnalysisContext,
828        enum_id: SymbolId,
829    ) -> SuggestResult<Vec<MutationSpec>> {
830        // Return single EnumToTrait spec - all the work is done by EnumToTraitMutation
831        Ok(vec![MutationSpec::EnumToTrait {
832            target: ryo_executor::MutationTargetSymbol::ById(enum_id),
833            trait_name: None, // Use same name as enum
834            remove_enum: true,
835            strategy: EnumToTraitStrategy::default(),
836            match_handling: MatchHandling::default(),
837        }])
838    }
839}
840
841impl std::fmt::Debug for PatternBasedSuggest {
842    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
843        f.debug_struct("PatternBasedSuggest")
844            .field("rule_id", &self.rule.id)
845            .field("rule_name", &self.rule.name)
846            .finish()
847    }
848}
849
850impl Suggest for PatternBasedSuggest {
851    fn name(&self) -> &'static str {
852        self.name
853    }
854
855    fn description(&self) -> &str {
856        &self.description
857    }
858
859    fn category(&self) -> SuggestCategory {
860        SuggestCategory::Lint
861    }
862
863    fn safety_level(&self) -> SafetyLevel {
864        Self::to_safety_level(self.rule.severity)
865    }
866
867    fn priority_weight(&self) -> f32 {
868        match self.rule.severity {
869            PatternSeverity::Error => 1.0,
870            PatternSeverity::Warning => 0.8,
871            PatternSeverity::Info => 0.5,
872            PatternSeverity::Hint => 0.3,
873        }
874    }
875
876    fn rule_id(&self) -> Option<&str> {
877        Some(&self.rule.id)
878    }
879
880    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
881        let mut opportunities = Vec::new();
882        let mut next_id = 0u32;
883
884        // Determine which symbols to check
885        let symbols_to_check: Box<dyn Iterator<Item = SymbolId>> = if symbols.is_empty() {
886            // Check all symbols matching the query kind
887            if let Some(kind) = &self.rule.query.kind {
888                use ryo_pattern::SymbolKind as PatternKind;
889                let symbol_kind = match kind {
890                    PatternKind::Function => Some(SymbolKind::Function),
891                    PatternKind::Struct => Some(SymbolKind::Struct),
892                    PatternKind::Enum => Some(SymbolKind::Enum),
893                    PatternKind::Trait => Some(SymbolKind::Trait),
894                    PatternKind::Impl => Some(SymbolKind::Impl),
895                    PatternKind::Mod => Some(SymbolKind::Mod),
896                    _ => None,
897                };
898
899                if let Some(sk) = symbol_kind {
900                    Box::new(ctx.registry.iter_by_kind(sk))
901                } else {
902                    Box::new(ctx.registry.iter().map(|(id, _)| id))
903                }
904            } else {
905                Box::new(ctx.registry.iter().map(|(id, _)| id))
906            }
907        } else {
908            Box::new(symbols.iter().copied())
909        };
910
911        // Pre-compute binary crate names if this rule has scope constraints
912        let binary_crates = if self.rule.scope.is_empty() {
913            None
914        } else {
915            Some(SymbolScope::binary_crate_names(ctx))
916        };
917
918        for symbol_id in symbols_to_check {
919            // Check rule scope constraint (early exit before expensive checks)
920            if let Some(ref bin_crates) = binary_crates {
921                let symbol_scope = SymbolScope::resolve(ctx, symbol_id, bin_crates);
922                let scope_str = symbol_scope.to_string();
923                if !self.rule.scope.iter().any(|s| s == &scope_str) {
924                    continue;
925                }
926            }
927
928            // Check kind filter
929            if !self.matches_kind(ctx, symbol_id) {
930                continue;
931            }
932
933            // Check attribute filter
934            if !self.matches_attrs(ctx, symbol_id) {
935                continue;
936            }
937
938            // Dynamic checks for rules that need runtime analysis (RL060, RL061, etc.)
939            let (passes_dynamic, custom_message) = self.matches_dynamic_checks(ctx, symbol_id);
940            if !passes_dynamic {
941                continue;
942            }
943
944            // Scan body for pattern matches
945            let matches = self.scan_body(ctx, symbol_id);
946
947            // If body pattern exists but no matches, skip
948            if self.rule.query.body.is_some() && matches.is_empty() {
949                continue;
950            }
951
952            // Create opportunity for each match
953            let Some(location) = self.get_location(ctx, symbol_id) else {
954                continue; // Skip if symbol location cannot be resolved
955            };
956
957            if matches.is_empty() && self.rule.query.body.is_none() {
958                // No body pattern, just kind/attr match
959                // Use custom message from dynamic checks if available
960                let message = custom_message
961                    .clone()
962                    .unwrap_or_else(|| self.rule.message.clone());
963                let opp = self.create_lint_opportunity(
964                    OpportunityId::new(next_id),
965                    vec![symbol_id],
966                    location,
967                    message,
968                    LintDetails {
969                        suggestion: self.rule.suggestion.clone(),
970                        expected: None,
971                        actual: None,
972                    },
973                );
974                opportunities.push(opp);
975                next_id += 1;
976            } else if let Some(msg) = custom_message.clone() {
977                // Dynamic check provided a summary message (e.g. RL040 collapsible-if
978                // count). Emit a single suggestion per symbol instead of one per body
979                // match, since the dynamic check already aggregated the count.
980                let opp = self.create_lint_opportunity(
981                    OpportunityId::new(next_id),
982                    vec![symbol_id],
983                    location,
984                    msg,
985                    LintDetails {
986                        suggestion: self.rule.suggestion.clone(),
987                        expected: None,
988                        actual: None,
989                    },
990                );
991                opportunities.push(opp);
992                next_id += 1;
993            } else {
994                // Body pattern matches — one suggestion per match
995                for match_result in matches {
996                    let message = self.interpolate_message(&match_result);
997
998                    let opp = self.create_lint_opportunity(
999                        OpportunityId::new(next_id),
1000                        vec![symbol_id],
1001                        location.clone(),
1002                        message,
1003                        LintDetails {
1004                            suggestion: self.rule.suggestion.clone(),
1005                            expected: None,
1006                            actual: None,
1007                        },
1008                    );
1009                    opportunities.push(opp);
1010                    next_id += 1;
1011                }
1012            }
1013        }
1014
1015        opportunities
1016    }
1017
1018    fn to_mutation_specs(
1019        &self,
1020        ctx: &AnalysisContext,
1021        opportunity: &SuggestOpportunity,
1022    ) -> SuggestResult<Vec<MutationSpec>> {
1023        // Generate MutationSpecs based on rule ID using static registry
1024        self.generate_specs_for_rule(ctx, opportunity)
1025    }
1026}
1027
1028impl LintSuggest for PatternBasedSuggest {
1029    fn code(&self) -> &'static str {
1030        // We need to return &'static str, but rule.id is dynamic
1031        // Using leaked string (acceptable for long-lived rules)
1032        // Alternative: use a static registry of rule codes
1033        Box::leak(self.rule.id.clone().into_boxed_str())
1034    }
1035
1036    fn default_severity(&self) -> LintSeverity {
1037        Self::to_lint_severity(self.rule.severity)
1038    }
1039}
1040
1041/// Count statements in a block (recursive)
1042fn count_statements(block: &PureBlock) -> usize {
1043    let mut count = 0;
1044    for stmt in &block.stmts {
1045        count += 1;
1046        // Count nested statements in control flow
1047        match stmt {
1048            PureStmt::Expr(expr) | PureStmt::Semi(expr) => {
1049                count += count_statements_in_expr(expr);
1050            }
1051            _ => {}
1052        }
1053    }
1054    count
1055}
1056
1057/// Check if block ends with a return statement
1058fn is_trailing_return(block: &PureBlock) -> bool {
1059    match block.stmts.last() {
1060        Some(PureStmt::Semi(expr)) | Some(PureStmt::Expr(expr)) => {
1061            matches!(expr, ryo_source::pure::PureExpr::Return(Some(_)))
1062        }
1063        _ => false,
1064    }
1065}
1066
1067/// Count nested statements in expressions (if/match/loop bodies)
1068fn count_statements_in_expr(expr: &ryo_source::pure::PureExpr) -> usize {
1069    use ryo_source::pure::PureExpr;
1070    match expr {
1071        PureExpr::Block { block, .. } => count_statements(block),
1072        PureExpr::If {
1073            then_branch,
1074            else_branch,
1075            ..
1076        } => {
1077            let mut count = count_statements(then_branch);
1078            if let Some(else_expr) = else_branch {
1079                count += count_statements_in_expr(else_expr);
1080            }
1081            count
1082        }
1083        PureExpr::Match { arms, .. } => {
1084            arms.iter().map(|a| count_statements_in_expr(&a.body)).sum()
1085        }
1086        PureExpr::Loop { body: block, .. }
1087        | PureExpr::While { body: block, .. }
1088        | PureExpr::For { body: block, .. } => count_statements(block),
1089        _ => 0,
1090    }
1091}
1092
1093/// Count match expressions with empty wildcard arms (`_ => {}`) in a block.
1094fn count_empty_wildcard_arms(block: &PureBlock) -> usize {
1095    let mut count = 0;
1096    for stmt in &block.stmts {
1097        let expr = match stmt {
1098            PureStmt::Expr(e) | PureStmt::Semi(e) => e,
1099            _ => continue,
1100        };
1101        count += count_empty_wildcard_arms_in_expr(expr);
1102    }
1103    count
1104}
1105
1106fn count_empty_wildcard_arms_in_expr(expr: &PureExpr) -> usize {
1107    let mut count = 0;
1108    match expr {
1109        PureExpr::Match { arms, .. } => {
1110            // Check if any arm is `_ => {}` (wildcard with empty body)
1111            for arm in arms {
1112                if is_empty_wildcard_arm(arm) {
1113                    count += 1;
1114                }
1115                // Recurse into arm bodies
1116                count += count_empty_wildcard_arms_in_expr(&arm.body);
1117            }
1118        }
1119        PureExpr::If {
1120            then_branch,
1121            else_branch,
1122            ..
1123        } => {
1124            count += count_empty_wildcard_arms(then_branch);
1125            if let Some(else_expr) = else_branch {
1126                count += count_empty_wildcard_arms_in_expr(else_expr);
1127            }
1128        }
1129        PureExpr::Block { block, .. } => {
1130            count += count_empty_wildcard_arms(block);
1131        }
1132        PureExpr::Loop { body: block, .. }
1133        | PureExpr::While { body: block, .. }
1134        | PureExpr::For { body: block, .. } => {
1135            count += count_empty_wildcard_arms(block);
1136        }
1137        PureExpr::Closure { body, .. } => {
1138            count += count_empty_wildcard_arms_in_expr(body);
1139        }
1140        _ => {}
1141    }
1142    count
1143}
1144
1145/// Check if a match arm is a wildcard with empty body (`_ => {}`).
1146fn is_empty_wildcard_arm(arm: &PureMatchArm) -> bool {
1147    // Pattern must be wildcard `_`
1148    if !matches!(arm.pattern, PurePattern::Wild) {
1149        return false;
1150    }
1151    // Guard must be absent
1152    if arm.guard.is_some() {
1153        return false;
1154    }
1155    // Body must be empty: unit `()` or empty block `{}`
1156    match &arm.body {
1157        PureExpr::Tuple(items) if items.is_empty() => true,
1158        PureExpr::Block { block, .. } if block.stmts.is_empty() => true,
1159        PureExpr::Lit(s) if s == "()" => true,
1160        _ => false,
1161    }
1162}
1163
1164/// Count collapsible if patterns in a block.
1165///
1166/// A collapsible if is: `if A { if B { body } }` where:
1167/// - Outer if has NO else branch
1168/// - then_branch contains exactly 1 statement
1169/// - That statement is an if with NO else branch
1170fn count_collapsible_ifs(block: &PureBlock) -> usize {
1171    let mut count = 0;
1172    for stmt in &block.stmts {
1173        let expr = match stmt {
1174            PureStmt::Expr(e) | PureStmt::Semi(e) => e,
1175            _ => continue,
1176        };
1177        count += count_collapsible_ifs_in_expr(expr);
1178    }
1179    count
1180}
1181
1182fn count_collapsible_ifs_in_expr(expr: &ryo_source::pure::PureExpr) -> usize {
1183    use ryo_source::pure::PureExpr;
1184    let mut count = 0;
1185
1186    match expr {
1187        PureExpr::If {
1188            cond: outer_cond,
1189            then_branch,
1190            else_branch: None,
1191        } => {
1192            // Check if then_branch is a single if-no-else (= collapsible)
1193            // Exclude if-let patterns (cond is PureExpr::Let) — Clippy doesn't flag these
1194            if then_branch.stmts.len() == 1 && !matches!(outer_cond.as_ref(), PureExpr::Let { .. })
1195            {
1196                let inner = match &then_branch.stmts[0] {
1197                    PureStmt::Expr(e) | PureStmt::Semi(e) => Some(e),
1198                    _ => None,
1199                };
1200                if let Some(PureExpr::If {
1201                    cond: inner_cond,
1202                    else_branch: None,
1203                    ..
1204                }) = inner
1205                {
1206                    // Inner must also not be if-let
1207                    if !matches!(inner_cond.as_ref(), PureExpr::Let { .. }) {
1208                        count += 1;
1209                    }
1210                }
1211            }
1212            // Recurse into then_branch
1213            count += count_collapsible_ifs(then_branch);
1214        }
1215        PureExpr::If {
1216            then_branch,
1217            else_branch: Some(else_expr),
1218            ..
1219        } => {
1220            count += count_collapsible_ifs(then_branch);
1221            count += count_collapsible_ifs_in_expr(else_expr);
1222        }
1223        PureExpr::Block { block, .. } => {
1224            count += count_collapsible_ifs(block);
1225        }
1226        PureExpr::Match { arms, .. } => {
1227            for arm in arms {
1228                count += count_collapsible_ifs_in_expr(&arm.body);
1229            }
1230        }
1231        PureExpr::Loop { body: block, .. }
1232        | PureExpr::While { body: block, .. }
1233        | PureExpr::For { body: block, .. } => {
1234            count += count_collapsible_ifs(block);
1235        }
1236        PureExpr::Closure { body, .. } => {
1237            count += count_collapsible_ifs_in_expr(body);
1238        }
1239        _ => {}
1240    }
1241    count
1242}
1243
1244/// Count `.unwrap_or(Default::default())` calls in a block.
1245fn count_unwrap_or_default_calls(block: &PureBlock) -> usize {
1246    let mut count = 0;
1247    for stmt in &block.stmts {
1248        let expr = match stmt {
1249            PureStmt::Expr(e) | PureStmt::Semi(e) => e,
1250            PureStmt::Local { init: Some(e), .. } => e,
1251            _ => continue,
1252        };
1253        count += count_unwrap_or_default_calls_in_expr(expr);
1254    }
1255    count
1256}
1257
1258fn count_unwrap_or_default_calls_in_expr(expr: &PureExpr) -> usize {
1259    let mut count = 0;
1260    match expr {
1261        PureExpr::MethodCall {
1262            receiver,
1263            method,
1264            args,
1265            ..
1266        } => {
1267            if method == "unwrap_or" && args.len() == 1 && is_default_default_call(&args[0]) {
1268                count += 1;
1269            }
1270            count += count_unwrap_or_default_calls_in_expr(receiver);
1271            for arg in args {
1272                count += count_unwrap_or_default_calls_in_expr(arg);
1273            }
1274        }
1275        PureExpr::Call { func, args } => {
1276            count += count_unwrap_or_default_calls_in_expr(func);
1277            for arg in args {
1278                count += count_unwrap_or_default_calls_in_expr(arg);
1279            }
1280        }
1281        PureExpr::Binary { left, right, .. } => {
1282            count += count_unwrap_or_default_calls_in_expr(left);
1283            count += count_unwrap_or_default_calls_in_expr(right);
1284        }
1285        PureExpr::Unary { expr, .. } | PureExpr::Try(expr) | PureExpr::Await(expr) => {
1286            count += count_unwrap_or_default_calls_in_expr(expr);
1287        }
1288        PureExpr::Field { expr, .. } => {
1289            count += count_unwrap_or_default_calls_in_expr(expr);
1290        }
1291        PureExpr::Index { expr, index } => {
1292            count += count_unwrap_or_default_calls_in_expr(expr);
1293            count += count_unwrap_or_default_calls_in_expr(index);
1294        }
1295        PureExpr::If {
1296            cond,
1297            then_branch,
1298            else_branch,
1299        } => {
1300            count += count_unwrap_or_default_calls_in_expr(cond);
1301            count += count_unwrap_or_default_calls(then_branch);
1302            if let Some(else_expr) = else_branch {
1303                count += count_unwrap_or_default_calls_in_expr(else_expr);
1304            }
1305        }
1306        PureExpr::Match { expr, arms } => {
1307            count += count_unwrap_or_default_calls_in_expr(expr);
1308            for arm in arms {
1309                count += count_unwrap_or_default_calls_in_expr(&arm.body);
1310            }
1311        }
1312        PureExpr::Block { block, .. } => {
1313            count += count_unwrap_or_default_calls(block);
1314        }
1315        PureExpr::Loop { body: block, .. }
1316        | PureExpr::While { body: block, .. }
1317        | PureExpr::For { body: block, .. } => {
1318            count += count_unwrap_or_default_calls(block);
1319        }
1320        PureExpr::Closure { body, .. } => {
1321            count += count_unwrap_or_default_calls_in_expr(body);
1322        }
1323        _ => {}
1324    }
1325    count
1326}
1327
1328/// Check if an expression is `Default::default()` (a call to `Default::default` with no args).
1329fn is_default_default_call(expr: &PureExpr) -> bool {
1330    match expr {
1331        PureExpr::Call { func, args } if args.is_empty() => {
1332            matches!(func.as_ref(), PureExpr::Path(p) if p == "Default::default" || p == "Default :: default")
1333        }
1334        _ => false,
1335    }
1336}
1337
1338/// Extract the root name from a PureExpr (e.g., `x`, `self.field` → `self`).
1339fn expr_root_name(expr: &PureExpr) -> Option<&str> {
1340    match expr {
1341        PureExpr::Path(name) => Some(name.as_str()),
1342        PureExpr::Field { expr, .. } => expr_root_name(expr),
1343        PureExpr::MethodCall { receiver, .. } => expr_root_name(receiver),
1344        _ => None,
1345    }
1346}
1347
1348/// Walk a block and collect one MatchResult per Index expression on an unguarded collection.
1349fn scan_unguarded_indexes_block(
1350    block: &PureBlock,
1351    unguarded: &[String],
1352    results: &mut Vec<MatchResult>,
1353) {
1354    for stmt in &block.stmts {
1355        match stmt {
1356            PureStmt::Local {
1357                init: Some(expr), ..
1358            } => {
1359                scan_unguarded_indexes_expr(expr, unguarded, results);
1360            }
1361            PureStmt::Semi(expr) | PureStmt::Expr(expr) => {
1362                scan_unguarded_indexes_expr(expr, unguarded, results);
1363            }
1364            _ => {}
1365        }
1366    }
1367}
1368
1369fn scan_unguarded_indexes_expr(
1370    expr: &PureExpr,
1371    unguarded: &[String],
1372    results: &mut Vec<MatchResult>,
1373) {
1374    // Check if this is an unguarded Index expression
1375    if let PureExpr::Index { expr: coll, index } = expr {
1376        if let Some(name) = expr_root_name(coll) {
1377            if unguarded.iter().any(|u| u == name) {
1378                results.push(MatchResult::matched());
1379            }
1380        }
1381        scan_unguarded_indexes_expr(coll, unguarded, results);
1382        scan_unguarded_indexes_expr(index, unguarded, results);
1383        return;
1384    }
1385
1386    match expr {
1387        PureExpr::Binary { left, right, .. } => {
1388            scan_unguarded_indexes_expr(left, unguarded, results);
1389            scan_unguarded_indexes_expr(right, unguarded, results);
1390        }
1391        PureExpr::Unary { expr, .. } | PureExpr::Try(expr) | PureExpr::Field { expr, .. } => {
1392            scan_unguarded_indexes_expr(expr, unguarded, results);
1393        }
1394        PureExpr::MethodCall { receiver, args, .. } => {
1395            scan_unguarded_indexes_expr(receiver, unguarded, results);
1396            for a in args {
1397                scan_unguarded_indexes_expr(a, unguarded, results);
1398            }
1399        }
1400        PureExpr::Call { func, args } => {
1401            scan_unguarded_indexes_expr(func, unguarded, results);
1402            for a in args {
1403                scan_unguarded_indexes_expr(a, unguarded, results);
1404            }
1405        }
1406        PureExpr::If {
1407            cond,
1408            then_branch,
1409            else_branch,
1410            ..
1411        } => {
1412            scan_unguarded_indexes_expr(cond, unguarded, results);
1413            scan_unguarded_indexes_block(then_branch, unguarded, results);
1414            if let Some(e) = else_branch {
1415                scan_unguarded_indexes_expr(e, unguarded, results);
1416            }
1417        }
1418        PureExpr::Block { block, .. } => {
1419            scan_unguarded_indexes_block(block, unguarded, results);
1420        }
1421        PureExpr::Match { expr: e, arms } => {
1422            scan_unguarded_indexes_expr(e, unguarded, results);
1423            for arm in arms {
1424                scan_unguarded_indexes_expr(&arm.body, unguarded, results);
1425            }
1426        }
1427        PureExpr::Loop { body, .. } => {
1428            scan_unguarded_indexes_block(body, unguarded, results);
1429        }
1430        PureExpr::While { cond, body, .. } => {
1431            scan_unguarded_indexes_expr(cond, unguarded, results);
1432            scan_unguarded_indexes_block(body, unguarded, results);
1433        }
1434        PureExpr::For {
1435            expr: iter, body, ..
1436        } => {
1437            scan_unguarded_indexes_expr(iter, unguarded, results);
1438            scan_unguarded_indexes_block(body, unguarded, results);
1439        }
1440        PureExpr::Closure { body, .. } => {
1441            scan_unguarded_indexes_expr(body, unguarded, results);
1442        }
1443        PureExpr::Tuple(items) | PureExpr::Array(items) => {
1444            for item in items {
1445                scan_unguarded_indexes_expr(item, unguarded, results);
1446            }
1447        }
1448        PureExpr::Let { expr, .. } | PureExpr::Return(Some(expr)) => {
1449            scan_unguarded_indexes_expr(expr, unguarded, results);
1450        }
1451        _ => {}
1452    }
1453}
1454
1455/// Collect names of collections that are directly indexed in a block.
1456fn collect_indexed_collections(block: &PureBlock, out: &mut Vec<String>) {
1457    for stmt in &block.stmts {
1458        let expr = match stmt {
1459            PureStmt::Expr(e) | PureStmt::Semi(e) => e,
1460            PureStmt::Local { init, .. } => {
1461                if let Some(init) = init {
1462                    collect_indexed_collections_in_expr(init, out);
1463                }
1464                continue;
1465            }
1466            _ => continue,
1467        };
1468        collect_indexed_collections_in_expr(expr, out);
1469    }
1470}
1471
1472fn collect_indexed_collections_in_expr(expr: &PureExpr, out: &mut Vec<String>) {
1473    match expr {
1474        PureExpr::Index { expr: coll, index } => {
1475            if let Some(name) = expr_root_name(coll) {
1476                if !out.contains(&name.to_string()) {
1477                    out.push(name.to_string());
1478                }
1479            }
1480            collect_indexed_collections_in_expr(coll, out);
1481            collect_indexed_collections_in_expr(index, out);
1482        }
1483        PureExpr::Binary { left, right, .. } => {
1484            collect_indexed_collections_in_expr(left, out);
1485            collect_indexed_collections_in_expr(right, out);
1486        }
1487        PureExpr::Unary { expr, .. } | PureExpr::Try(expr) | PureExpr::Return(Some(expr)) => {
1488            collect_indexed_collections_in_expr(expr, out);
1489        }
1490        PureExpr::Call { func, args } => {
1491            collect_indexed_collections_in_expr(func, out);
1492            for a in args {
1493                collect_indexed_collections_in_expr(a, out);
1494            }
1495        }
1496        PureExpr::MethodCall { receiver, args, .. } => {
1497            collect_indexed_collections_in_expr(receiver, out);
1498            for a in args {
1499                collect_indexed_collections_in_expr(a, out);
1500            }
1501        }
1502        PureExpr::Field { expr, .. } => {
1503            collect_indexed_collections_in_expr(expr, out);
1504        }
1505        PureExpr::If {
1506            cond,
1507            then_branch,
1508            else_branch,
1509            ..
1510        } => {
1511            collect_indexed_collections_in_expr(cond, out);
1512            collect_indexed_collections(then_branch, out);
1513            if let Some(e) = else_branch {
1514                collect_indexed_collections_in_expr(e, out);
1515            }
1516        }
1517        PureExpr::Block { block, .. } => {
1518            collect_indexed_collections(block, out);
1519        }
1520        PureExpr::Match { expr, arms } => {
1521            collect_indexed_collections_in_expr(expr, out);
1522            for arm in arms {
1523                collect_indexed_collections_in_expr(&arm.body, out);
1524            }
1525        }
1526        PureExpr::Loop { body, .. } | PureExpr::While { body, .. } | PureExpr::For { body, .. } => {
1527            collect_indexed_collections(body, out);
1528        }
1529        PureExpr::Closure { body, .. } => {
1530            collect_indexed_collections_in_expr(body, out);
1531        }
1532        PureExpr::Tuple(items) | PureExpr::Array(items) => {
1533            for item in items {
1534                collect_indexed_collections_in_expr(item, out);
1535            }
1536        }
1537        PureExpr::Let { expr, .. } => {
1538            collect_indexed_collections_in_expr(expr, out);
1539        }
1540        _ => {}
1541    }
1542}
1543
1544/// Collect `let var = collection.len()` aliases from a block.
1545fn collect_len_aliases(block: &PureBlock, aliases: &mut std::collections::HashMap<String, String>) {
1546    for stmt in &block.stmts {
1547        if let PureStmt::Local { pattern, init, .. } = stmt {
1548            if let (
1549                PurePattern::Ident { name: var_name, .. },
1550                Some(PureExpr::MethodCall {
1551                    receiver, method, ..
1552                }),
1553            ) = (pattern, init)
1554            {
1555                if matches!(method.as_str(), "len" | "find" | "rfind" | "position") {
1556                    if let Some(coll_name) = expr_root_name(receiver) {
1557                        aliases.insert(var_name.clone(), coll_name.to_string());
1558                    }
1559                }
1560            }
1561        }
1562        // Recurse into nested blocks
1563        if let Some(expr) = stmt.get_expr() {
1564            collect_len_aliases_in_expr(expr, aliases);
1565        }
1566    }
1567}
1568
1569fn collect_len_aliases_in_expr(
1570    expr: &PureExpr,
1571    aliases: &mut std::collections::HashMap<String, String>,
1572) {
1573    match expr {
1574        PureExpr::If {
1575            then_branch,
1576            else_branch,
1577            ..
1578        } => {
1579            collect_len_aliases(then_branch, aliases);
1580            if let Some(e) = else_branch {
1581                collect_len_aliases_in_expr(e, aliases);
1582            }
1583        }
1584        PureExpr::Block { block, .. } => collect_len_aliases(block, aliases),
1585        PureExpr::Loop { body, .. } | PureExpr::While { body, .. } | PureExpr::For { body, .. } => {
1586            collect_len_aliases(body, aliases);
1587        }
1588        PureExpr::Match { arms, .. } => {
1589            for arm in arms {
1590                collect_len_aliases_in_expr(&arm.body, aliases);
1591            }
1592        }
1593        PureExpr::Closure { body, .. } => collect_len_aliases_in_expr(body, aliases),
1594        _ => {}
1595    }
1596}
1597
1598/// Collect names of collections that have bounds checks in if/while/for conditions.
1599fn collect_len_checked_collections(
1600    block: &PureBlock,
1601    out: &mut Vec<String>,
1602    aliases: &std::collections::HashMap<String, String>,
1603) {
1604    for stmt in &block.stmts {
1605        let expr = match stmt {
1606            PureStmt::Expr(e) | PureStmt::Semi(e) => e,
1607            PureStmt::Local { init, .. } => {
1608                if let Some(init) = init {
1609                    collect_len_checked_in_expr(init, out, aliases);
1610                }
1611                continue;
1612            }
1613            _ => continue,
1614        };
1615        collect_len_checked_in_expr(expr, out, aliases);
1616    }
1617}
1618
1619fn collect_len_checked_in_expr(
1620    expr: &PureExpr,
1621    out: &mut Vec<String>,
1622    aliases: &std::collections::HashMap<String, String>,
1623) {
1624    match expr {
1625        PureExpr::If {
1626            cond,
1627            then_branch,
1628            else_branch,
1629            ..
1630        } => {
1631            collect_len_names_from_cond(cond, out, aliases);
1632            collect_len_checked_collections(then_branch, out, aliases);
1633            if let Some(e) = else_branch {
1634                collect_len_checked_in_expr(e, out, aliases);
1635            }
1636        }
1637        PureExpr::While { cond, body, .. } => {
1638            collect_len_names_from_cond(cond, out, aliases);
1639            collect_len_checked_collections(body, out, aliases);
1640        }
1641        PureExpr::For { expr, body, .. } => {
1642            // Extract bounds from range iterator: `for i in 0..collection.len()`
1643            if let PureExpr::Range { end: Some(end), .. } = expr.as_ref() {
1644                if let Some(name) = extract_bounds_receiver(end) {
1645                    if !out.contains(&name) {
1646                        out.push(name);
1647                    }
1648                }
1649                // Alias: `for i in 0..len` where `let len = x.len()`
1650                if let PureExpr::Path(var) = end.as_ref() {
1651                    if let Some(coll) = aliases.get(var.as_str()) {
1652                        if !out.contains(coll) {
1653                            out.push(coll.clone());
1654                        }
1655                    }
1656                }
1657            }
1658            collect_len_checked_collections(body, out, aliases);
1659        }
1660        PureExpr::Block { block, .. } => {
1661            collect_len_checked_collections(block, out, aliases);
1662        }
1663        PureExpr::Loop { body, .. } => {
1664            collect_len_checked_collections(body, out, aliases);
1665        }
1666        PureExpr::Match { expr, arms } => {
1667            // C: `match x.len() { N => x[i] }` — match on .len() implies bounds
1668            if let Some(name) = extract_bounds_receiver(expr) {
1669                if !out.contains(&name) {
1670                    out.push(name);
1671                }
1672            }
1673            // Also check len-alias: `match len { N => x[i] }` where `let len = x.len()`
1674            if let PureExpr::Path(var) = expr.as_ref() {
1675                if let Some(coll) = aliases.get(var.as_str()) {
1676                    if !out.contains(coll) {
1677                        out.push(coll.clone());
1678                    }
1679                }
1680            }
1681            // Check tuple elements: `match (x, y) { (Some(a), Some(b)) => ... }`
1682            // where x/y are find/position aliases
1683            if let PureExpr::Tuple(items) = expr.as_ref() {
1684                for item in items {
1685                    if let Some(name) = extract_bounds_receiver(item) {
1686                        if !out.contains(&name) {
1687                            out.push(name);
1688                        }
1689                    }
1690                    if let PureExpr::Path(var) = item {
1691                        if let Some(coll) = aliases.get(var.as_str()) {
1692                            if !out.contains(coll) {
1693                                out.push(coll.clone());
1694                            }
1695                        }
1696                    }
1697                }
1698            }
1699            for arm in arms {
1700                collect_len_checked_in_expr(&arm.body, out, aliases);
1701            }
1702        }
1703        PureExpr::Closure { body, .. } => {
1704            collect_len_checked_in_expr(body, out, aliases);
1705        }
1706        // Traverse into arbitrary expressions to find nested control flow
1707        PureExpr::MethodCall { receiver, args, .. } => {
1708            collect_len_checked_in_expr(receiver, out, aliases);
1709            for a in args {
1710                collect_len_checked_in_expr(a, out, aliases);
1711            }
1712        }
1713        PureExpr::Call { func, args } => {
1714            collect_len_checked_in_expr(func, out, aliases);
1715            for a in args {
1716                collect_len_checked_in_expr(a, out, aliases);
1717            }
1718        }
1719        PureExpr::Binary { left, right, .. } => {
1720            collect_len_checked_in_expr(left, out, aliases);
1721            collect_len_checked_in_expr(right, out, aliases);
1722        }
1723        PureExpr::Unary { expr, .. } | PureExpr::Try(expr) | PureExpr::Field { expr, .. } => {
1724            collect_len_checked_in_expr(expr, out, aliases);
1725        }
1726        PureExpr::Tuple(items) | PureExpr::Array(items) => {
1727            for item in items {
1728                collect_len_checked_in_expr(item, out, aliases);
1729            }
1730        }
1731        PureExpr::Index { expr, index } => {
1732            collect_len_checked_in_expr(expr, out, aliases);
1733            collect_len_checked_in_expr(index, out, aliases);
1734        }
1735        PureExpr::Let { expr, .. } | PureExpr::Return(Some(expr)) => {
1736            collect_len_checked_in_expr(expr, out, aliases);
1737        }
1738        _ => {}
1739    }
1740}
1741
1742/// Extract collection names from condition expressions.
1743/// Recognizes: `.len()` calls, `.is_empty()`, `.starts_with()/.ends_with()`,
1744/// `if let Some = .find()`, and len-aliased variables (`let n = x.len(); i < n`).
1745fn collect_len_names_from_cond(
1746    cond: &PureExpr,
1747    out: &mut Vec<String>,
1748    aliases: &std::collections::HashMap<String, String>,
1749) {
1750    match cond {
1751        PureExpr::Binary { op, left, right } => match op.as_str() {
1752            "<" | "<=" | ">" | ">=" | "==" | "!=" => {
1753                // Direct .len() call: `x.len() < n`
1754                for side in [left.as_ref(), right.as_ref()] {
1755                    if let Some(name) = extract_bounds_receiver(side) {
1756                        if !out.contains(&name) {
1757                            out.push(name);
1758                        }
1759                    }
1760                    // Len alias: `i < len` where `let len = x.len()`
1761                    if let PureExpr::Path(var) = side {
1762                        if let Some(coll) = aliases.get(var.as_str()) {
1763                            if !out.contains(coll) {
1764                                out.push(coll.clone());
1765                            }
1766                        }
1767                    }
1768                }
1769            }
1770            "&&" | "||" => {
1771                collect_len_names_from_cond(left, out, aliases);
1772                collect_len_names_from_cond(right, out, aliases);
1773            }
1774            _ => {}
1775        },
1776        PureExpr::Unary { op, expr } if op == "!" => {
1777            collect_len_names_from_cond(expr, out, aliases);
1778        }
1779        // Standalone method call: `x.is_empty()`, `x.starts_with()`, etc.
1780        PureExpr::MethodCall {
1781            receiver, method, ..
1782        } if is_bounds_implying_method(method) => {
1783            if let Some(name) = expr_root_name(receiver) {
1784                if !out.contains(&name.to_string()) {
1785                    out.push(name.to_string());
1786                }
1787            }
1788        }
1789        // `if let Some(idx) = x.find(...)` — find() returns valid index
1790        // Also handles combinator chains: x.find().or_else(|| x.find())
1791        PureExpr::Let { expr, .. } => {
1792            if let Some(name) = extract_bounds_receiver(expr) {
1793                if !out.contains(&name) {
1794                    out.push(name);
1795                }
1796            }
1797            // Alias: `if let Some(..) = variable` where `let variable = x.find(..)`
1798            if let PureExpr::Path(var) = expr.as_ref() {
1799                if let Some(coll) = aliases.get(var.as_str()) {
1800                    if !out.contains(coll) {
1801                        out.push(coll.clone());
1802                    }
1803                }
1804            }
1805        }
1806        _ => {}
1807    }
1808}
1809
1810/// Methods that imply bounds awareness on the receiver.
1811fn is_bounds_implying_method(method: &str) -> bool {
1812    matches!(
1813        method,
1814        "is_empty"
1815            | "len"
1816            | "starts_with"
1817            | "ends_with"
1818            | "contains"
1819            | "find"
1820            | "rfind"
1821            | "position"
1822            | "get"
1823            | "get_mut"
1824    )
1825}
1826
1827/// If expr is `something.len()`, `.is_empty()`, `.starts_with()`, etc., return root name.
1828fn extract_bounds_receiver(expr: &PureExpr) -> Option<String> {
1829    if let PureExpr::MethodCall {
1830        receiver, method, ..
1831    } = expr
1832    {
1833        if is_bounds_implying_method(method) {
1834            return expr_root_name(receiver).map(|s| s.to_string());
1835        }
1836        // Chain: x.len().saturating_sub(N) → extract from inner .len()
1837        if matches!(
1838            method.as_str(),
1839            "saturating_sub" | "checked_sub" | "wrapping_sub"
1840        ) {
1841            return extract_bounds_receiver(receiver);
1842        }
1843        // Combinator chain: x.find().or_else(), x.get().map(), etc.
1844        if matches!(
1845            method.as_str(),
1846            "or_else" | "and_then" | "map" | "unwrap_or" | "unwrap_or_else"
1847        ) {
1848            return extract_bounds_receiver(receiver);
1849        }
1850    }
1851    None
1852}
1853
1854/// Collect variables derived from `split().collect()` — guaranteed non-empty.
1855///
1856/// `str::split()` always returns at least 1 element, so `parts[0]` after
1857/// `let parts: Vec<&str> = s.split('/').collect()` is always safe.
1858fn collect_split_derived(block: &PureBlock, out: &mut Vec<String>) {
1859    for stmt in &block.stmts {
1860        if let PureStmt::Local { pattern, init, .. } = stmt {
1861            if let (PurePattern::Ident { name, .. }, Some(init_expr)) = (pattern, init) {
1862                if is_split_collect_chain(init_expr) && !out.contains(name) {
1863                    out.push(name.clone());
1864                }
1865            }
1866        }
1867    }
1868}
1869
1870/// Check if an expression is a `.split(...).collect()` chain (possibly with intermediate methods).
1871fn is_split_collect_chain(expr: &PureExpr) -> bool {
1872    if let PureExpr::MethodCall {
1873        receiver, method, ..
1874    } = expr
1875    {
1876        if method == "collect" {
1877            return has_split_in_chain(receiver);
1878        }
1879    }
1880    false
1881}
1882
1883fn has_split_in_chain(expr: &PureExpr) -> bool {
1884    if let PureExpr::MethodCall {
1885        receiver, method, ..
1886    } = expr
1887    {
1888        if matches!(
1889            method.as_str(),
1890            "split" | "splitn" | "split_whitespace" | "split_terminator" | "split_ascii_whitespace"
1891        ) {
1892            return true;
1893        }
1894        return has_split_in_chain(receiver);
1895    }
1896    false
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use ryo_pattern::{PatternQuery, Severity, SymbolKind as PatternKind};
1903
1904    fn create_test_rule() -> Rule {
1905        Rule::new(
1906            "TEST001",
1907            "test-rule",
1908            Severity::Warning,
1909            PatternQuery::new().kind(PatternKind::Function),
1910            "Test message",
1911        )
1912    }
1913
1914    #[test]
1915    fn test_pattern_based_suggest_basic() {
1916        let rule = create_test_rule();
1917        let suggest = PatternBasedSuggest::new(rule);
1918
1919        assert_eq!(suggest.name(), "TEST001"); // name() returns rule.id
1920        assert_eq!(suggest.description(), "test-rule");
1921        assert_eq!(suggest.category(), SuggestCategory::Lint);
1922        assert_eq!(suggest.safety_level(), SafetyLevel::Confirm);
1923    }
1924
1925    #[test]
1926    fn test_severity_conversion() {
1927        assert_eq!(
1928            PatternBasedSuggest::to_lint_severity(Severity::Error),
1929            LintSeverity::Error
1930        );
1931        assert_eq!(
1932            PatternBasedSuggest::to_lint_severity(Severity::Warning),
1933            LintSeverity::Warning
1934        );
1935        assert_eq!(
1936            PatternBasedSuggest::to_lint_severity(Severity::Info),
1937            LintSeverity::Info
1938        );
1939        assert_eq!(
1940            PatternBasedSuggest::to_lint_severity(Severity::Hint),
1941            LintSeverity::Info
1942        );
1943    }
1944
1945    #[test]
1946    fn test_safety_level_conversion() {
1947        assert_eq!(
1948            PatternBasedSuggest::to_safety_level(Severity::Error),
1949            SafetyLevel::Manual
1950        );
1951        assert_eq!(
1952            PatternBasedSuggest::to_safety_level(Severity::Warning),
1953            SafetyLevel::Confirm
1954        );
1955        assert_eq!(
1956            PatternBasedSuggest::to_safety_level(Severity::Info),
1957            SafetyLevel::Auto
1958        );
1959    }
1960
1961    #[test]
1962    fn test_glob_matching() {
1963        let suggest = PatternBasedSuggest::new(create_test_rule());
1964
1965        assert!(suggest.matches_glob("*", "anything"));
1966        assert!(suggest.matches_glob("test_*", "test_foo"));
1967        assert!(!suggest.matches_glob("test_*", "foo_test"));
1968        assert!(suggest.matches_glob("*_test", "foo_test"));
1969        assert!(!suggest.matches_glob("*_test", "test_foo"));
1970        assert!(suggest.matches_glob("exact", "exact"));
1971        assert!(!suggest.matches_glob("exact", "different"));
1972    }
1973
1974    #[test]
1975    fn test_mutation_spec_generation_rl001() {
1976        // Test RL001 (no-unwrap-in-public) generates UnwrapToQuestion
1977        let rule = Rule::new(
1978            "RL001",
1979            "no-unwrap-in-public",
1980            Severity::Warning,
1981            PatternQuery::new().kind(PatternKind::Function),
1982            "Avoid unwrap() in public function",
1983        );
1984        let suggest = PatternBasedSuggest::new(rule);
1985
1986        // Verify rule ID is correctly mapped
1987        assert_eq!(suggest.rule.id, "RL001");
1988
1989        // The generate_specs_for_rule would return UnwrapToQuestion for RL001
1990        // We verify the rule configuration matches our expectation
1991        assert!(suggest.rule.id.starts_with("RL001"));
1992    }
1993
1994    #[test]
1995    fn test_mutation_spec_generation_rl020() {
1996        // Test RL020 (no-dbg-macro) generates RemoveStatement
1997        let rule = Rule::new(
1998            "RL020",
1999            "no-dbg-macro",
2000            Severity::Warning,
2001            PatternQuery::new().kind(PatternKind::Function),
2002            "Found dbg!() macro",
2003        );
2004        let suggest = PatternBasedSuggest::new(rule);
2005
2006        assert_eq!(suggest.rule.id, "RL020");
2007    }
2008
2009    #[test]
2010    fn test_mutation_spec_generation_unknown_rule() {
2011        // Test unknown rule returns empty Vec
2012        let rule = Rule::new(
2013            "UNKNOWN",
2014            "unknown-rule",
2015            Severity::Info,
2016            PatternQuery::new(),
2017            "Unknown",
2018        );
2019        let suggest = PatternBasedSuggest::new(rule);
2020
2021        // Unknown rules should not have automatic fixes
2022        assert_eq!(suggest.rule.id, "UNKNOWN");
2023        // generate_specs_for_rule would return Ok(Vec::new()) for unknown rules
2024    }
2025
2026    #[test]
2027    fn test_priority_weight() {
2028        let error_rule = Rule::new(
2029            "E001",
2030            "error-rule",
2031            Severity::Error,
2032            PatternQuery::new(),
2033            "Error",
2034        );
2035        let warning_rule = Rule::new(
2036            "W001",
2037            "warning-rule",
2038            Severity::Warning,
2039            PatternQuery::new(),
2040            "Warning",
2041        );
2042        let info_rule = Rule::new(
2043            "I001",
2044            "info-rule",
2045            Severity::Info,
2046            PatternQuery::new(),
2047            "Info",
2048        );
2049
2050        assert_eq!(PatternBasedSuggest::new(error_rule).priority_weight(), 1.0);
2051        assert_eq!(
2052            PatternBasedSuggest::new(warning_rule).priority_weight(),
2053            0.8
2054        );
2055        assert_eq!(PatternBasedSuggest::new(info_rule).priority_weight(), 0.5);
2056    }
2057}