Skip to main content

ryo_pattern/
rule.rs

1//! Rule and MatchResult types
2//!
3//! Lint rule definition and pattern matching results.
4
5use crate::{BodyMatch, CodePattern, Relations};
6use ryo_symbol::SymbolId;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Lint rule definition with optional fix
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
13pub struct Rule {
14    /// Unique identifier (e.g., "RL001")
15    pub id: String,
16
17    /// Human-readable name
18    pub name: String,
19
20    /// Severity level
21    pub severity: Severity,
22
23    /// Category for grouping
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub category: Option<String>,
26
27    /// Detection query
28    pub query: PatternQuery,
29
30    /// Error message (supports variable interpolation)
31    pub message: String,
32
33    /// Suggestion text (human-readable hint, not executable)
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub suggestion: Option<String>,
36
37    /// Target code scopes where this rule applies.
38    ///
39    /// When non-empty, the rule only fires on symbols within the specified scopes.
40    /// Valid values: "lib", "bin", "test".
41    /// When empty (default), the rule applies to all scopes.
42    ///
43    /// ```yaml
44    /// scope: [lib]           # Library code only
45    /// scope: [lib, bin]      # Production code (lib + bin)
46    /// ```
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub scope: Vec<String>,
49
50    /// Fix specification (Intent or MutationSpec as JSON)
51    ///
52    /// Default format is Intent (public DSL):
53    /// ```yaml
54    /// fix:
55    ///   type: UnwrapToQuestion
56    ///   target_fn: "$MATCH"
57    /// ```
58    ///
59    /// For builtin rules, MutationSpec can be used with `_kind: mutation_spec`:
60    /// ```yaml
61    /// fix:
62    ///   _kind: mutation_spec
63    ///   type: UnwrapToQuestion
64    ///   target: ...
65    /// ```
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub fix: Option<serde_json::Value>,
68}
69
70impl Rule {
71    /// Create a new rule
72    pub fn new(
73        id: impl Into<String>,
74        name: impl Into<String>,
75        severity: Severity,
76        query: PatternQuery,
77        message: impl Into<String>,
78    ) -> Self {
79        Self {
80            id: id.into(),
81            name: name.into(),
82            severity,
83            category: None,
84            query,
85            message: message.into(),
86            suggestion: None,
87            scope: Vec::new(),
88            fix: None,
89        }
90    }
91
92    /// Set category
93    pub fn with_category(mut self, category: impl Into<String>) -> Self {
94        self.category = Some(category.into());
95        self
96    }
97
98    /// Set suggestion
99    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
100        self.suggestion = Some(suggestion.into());
101        self
102    }
103
104    /// Set fix specification (as JSON Value)
105    pub fn with_fix(mut self, fix: serde_json::Value) -> Self {
106        self.fix = Some(fix);
107        self
108    }
109}
110
111/// Query for pattern matching (extends RyoQL Query concept)
112#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
113pub struct PatternQuery {
114    /// Symbol kind to match (Function, Struct, etc.)
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub kind: Option<SymbolKind>,
117
118    /// Attribute matching conditions
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub r#match: Option<MatchAttrs>,
121
122    /// Body pattern matching
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub body: Option<BodyMatch>,
125
126    /// Relation conditions
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub relations: Option<Relations>,
129
130    /// Direct code pattern (for CodePattern-only queries)
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub pattern: Option<CodePattern>,
133}
134
135impl PatternQuery {
136    /// Construct an empty `PatternQuery`.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Set symbol kind
142    pub fn kind(mut self, kind: SymbolKind) -> Self {
143        self.kind = Some(kind);
144        self
145    }
146
147    /// Set match attributes
148    pub fn with_match(mut self, attrs: MatchAttrs) -> Self {
149        self.r#match = Some(attrs);
150        self
151    }
152
153    /// Set body match
154    pub fn with_body(mut self, body: BodyMatch) -> Self {
155        self.body = Some(body);
156        self
157    }
158
159    /// Set relations
160    pub fn with_relations(mut self, relations: Relations) -> Self {
161        self.relations = Some(relations);
162        self
163    }
164
165    /// Set direct pattern
166    pub fn with_pattern(mut self, pattern: CodePattern) -> Self {
167        self.pattern = Some(pattern);
168        self
169    }
170}
171
172/// Symbol kind for query filtering
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
174pub enum SymbolKind {
175    /// `fn` item.
176    Function,
177    /// `struct` item.
178    Struct,
179    /// `enum` item.
180    Enum,
181    /// `trait` item.
182    Trait,
183    /// `impl` block.
184    Impl,
185    /// `mod` item.
186    Mod,
187    /// `const` item.
188    Const,
189    /// `static` item.
190    Static,
191    /// `type` alias.
192    TypeAlias,
193    /// Struct / enum field.
194    Field,
195    /// Enum variant.
196    Variant,
197    /// Match CodePattern directly
198    CodePattern,
199}
200
201/// Match attributes for symbol filtering
202#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
203pub struct MatchAttrs {
204    /// Name pattern
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub name: Option<String>,
207
208    /// Glob pattern for name
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub pattern: Option<String>,
211
212    /// Visibility
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub vis: Option<Visibility>,
215
216    /// Trait name (for Impl)
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub trait_name: Option<String>,
219
220    /// Required attributes
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub attributes: Option<Vec<String>>,
223}
224
225impl MatchAttrs {
226    /// Construct an empty `MatchAttrs`.
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Set the symbol name.
232    pub fn name(mut self, name: impl Into<String>) -> Self {
233        self.name = Some(name.into());
234        self
235    }
236
237    /// Set the name glob pattern.
238    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
239        self.pattern = Some(pattern.into());
240        self
241    }
242
243    /// Set the visibility filter.
244    pub fn vis(mut self, vis: Visibility) -> Self {
245        self.vis = Some(vis);
246        self
247    }
248}
249
250/// Visibility level
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
252pub enum Visibility {
253    /// `pub`
254    Public,
255    /// `pub(crate)`
256    Crate,
257    /// `pub(super)`
258    Super,
259    /// 非公開 (modifier 無し)。
260    Private,
261}
262
263/// Severity level
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265pub enum Severity {
266    /// Must fix - blocks compilation or causes runtime errors
267    Error,
268    /// Should fix - code smell or potential issue
269    Warning,
270    /// Consider - style or convention suggestion
271    Info,
272    /// FYI - informational note
273    Hint,
274}
275
276impl std::fmt::Display for Severity {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            Severity::Error => write!(f, "error"),
280            Severity::Warning => write!(f, "warning"),
281            Severity::Info => write!(f, "info"),
282            Severity::Hint => write!(f, "hint"),
283        }
284    }
285}
286
287/// Result of pattern matching
288#[derive(Debug, Clone)]
289pub struct MatchResult {
290    /// Whether the pattern matched
291    pub matched: bool,
292
293    /// Rule that matched (if from Rule evaluation)
294    pub rule_id: Option<String>,
295
296    /// Severity of the match
297    pub severity: Option<Severity>,
298
299    /// Interpolated message
300    pub message: Option<String>,
301
302    /// Suggestion text
303    pub suggestion: Option<String>,
304
305    /// Captured nodes (available for downstream mutation systems)
306    pub captures: HashMap<String, CapturedNode>,
307}
308
309impl MatchResult {
310    /// Create a non-matching result
311    pub fn no_match() -> Self {
312        Self {
313            matched: false,
314            rule_id: None,
315            severity: None,
316            message: None,
317            suggestion: None,
318            captures: HashMap::new(),
319        }
320    }
321
322    /// Create a matching result
323    pub fn matched() -> Self {
324        Self {
325            matched: true,
326            rule_id: None,
327            severity: None,
328            message: None,
329            suggestion: None,
330            captures: HashMap::new(),
331        }
332    }
333
334    /// Set rule info
335    pub fn with_rule(mut self, rule: &Rule) -> Self {
336        self.rule_id = Some(rule.id.clone());
337        self.severity = Some(rule.severity);
338        self.message = Some(rule.message.clone());
339        self.suggestion = rule.suggestion.clone();
340        self
341    }
342
343    /// Add a capture
344    pub fn capture(mut self, var: impl Into<String>, node: CapturedNode) -> Self {
345        self.captures.insert(var.into(), node);
346        self
347    }
348}
349
350/// A captured AST node from pattern matching
351#[derive(Debug, Clone)]
352pub struct CapturedNode {
353    /// Symbol ID if the node corresponds to a known symbol
354    pub symbol_id: Option<SymbolId>,
355
356    /// Source location
357    pub span: Span,
358
359    /// Original source text
360    pub text: String,
361}
362
363impl CapturedNode {
364    /// Construct a `CapturedNode` from a span and source text.
365    pub fn new(span: Span, text: impl Into<String>) -> Self {
366        Self {
367            symbol_id: None,
368            span,
369            text: text.into(),
370        }
371    }
372
373    /// Attach a `SymbolId` to the captured node.
374    pub fn with_symbol(mut self, id: SymbolId) -> Self {
375        self.symbol_id = Some(id);
376        self
377    }
378}
379
380/// Source span (line/column based)
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub struct Span {
383    /// Inclusive start position.
384    pub start: Position,
385    /// Exclusive end position.
386    pub end: Position,
387}
388
389impl Span {
390    /// Construct a span from start / end positions.
391    pub fn new(start: Position, end: Position) -> Self {
392        Self { start, end }
393    }
394
395    /// Construct a zero-width span at `(line, column)`.
396    pub fn point(line: u32, column: u32) -> Self {
397        let pos = Position { line, column };
398        Self {
399            start: pos,
400            end: pos,
401        }
402    }
403}
404
405/// Position in source code
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct Position {
408    /// 1-based line number.
409    pub line: u32,
410    /// 0-based column.
411    pub column: u32,
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::NodeKind;
418
419    #[test]
420    fn test_rule_builder() {
421        let query = PatternQuery::new().kind(SymbolKind::Function).with_match(
422            MatchAttrs::new()
423                .vis(Visibility::Public)
424                .pattern("process_*"),
425        );
426
427        let rule = Rule::new(
428            "RL001",
429            "no-unwrap",
430            Severity::Warning,
431            query,
432            "Avoid unwrap()",
433        )
434        .with_category("error-handling")
435        .with_suggestion("Use ? operator instead");
436
437        assert_eq!(rule.id, "RL001");
438        assert_eq!(rule.severity, Severity::Warning);
439        assert!(rule.category.is_some());
440        assert!(rule.suggestion.is_some());
441    }
442
443    #[test]
444    fn test_match_result() {
445        let result = MatchResult::matched().capture(
446            "$UNWRAP",
447            CapturedNode::new(Span::point(42, 10), "result.unwrap()"),
448        );
449
450        assert!(result.matched);
451        assert!(result.captures.contains_key("$UNWRAP"));
452    }
453
454    #[test]
455    fn test_pattern_query_with_body() {
456        let query = PatternQuery::new()
457            .kind(SymbolKind::Function)
458            .with_body(BodyMatch::new().contains(CodePattern::new(NodeKind::MethodCall)));
459
460        assert!(query.kind.is_some());
461        assert!(query.body.is_some());
462    }
463}