Skip to main content

ryo_executor/executor/registry/converters/
stmt.rs

1//! StmtConverter: Converts statement-level MutationSpec variants to Mutations
2//!
3//! Handles:
4//! - ReplaceExpr
5//! - RemoveStatement
6//! - InsertStatement
7//! - ReplaceStatement
8
9use crate::engine::ASTRegApply;
10use crate::executor::registry::converters::ResolveTargetSymbol;
11use crate::executor::registry::{ConvertError, MutationConverter};
12use crate::executor::spec::{MutationSpec, StmtInsertPosition};
13use ryo_analysis::AnalysisContext;
14use ryo_analysis::SymbolPath;
15use ryo_mutations::basic::stmt::{
16    InsertPosition, InsertStatementMutation, RemoveStatementMutation, ReplaceExprAtMutation,
17    ReplaceExprMutation, ReplaceStatementMutation,
18};
19use ryo_source::pure::{PureExpr, PureStmt, ToPure};
20
21/// Converter for statement-level mutations
22#[derive(Debug, Clone, Default)]
23pub struct StmtConverter;
24
25impl StmtConverter {
26    pub fn new() -> Self {
27        Self
28    }
29
30    /// Parse a Rust expression string into PureExpr (used for `old_expr` / match keys).
31    ///
32    /// Match keys go through full parse → `to_pure()` normalization so that
33    /// whitespace / token-spacing variants on the *match side* don't perturb
34    /// equality comparison.
35    pub fn parse_expr(expr_str: &str) -> Result<PureExpr, ConvertError> {
36        let expr: syn::Expr = syn::parse_str(expr_str).map_err(|e| {
37            ConvertError::Parse(format!("Failed to parse expression '{}': {}", expr_str, e))
38        })?;
39        Ok(expr.to_pure())
40    }
41
42    /// Parse a Rust statement string into PureStmt (used for `old_stmt` / `reference_pattern`).
43    ///
44    /// See `parse_expr` for the match-key normalization rationale.
45    pub fn parse_stmt(stmt_str: &str) -> Result<PureStmt, ConvertError> {
46        // Try parsing as a statement first
47        let stmt_with_semi = if stmt_str.ends_with(';') {
48            stmt_str.to_string()
49        } else {
50            format!("{};", stmt_str)
51        };
52
53        let stmt: syn::Stmt = syn::parse_str(&stmt_with_semi).map_err(|e| {
54            ConvertError::Parse(format!("Failed to parse statement '{}': {}", stmt_str, e))
55        })?;
56
57        // Use ToPure trait implementation for syn::Stmt
58        Ok(stmt.to_pure())
59    }
60
61    /// Build a verbatim **replacement** expression carrier from a raw source string.
62    ///
63    /// Used for `new_expr` (the *output* side of ReplaceExpr / ReplaceExprAt).
64    /// The string is still syntax-checked via `syn::parse_str` (so a malformed
65    /// expression is rejected up-front and the converter never emits garbage),
66    /// but the parsed AST is **discarded** — `PureExpr::Verbatim(raw)` preserves
67    /// the user-provided byte sequence 1:1 through `to_source`.
68    ///
69    /// This is the wire that connects the B-3 carrier (PureExpr::Verbatim) to
70    /// producer paths so that DSL-shaped replacements survive prettyplease.
71    pub fn build_verbatim_expr(expr_str: &str) -> Result<PureExpr, ConvertError> {
72        // Validate syntactic well-formedness; discard the parsed shape.
73        let _: syn::Expr = syn::parse_str(expr_str).map_err(|e| {
74            ConvertError::Parse(format!("Failed to parse expression '{}': {}", expr_str, e))
75        })?;
76        Ok(PureExpr::Verbatim(expr_str.to_string()))
77    }
78
79    /// Build a verbatim **replacement** statement carrier from a raw source string.
80    ///
81    /// Used for `new_stmt` (ReplaceStatement) and `stmt` (InsertStatement) —
82    /// the *output* side. See `build_verbatim_expr` for the parse-then-discard
83    /// rationale; this wraps in `PureStmt::Verbatim` (the B-3-cont carrier).
84    pub fn build_verbatim_stmt(stmt_str: &str) -> Result<PureStmt, ConvertError> {
85        let stmt_with_semi = if stmt_str.ends_with(';') {
86            stmt_str.to_string()
87        } else {
88            format!("{};", stmt_str)
89        };
90        let _: syn::Stmt = syn::parse_str(&stmt_with_semi).map_err(|e| {
91            ConvertError::Parse(format!("Failed to parse statement '{}': {}", stmt_str, e))
92        })?;
93        // The line-splice pass in `PureFile::to_source` replaces the **entire
94        // stub line** (including the synthetic trailing `;` emitted by the
95        // stub) with the raw bytes carried here. If the caller forgot the
96        // terminator, the post-splice line would be a bare expression — and
97        // a later round-trip through `syn::parse_file` would either drop or
98        // re-shape it. Carrying the normalized `stmt_with_semi` keeps the
99        // user's *interior* spacing 1:1 while guaranteeing a valid stmt line.
100        Ok(PureStmt::Verbatim(stmt_with_semi))
101    }
102
103    /// Build a verbatim **replacement** statement carrier where a missing
104    /// `;` terminator is honored as **tail-expression intent** instead of
105    /// being auto-completed.
106    ///
107    /// Used for `new_stmt` (ReplaceStatement) only. RL043 (needless-return)
108    /// replaces a trailing `return expr;` with the bare `expr` — with the
109    /// `build_verbatim_stmt` semi auto-completion the splice emits `expr;`,
110    /// the function loses its tail expression, and the post-mutation cargo
111    /// check fails with "mismatched types" (2026-06-12 bulk-lint sweep v1).
112    ///
113    /// Contract: a terminator-less string that parses as a bare expression
114    /// is carried verbatim (no `;`); anything else (e.g. `let x = 1`)
115    /// falls back to the normalizing `build_verbatim_stmt` path, keeping
116    /// existing semi-completion behavior for statement-shaped input.
117    /// Mid-block placement of a non-unit bare expression is caught by the
118    /// post-mutation cargo check (caller responsibility, same as any other
119    /// type-changing replacement).
120    pub fn build_verbatim_replacement_stmt(stmt_str: &str) -> Result<PureStmt, ConvertError> {
121        let trimmed = stmt_str.trim_end();
122        if !trimmed.ends_with(';') {
123            match syn::parse_str::<syn::Expr>(trimmed) {
124                // `let pat = init` parses as syn::Expr::Let (let-chain
125                // grammar leniency) but is statement-shaped input — keep
126                // it on the semi-completion path.
127                Ok(expr) if !matches!(expr, syn::Expr::Let(_)) => {
128                    return Ok(PureStmt::Verbatim(trimmed.to_string()));
129                }
130                _ => {}
131            }
132        }
133        Self::build_verbatim_stmt(stmt_str)
134    }
135
136    /// Convert StmtInsertPosition to internal InsertPosition
137    fn convert_position(pos: &StmtInsertPosition) -> InsertPosition {
138        match pos {
139            StmtInsertPosition::Start => InsertPosition::Start,
140            StmtInsertPosition::End => InsertPosition::End,
141            StmtInsertPosition::BeforePattern => InsertPosition::BeforePattern,
142            StmtInsertPosition::AfterPattern => InsertPosition::AfterPattern,
143        }
144    }
145}
146
147// StmtConverter uses the default implementation of ResolveTargetSymbol
148impl ResolveTargetSymbol for StmtConverter {}
149
150impl MutationConverter for StmtConverter {
151    fn spec_kinds(&self) -> &'static [&'static str] {
152        &[
153            "ReplaceExpr",
154            "RemoveStatement",
155            "InsertStatement",
156            "ReplaceStatement",
157        ]
158    }
159
160    fn convert_v2(
161        &self,
162        spec: &MutationSpec,
163        ctx: &AnalysisContext,
164    ) -> Result<Vec<Box<dyn ASTRegApply>>, ConvertError> {
165        match spec {
166            MutationSpec::ReplaceExpr {
167                fn_id,
168                old_expr,
169                new_expr,
170                replace_all,
171                symbol_path,
172                ..
173            } => {
174                // B-3 carrier wire-up: preserve user-provided bytes for the
175                // *output* expression. `old_expr` still goes through
176                // parse-normalize because it's a match key.
177                let new = Self::build_verbatim_expr(new_expr)?;
178
179                // If symbol_path is provided, use position-based replacement
180                if let Some(path_str) = symbol_path {
181                    let path = SymbolPath::parse(path_str).map_err(|e| {
182                        ConvertError::Parse(format!("Invalid symbol_path '{}': {}", path_str, e))
183                    })?;
184
185                    if !path.has_body_segment() {
186                        return Err(ConvertError::Parse(format!(
187                            "symbol_path '{}' must contain $body segment for position-based replacement",
188                            path_str
189                        )));
190                    }
191
192                    let (fn_path, indices) = path.split_at_body().ok_or_else(|| {
193                        ConvertError::Parse(format!(
194                            "Failed to extract function path from symbol_path '{}'",
195                            path_str
196                        ))
197                    })?;
198
199                    // Lookup SymbolId from function path
200                    let fn_id = ctx.registry.lookup(&fn_path).ok_or_else(|| {
201                        ConvertError::TargetNotFound(format!(
202                            "Function '{}' not found in registry",
203                            fn_path
204                        ))
205                    })?;
206
207                    let mutation = ReplaceExprAtMutation::new(fn_id, indices, new);
208                    return Ok(vec![Box::new(mutation)]);
209                }
210
211                let old = Self::parse_expr(old_expr)?;
212
213                // If fn_id is None, generate mutations for all functions
214                let mutations: Vec<Box<dyn ASTRegApply>> = if let Some(id) = fn_id {
215                    let mut mutation = ReplaceExprMutation::new(old.clone(), new.clone(), *id);
216                    mutation.replace_all = *replace_all;
217                    vec![Box::new(mutation)]
218                } else {
219                    use ryo_analysis::SymbolKind;
220                    ctx.registry
221                        .iter()
222                        .filter(|(id, _)| {
223                            matches!(ctx.registry.kind(*id), Some(SymbolKind::Function))
224                        })
225                        .map(|(id, _)| {
226                            let mut mutation =
227                                ReplaceExprMutation::new(old.clone(), new.clone(), id);
228                            mutation.replace_all = *replace_all;
229                            Box::new(mutation) as Box<dyn ASTRegApply>
230                        })
231                        .collect()
232                };
233
234                Ok(mutations)
235            }
236
237            MutationSpec::RemoveStatement {
238                fn_id,
239                pattern,
240                remove_all,
241                ..
242            } => {
243                let target_stmt = Self::parse_stmt(pattern)?;
244
245                // If fn_id is None, generate mutations for all functions
246                let mutations: Vec<Box<dyn ASTRegApply>> = if let Some(id) = fn_id {
247                    let mut mutation =
248                        RemoveStatementMutation::new(target_stmt.clone(), pattern.clone(), *id);
249                    mutation.remove_all = *remove_all;
250                    vec![Box::new(mutation)]
251                } else {
252                    use ryo_analysis::SymbolKind;
253                    ctx.registry
254                        .iter()
255                        .filter(|(id, _)| {
256                            matches!(ctx.registry.kind(*id), Some(SymbolKind::Function))
257                        })
258                        .map(|(id, _)| {
259                            let mut mutation = RemoveStatementMutation::new(
260                                target_stmt.clone(),
261                                pattern.clone(),
262                                id,
263                            );
264                            mutation.remove_all = *remove_all;
265                            Box::new(mutation) as Box<dyn ASTRegApply>
266                        })
267                        .collect()
268                };
269
270                Ok(mutations)
271            }
272
273            MutationSpec::InsertStatement {
274                fn_id,
275                stmt,
276                position,
277                reference_pattern,
278                ..
279            } => {
280                // B-3-cont carrier wire-up: preserve user-provided bytes for
281                // the *inserted* statement. `reference_pattern` stays parsed
282                // because it's used as a structural match key.
283                let pure_stmt = Self::build_verbatim_stmt(stmt)?;
284                let reference_stmt = reference_pattern
285                    .as_ref()
286                    .map(|p| Self::parse_stmt(p))
287                    .transpose()?;
288
289                let mut mutation = InsertStatementMutation::new(pure_stmt, *fn_id);
290                mutation.position = Self::convert_position(position);
291                mutation.reference_stmt = reference_stmt;
292
293                Ok(vec![Box::new(mutation)])
294            }
295
296            MutationSpec::ReplaceStatement {
297                fn_id,
298                old_stmt,
299                new_stmt,
300                ..
301            } => {
302                let old = Self::parse_stmt(old_stmt)?;
303                // B-3-cont carrier wire-up for the *output* statement.
304                // Terminator-less expression input is honored as
305                // tail-expression intent (RL043 `return x;` → `x`).
306                let new = Self::build_verbatim_replacement_stmt(new_stmt)?;
307
308                // If fn_id is None, generate mutations for all functions
309                let mutations: Vec<Box<dyn ASTRegApply>> = if let Some(id) = fn_id {
310                    vec![Box::new(ReplaceStatementMutation::new(
311                        old.clone(),
312                        new.clone(),
313                        *id,
314                    ))]
315                } else {
316                    use ryo_analysis::SymbolKind;
317                    ctx.registry
318                        .iter()
319                        .filter(|(id, _)| {
320                            matches!(ctx.registry.kind(*id), Some(SymbolKind::Function))
321                        })
322                        .map(|(id, _)| {
323                            Box::new(ReplaceStatementMutation::new(old.clone(), new.clone(), id))
324                                as Box<dyn ASTRegApply>
325                        })
326                        .collect()
327                };
328
329                Ok(mutations)
330            }
331
332            _ => Err(ConvertError::TypeMismatch {
333                expected: "ReplaceExpr|RemoveStatement|InsertStatement|ReplaceStatement",
334                actual: spec.kind_name().to_string(),
335            }),
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use ryo_symbol::SymbolId;
344
345    #[test]
346    fn test_stmt_converter_spec_kinds() {
347        let converter = StmtConverter::new();
348        let kinds = converter.spec_kinds();
349        assert!(kinds.contains(&"ReplaceExpr"));
350        assert!(kinds.contains(&"RemoveStatement"));
351        assert!(kinds.contains(&"InsertStatement"));
352        assert!(kinds.contains(&"ReplaceStatement"));
353    }
354
355    #[test]
356    fn test_stmt_converter_can_handle() {
357        let converter = StmtConverter::new();
358
359        let replace_expr_spec = MutationSpec::ReplaceExpr {
360            module_id: None,
361            fn_id: None,
362            old_expr: "a + b".into(),
363            new_expr: "c + d".into(),
364            replace_all: true,
365            symbol_path: None,
366        };
367        assert!(converter.can_handle(&replace_expr_spec));
368
369        let remove_stmt_spec = MutationSpec::RemoveStatement {
370            module_id: None,
371            fn_id: None,
372            pattern: "println!".into(),
373            remove_all: true,
374            symbol_path: None,
375        };
376        assert!(converter.can_handle(&remove_stmt_spec));
377
378        let insert_stmt_spec = MutationSpec::InsertStatement {
379            module_id: None,
380            fn_id: SymbolId::default(),
381            stmt: "let x = 1".into(),
382            position: StmtInsertPosition::Start,
383            reference_pattern: None,
384            symbol_path: None,
385        };
386        assert!(converter.can_handle(&insert_stmt_spec));
387
388        let replace_stmt_spec = MutationSpec::ReplaceStatement {
389            module_id: None,
390            fn_id: None,
391            old_stmt: "let x = 1".into(),
392            new_stmt: "let x = 2".into(),
393            symbol_path: None,
394        };
395        assert!(converter.can_handle(&replace_stmt_spec));
396    }
397
398    // -------------------------------------------------------------------------
399    // A-patch: B-3 / B-3-cont carrier wire-up — `new_expr` / `new_stmt` / `stmt`
400    // are emitted as Verbatim, preserving user-provided byte sequences so that
401    // DSL-shaped replacements survive prettyplease.
402    // -------------------------------------------------------------------------
403
404    #[test]
405    fn build_verbatim_expr_preserves_raw_bytes_with_unusual_spacing() {
406        // prettyplease would normally collapse the doubled spaces and the
407        // unusual macro spacing — Verbatim must pass them through 1:1.
408        let raw = "html ! { < div   id = \"x\" > { value } < / div > }";
409        match StmtConverter::build_verbatim_expr(raw).unwrap() {
410            PureExpr::Verbatim(s) => assert_eq!(s, raw),
411            other => panic!("expected PureExpr::Verbatim, got {:?}", other),
412        }
413    }
414
415    #[test]
416    fn build_verbatim_stmt_appends_trailing_semicolon_if_missing() {
417        // The line-splice pass replaces the whole stub line (which includes
418        // a synthetic `;`), so the carrier MUST itself terminate the stmt.
419        match StmtConverter::build_verbatim_stmt("let x = 1").unwrap() {
420            PureStmt::Verbatim(s) => assert_eq!(s, "let x = 1;"),
421            other => panic!("expected PureStmt::Verbatim, got {:?}", other),
422        }
423    }
424
425    #[test]
426    fn build_verbatim_stmt_preserves_user_terminator_and_interior_spacing() {
427        // Interior raw spacing is preserved; the existing `;` is not doubled.
428        let raw = "let  x   =   foo ! { a , b } ;";
429        match StmtConverter::build_verbatim_stmt(raw).unwrap() {
430            PureStmt::Verbatim(s) => assert_eq!(s, raw),
431            other => panic!("expected PureStmt::Verbatim, got {:?}", other),
432        }
433    }
434
435    #[test]
436    fn build_verbatim_expr_rejects_syntactically_invalid_input() {
437        // Malformed inputs must be caught at the converter boundary so the
438        // raw stub never reaches `PureFile::to_source`.
439        assert!(StmtConverter::build_verbatim_expr("let x = ;").is_err());
440    }
441
442    #[test]
443    fn build_verbatim_stmt_rejects_syntactically_invalid_input() {
444        assert!(StmtConverter::build_verbatim_stmt("@@@ nonsense @@@").is_err());
445    }
446
447    // -------------------------------------------------------------------------
448    // RL043 tail-expression replacement pins (2026-06-12 bulk-lint sweep)
449    // -------------------------------------------------------------------------
450
451    #[test]
452    fn build_verbatim_replacement_stmt_honors_tail_expr_intent() {
453        // Terminator-less bare expression is carried verbatim WITHOUT a
454        // synthetic `;`, so the replaced statement can become the block's
455        // tail expression (RL043 `return x;` → `x`). With semi
456        // auto-completion the splice emitted `x;` and the post-mutation
457        // cargo check failed with "mismatched types".
458        match StmtConverter::build_verbatim_replacement_stmt("sum").unwrap() {
459            PureStmt::Verbatim(s) => assert_eq!(s, "sum"),
460            other => panic!("expected PureStmt::Verbatim, got {:?}", other),
461        }
462    }
463
464    #[test]
465    fn build_verbatim_replacement_stmt_keeps_semi_completion_for_let() {
466        // `let pat = init` parses as syn::Expr::Let (let-chain grammar
467        // leniency) but is statement-shaped input — it must stay on the
468        // normalizing semi-completion path (regression pin for
469        // v2_replace_statement_basic).
470        match StmtConverter::build_verbatim_replacement_stmt("let new_value = 20").unwrap() {
471            PureStmt::Verbatim(s) => assert_eq!(s, "let new_value = 20;"),
472            other => panic!("expected PureStmt::Verbatim, got {:?}", other),
473        }
474    }
475
476    #[test]
477    fn build_verbatim_replacement_stmt_passes_explicit_terminator_through() {
478        // An explicit `;` is statement intent — unchanged passthrough.
479        match StmtConverter::build_verbatim_replacement_stmt("foo();").unwrap() {
480            PureStmt::Verbatim(s) => assert_eq!(s, "foo();"),
481            other => panic!("expected PureStmt::Verbatim, got {:?}", other),
482        }
483    }
484}