Skip to main content

stryke/
convert.rs

1//! Convert standard Perl source to idiomatic stryke syntax.
2//!
3//! Transformations applied:
4//! - Nested function/builtin calls → `|>` pipe-forward chains
5//! - `map { BLOCK } LIST` → `LIST |> map { BLOCK }`
6//! - `grep { BLOCK } LIST` → `LIST |> grep { BLOCK }`
7//! - `sort [{ CMP }] LIST` → `LIST |> sort [{ CMP }]`
8//! - `join(SEP, LIST)` → `LIST |> join SEP`
9//! - No trailing semicolons (newline terminates statements)
10//! - 4-space indentation for block bodies
11//! - `#!/usr/bin/env stryke` shebang prepended
12//! - Pipe RHS uses bare args: `|> binmode ":utf8"` not `|> binmode(":utf8")`
13
14#![allow(unused_variables)]
15
16use crate::ast::*;
17use crate::fmt;
18use std::cell::RefCell;
19
20const INDENT: &str = "    ";
21
22thread_local! {
23    static OUTPUT_DELIM: RefCell<Option<char>> = const { RefCell::new(None) };
24    static VAL_VAR_DECLS: RefCell<bool> = const { RefCell::new(false) };
25}
26
27/// Options for the convert module.
28#[derive(Debug, Clone, Default)]
29pub struct ConvertOptions {
30    /// Custom delimiter for s///, tr///, m// patterns (e.g., '|', '#', '!').
31    pub output_delim: Option<char>,
32    /// Render `My` declarations with `val`/`var` keywords instead of `my`:
33    /// an all-frozen group becomes `val` (immutable), otherwise `var`. Used by
34    /// the zsh→stryke transpiler, which never emits bare `my` (style rule 10).
35    pub val_var_decls: bool,
36}
37
38fn get_output_delim() -> Option<char> {
39    OUTPUT_DELIM.with(|d| *d.borrow())
40}
41
42fn set_output_delim(delim: Option<char>) {
43    OUTPUT_DELIM.with(|d| *d.borrow_mut() = delim);
44}
45
46fn val_var_decls_enabled() -> bool {
47    VAL_VAR_DECLS.with(|d| *d.borrow())
48}
49
50fn set_val_var_decls(on: bool) {
51    VAL_VAR_DECLS.with(|d| *d.borrow_mut() = on);
52}
53
54/// Keyword for a `My` declaration group. Default `my`; in val/var mode an
55/// all-frozen group is `val` (immutable), otherwise `var` (mutable).
56fn decl_keyword(decls: &[VarDecl]) -> &'static str {
57    if !val_var_decls_enabled() {
58        "my"
59    } else if !decls.is_empty() && decls.iter().all(|d| d.frozen) {
60        "val"
61    } else {
62        "var"
63    }
64}
65
66/// Choose the output delimiter: custom if set, else the original from the AST.
67fn choose_delim(original: char) -> char {
68    get_output_delim().unwrap_or(original)
69}
70
71// ── Public API ──────────────────────────────────────────────────────────────
72
73/// Convert a parsed Perl program to stryke syntax.
74pub fn convert_program(p: &Program) -> String {
75    convert_program_with_options(p, &ConvertOptions::default())
76}
77
78/// Convert a parsed Perl program to stryke syntax with custom options.
79pub fn convert_program_with_options(p: &Program, opts: &ConvertOptions) -> String {
80    set_output_delim(opts.output_delim);
81    set_val_var_decls(opts.val_var_decls);
82    let body = convert_statements(&p.statements, 0);
83    set_output_delim(None);
84    set_val_var_decls(false);
85    format!("#!/usr/bin/env stryke\n{}", body)
86}
87
88// ── Block / Statement ───────────────────────────────────────────────────────
89
90fn convert_block(b: &Block, depth: usize) -> String {
91    convert_statements(b, depth)
92}
93
94/// Convert a slice of statements, merging bare say/print with following string literals.
95fn convert_statements(stmts: &[Statement], depth: usize) -> String {
96    let mut out = Vec::new();
97    let mut i = 0;
98    while i < stmts.len() {
99        // Check for bare say/print followed by string literal
100        if let Some(merged) = try_merge_say_print(&stmts[i..], depth) {
101            out.push(merged);
102            i += 2; // skip both statements
103        } else {
104            out.push(convert_statement(&stmts[i], depth));
105            i += 1;
106        }
107    }
108    out.join("\n")
109}
110
111/// Try to merge a bare say/print statement with a following string literal.
112/// Returns Some(merged_string) if merge happened, None otherwise.
113fn try_merge_say_print(stmts: &[Statement], depth: usize) -> Option<String> {
114    if stmts.len() < 2 {
115        return None;
116    }
117    let pfx = indent(depth);
118
119    // First statement must be bare say or print (no args, no handle)
120    let (is_say, handle) = match &stmts[0].kind {
121        StmtKind::Expression(e) => match &e.kind {
122            ExprKind::Say { handle, args } if args.is_empty() => (true, handle),
123            ExprKind::Print { handle, args } if args.is_empty() => (false, handle),
124            _ => return None,
125        },
126        _ => return None,
127    };
128
129    // No handle allowed for merge
130    if handle.is_some() {
131        return None;
132    }
133
134    // Second statement must be a bare string expression
135    let str_expr = match &stmts[1].kind {
136        StmtKind::Expression(e) => e,
137        _ => return None,
138    };
139
140    // Format as: p "string" or print "string"
141    let cmd = if is_say { "p" } else { "print" };
142    let arg = convert_expr_top(str_expr);
143    Some(format!("{}{} {}", pfx, cmd, arg))
144}
145
146/// Indent a string by `depth` levels of 4 spaces.
147fn indent(depth: usize) -> String {
148    INDENT.repeat(depth)
149}
150
151fn convert_statement(s: &Statement, depth: usize) -> String {
152    let lab = s
153        .label
154        .as_ref()
155        .map(|l| format!("{}: ", l))
156        .unwrap_or_default();
157    let pfx = indent(depth);
158    let body = match &s.kind {
159        StmtKind::Expression(e) => convert_expr_top(e),
160        StmtKind::If {
161            condition,
162            body,
163            elsifs,
164            else_block,
165        } => {
166            let mut s = format!(
167                "if ({}) {{\n{}\n{}}}",
168                convert_expr_top(condition),
169                convert_block(body, depth + 1),
170                pfx
171            );
172            for (c, b) in elsifs {
173                s.push_str(&format!(
174                    " elsif ({}) {{\n{}\n{}}}",
175                    convert_expr_top(c),
176                    convert_block(b, depth + 1),
177                    pfx
178                ));
179            }
180            if let Some(eb) = else_block {
181                s.push_str(&format!(
182                    " else {{\n{}\n{}}}",
183                    convert_block(eb, depth + 1),
184                    pfx
185                ));
186            }
187            s
188        }
189        StmtKind::Unless {
190            condition,
191            body,
192            else_block,
193        } => {
194            let mut s = format!(
195                "unless ({}) {{\n{}\n{}}}",
196                convert_expr_top(condition),
197                convert_block(body, depth + 1),
198                pfx
199            );
200            if let Some(eb) = else_block {
201                s.push_str(&format!(
202                    " else {{\n{}\n{}}}",
203                    convert_block(eb, depth + 1),
204                    pfx
205                ));
206            }
207            s
208        }
209        StmtKind::While {
210            condition,
211            body,
212            label,
213            continue_block,
214        } => {
215            let lb = label
216                .as_ref()
217                .map(|l| format!("{}: ", l))
218                .unwrap_or_default();
219            let mut s = format!(
220                "{}while ({}) {{\n{}\n{}}}",
221                lb,
222                convert_expr_top(condition),
223                convert_block(body, depth + 1),
224                pfx
225            );
226            if let Some(cb) = continue_block {
227                s.push_str(&format!(
228                    " continue {{\n{}\n{}}}",
229                    convert_block(cb, depth + 1),
230                    pfx
231                ));
232            }
233            s
234        }
235        StmtKind::Until {
236            condition,
237            body,
238            label,
239            continue_block,
240        } => {
241            let lb = label
242                .as_ref()
243                .map(|l| format!("{}: ", l))
244                .unwrap_or_default();
245            let mut s = format!(
246                "{}until ({}) {{\n{}\n{}}}",
247                lb,
248                convert_expr_top(condition),
249                convert_block(body, depth + 1),
250                pfx
251            );
252            if let Some(cb) = continue_block {
253                s.push_str(&format!(
254                    " continue {{\n{}\n{}}}",
255                    convert_block(cb, depth + 1),
256                    pfx
257                ));
258            }
259            s
260        }
261        StmtKind::DoWhile { body, condition } => {
262            format!(
263                "do {{\n{}\n{}}} while ({})",
264                convert_block(body, depth + 1),
265                pfx,
266                convert_expr_top(condition)
267            )
268        }
269        StmtKind::For {
270            init,
271            condition,
272            step,
273            body,
274            label,
275            continue_block,
276        } => {
277            let lb = label
278                .as_ref()
279                .map(|l| format!("{}: ", l))
280                .unwrap_or_default();
281            let ini = init
282                .as_ref()
283                .map(|s| convert_statement_body(s))
284                .unwrap_or_default();
285            let cond = condition.as_ref().map(convert_expr).unwrap_or_default();
286            let st = step.as_ref().map(convert_expr).unwrap_or_default();
287            let mut s = format!(
288                "{}for ({}; {}; {}) {{\n{}\n{}}}",
289                lb,
290                ini,
291                cond,
292                st,
293                convert_block(body, depth + 1),
294                pfx
295            );
296            if let Some(cb) = continue_block {
297                s.push_str(&format!(
298                    " continue {{\n{}\n{}}}",
299                    convert_block(cb, depth + 1),
300                    pfx
301                ));
302            }
303            s
304        }
305        StmtKind::Foreach {
306            var,
307            list,
308            body,
309            label,
310            continue_block,
311        } => {
312            let lb = label
313                .as_ref()
314                .map(|l| format!("{}: ", l))
315                .unwrap_or_default();
316            let mut s = format!(
317                "{}for ${} ({}) {{\n{}\n{}}}",
318                lb,
319                var,
320                convert_expr(list),
321                convert_block(body, depth + 1),
322                pfx
323            );
324            if let Some(cb) = continue_block {
325                s.push_str(&format!(
326                    " continue {{\n{}\n{}}}",
327                    convert_block(cb, depth + 1),
328                    pfx
329                ));
330            }
331            s
332        }
333        StmtKind::SubDecl {
334            name,
335            params,
336            body,
337            prototype,
338        } => {
339            let sig = if !params.is_empty() {
340                format!(
341                    " ({})",
342                    params
343                        .iter()
344                        .map(fmt::format_sub_sig_param)
345                        .collect::<Vec<_>>()
346                        .join(", ")
347                )
348            } else {
349                prototype
350                    .as_ref()
351                    .map(|p| format!(" ({})", p))
352                    .unwrap_or_default()
353            };
354            format!(
355                "fn {}{} {{\n{}\n{}}}",
356                name,
357                sig,
358                convert_block(body, depth + 1),
359                pfx
360            )
361        }
362        StmtKind::Package { name } => format!("package {}", name),
363        StmtKind::UsePerlVersion { version } => {
364            if version.fract() == 0.0 && *version >= 0.0 {
365                format!("use {}", *version as i64)
366            } else {
367                format!("use {}", version)
368            }
369        }
370        StmtKind::Use { module, imports, version } => {
371            let ver = version.as_deref().map(|v| format!(" {}", v)).unwrap_or_default();
372            if imports.is_empty() {
373                format!("use {}{}", module, ver)
374            } else {
375                format!("use {}{} {}", module, ver, convert_expr_list(imports))
376            }
377        }
378        StmtKind::UseOverload { pairs } => {
379            let inner = pairs
380                .iter()
381                .map(|(k, v)| {
382                    format!(
383                        "'{}' => '{}'",
384                        k.replace('\'', "\\'"),
385                        v.replace('\'', "\\'")
386                    )
387                })
388                .collect::<Vec<_>>()
389                .join(", ");
390            format!("use overload {inner}")
391        }
392        StmtKind::No { module, imports } => {
393            if imports.is_empty() {
394                format!("no {}", module)
395            } else {
396                format!("no {} {}", module, convert_expr_list(imports))
397            }
398        }
399        StmtKind::Return(e) => e
400            .as_ref()
401            .map(|x| format!("return {}", convert_expr_top(x)))
402            .unwrap_or_else(|| "return".to_string()),
403        StmtKind::Last(l) => l
404            .as_ref()
405            .map(|x| format!("last {}", x))
406            .unwrap_or_else(|| "last".to_string()),
407        StmtKind::Next(l) => l
408            .as_ref()
409            .map(|x| format!("next {}", x))
410            .unwrap_or_else(|| "next".to_string()),
411        StmtKind::Redo(l) => l
412            .as_ref()
413            .map(|x| format!("redo {}", x))
414            .unwrap_or_else(|| "redo".to_string()),
415        StmtKind::My(decls) => format!("{} {}", decl_keyword(decls), convert_var_decls(decls)),
416        StmtKind::Our(decls) => format!("our {}", convert_var_decls(decls)),
417        StmtKind::Local(decls) => format!("local {}", convert_var_decls(decls)),
418        StmtKind::State(decls) => format!("state {}", convert_var_decls(decls)),
419        StmtKind::LocalExpr {
420            target,
421            initializer,
422        } => {
423            let mut s = format!("local {}", convert_expr(target));
424            if let Some(init) = initializer {
425                s.push_str(&format!(" = {}", convert_expr_top(init)));
426            }
427            s
428        }
429        StmtKind::MySync(decls) => format!("mysync {}", convert_var_decls(decls)),
430        StmtKind::OurSync(decls) => format!("oursync {}", convert_var_decls(decls)),
431        StmtKind::StmtGroup(b) => convert_block(b, depth),
432        StmtKind::Block(b) => format!("{{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
433        StmtKind::Begin(b) => format!("BEGIN {{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
434        StmtKind::UnitCheck(b) => {
435            format!("UNITCHECK {{\n{}\n{}}}", convert_block(b, depth + 1), pfx)
436        }
437        StmtKind::Check(b) => format!("CHECK {{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
438        StmtKind::Init(b) => format!("INIT {{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
439        StmtKind::End(b) => format!("END {{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
440        StmtKind::Empty => String::new(),
441        StmtKind::Goto { target } => format!("goto {}", convert_expr(target)),
442        StmtKind::Continue(b) => format!("continue {{\n{}\n{}}}", convert_block(b, depth + 1), pfx),
443        StmtKind::StructDecl { def } => {
444            let fields = def
445                .fields
446                .iter()
447                .map(|f| format!("{} => {}", f.name, f.ty.display_name()))
448                .collect::<Vec<_>>()
449                .join(", ");
450            format!("struct {} {{ {} }}", def.name, fields)
451        }
452        StmtKind::EnumDecl { def } => {
453            let variants = def
454                .variants
455                .iter()
456                .map(|v| {
457                    if let Some(ty) = &v.ty {
458                        format!("{} => {}", v.name, ty.display_name())
459                    } else {
460                        v.name.clone()
461                    }
462                })
463                .collect::<Vec<_>>()
464                .join(", ");
465            format!("enum {} {{ {} }}", def.name, variants)
466        }
467        StmtKind::ClassDecl { def } => {
468            let prefix = if def.is_abstract {
469                "abstract "
470            } else if def.is_final {
471                "final "
472            } else {
473                ""
474            };
475            let mut parts = vec![format!("{}class {}", prefix, def.name)];
476            if !def.extends.is_empty() {
477                parts.push(format!("extends {}", def.extends.join(", ")));
478            }
479            if !def.implements.is_empty() {
480                parts.push(format!("impl {}", def.implements.join(", ")));
481            }
482            let fields = def
483                .fields
484                .iter()
485                .map(|f| {
486                    let vis = match f.visibility {
487                        crate::ast::Visibility::Private => "priv ",
488                        crate::ast::Visibility::Protected => "prot ",
489                        crate::ast::Visibility::Public => "",
490                    };
491                    format!("{}{}: {}", vis, f.name, f.ty.display_name())
492                })
493                .collect::<Vec<_>>()
494                .join("; ");
495            format!("{} {{ {} }}", parts.join(" "), fields)
496        }
497        StmtKind::TraitDecl { def } => {
498            let methods = def
499                .methods
500                .iter()
501                .map(|m| format!("fn {}", m.name))
502                .collect::<Vec<_>>()
503                .join("; ");
504            format!("trait {} {{ {} }}", def.name, methods)
505        }
506        StmtKind::EvalTimeout { timeout, body } => {
507            format!(
508                "eval_timeout {} {{\n{}\n{}}}",
509                convert_expr(timeout),
510                convert_block(body, depth + 1),
511                pfx
512            )
513        }
514        StmtKind::TryCatch {
515            try_block,
516            catch_var,
517            catch_block,
518            finally_block,
519        } => {
520            let fin = finally_block
521                .as_ref()
522                .map(|b| {
523                    format!(
524                        "\n{}finally {{\n{}\n{}}}",
525                        pfx,
526                        convert_block(b, depth + 1),
527                        pfx
528                    )
529                })
530                .unwrap_or_default();
531            format!(
532                "try {{\n{}\n{}}} catch (${}) {{\n{}\n{}}}{}",
533                convert_block(try_block, depth + 1),
534                pfx,
535                catch_var,
536                convert_block(catch_block, depth + 1),
537                pfx,
538                fin
539            )
540        }
541        StmtKind::Given { topic, body } => {
542            format!(
543                "given ({}) {{\n{}\n{}}}",
544                convert_expr(topic),
545                convert_block(body, depth + 1),
546                pfx
547            )
548        }
549        StmtKind::When { cond, body } => {
550            format!(
551                "when ({}) {{\n{}\n{}}}",
552                convert_expr(cond),
553                convert_block(body, depth + 1),
554                pfx
555            )
556        }
557        StmtKind::DefaultCase { body } => {
558            format!("default {{\n{}\n{}}}", convert_block(body, depth + 1), pfx)
559        }
560        StmtKind::FormatDecl { name, lines } => {
561            let mut s = format!("format {} =\n", name);
562            for ln in lines {
563                s.push_str(ln);
564                s.push('\n');
565            }
566            s.push('.');
567            s
568        }
569        StmtKind::AdviceDecl {
570            kind,
571            pattern,
572            body,
573        } => {
574            let kw = match kind {
575                crate::ast::AdviceKind::Before => "before",
576                crate::ast::AdviceKind::After => "after",
577                crate::ast::AdviceKind::Around => "around",
578            };
579            format!(
580                "{} \"{}\" {{\n{}\n{}}}",
581                kw,
582                pattern,
583                convert_block(body, depth + 1),
584                pfx
585            )
586        }
587        StmtKind::Tie {
588            target,
589            class,
590            args,
591        } => {
592            let target_s = match target {
593                crate::ast::TieTarget::Hash(h) => format!("%{}", h),
594                crate::ast::TieTarget::Array(a) => format!("@{}", a),
595                crate::ast::TieTarget::Scalar(s) => format!("${}", s),
596            };
597            let mut s = format!("tie {} {}", target_s, convert_expr(class));
598            for a in args {
599                s.push_str(&format!(", {}", convert_expr(a)));
600            }
601            s
602        }
603    };
604    format!("{}{}{}", pfx, lab, body)
605}
606
607/// Convert a statement body without indentation prefix (for C-style for init).
608fn convert_statement_body(s: &Statement) -> String {
609    let lab = s
610        .label
611        .as_ref()
612        .map(|l| format!("{}: ", l))
613        .unwrap_or_default();
614    let body = match &s.kind {
615        StmtKind::Expression(e) => convert_expr_top(e),
616        StmtKind::My(decls) => format!("{} {}", decl_keyword(decls), convert_var_decls(decls)),
617        _ => convert_statement(s, 0).trim().to_string(),
618    };
619    format!("{}{}", lab, body)
620}
621
622// ── Variable declarations ───────────────────────────────────────────────────
623
624fn convert_var_decls(decls: &[VarDecl]) -> String {
625    decls
626        .iter()
627        .map(|d| {
628            let sig = match d.sigil {
629                Sigil::Scalar => "$",
630                Sigil::Array => "@",
631                Sigil::Hash => "%",
632                Sigil::Typeglob => "*",
633            };
634            let mut s = format!("{}{}", sig, d.name);
635            if let Some(ref t) = d.type_annotation {
636                s.push_str(&format!(" : {}", t.display_name()));
637            }
638            if let Some(ref init) = d.initializer {
639                s.push_str(&format!(" = {}", convert_expr_top(init)));
640            }
641            s
642        })
643        .collect::<Vec<_>>()
644        .join(", ")
645}
646
647// ── Expression conversion ───────────────────────────────────────────────────
648
649fn convert_expr_list(es: &[Expr]) -> String {
650    es.iter().map(convert_expr).collect::<Vec<_>>().join(", ")
651}
652
653/// Format a string part for converted output.
654/// Uses simple `$name` when possible, `${name}` only when needed.
655fn convert_string_part(p: &StringPart) -> String {
656    match p {
657        StringPart::Literal(s) => fmt::escape_interpolated_literal(s),
658        StringPart::ScalarVar(n) => {
659            // Use ${} only if name has special chars or would be ambiguous
660            if needs_braces(n) {
661                format!("${{{}}}", n)
662            } else {
663                format!("${}", n)
664            }
665        }
666        StringPart::ArrayVar(n) => {
667            if needs_braces(n) {
668                format!("@{{{}}}", n)
669            } else {
670                format!("@{}", n)
671            }
672        }
673        StringPart::Expr(e) => fmt::format_expr(e),
674    }
675}
676
677/// Check if a variable name needs braces in interpolation.
678fn needs_braces(name: &str) -> bool {
679    // Empty or starts with digit needs braces
680    if name.is_empty() || name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
681        return true;
682    }
683    // Contains non-identifier chars
684    !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
685}
686
687/// Convert an expression at statement level or assignment RHS — pipe chains
688/// are emitted without outer parentheses.
689fn convert_expr_top(e: &Expr) -> String {
690    convert_expr_impl(e, true)
691}
692
693/// Convert an expression in a sub-expression context — pipe chains are wrapped
694/// in parentheses to preserve precedence.
695fn convert_expr(e: &Expr) -> String {
696    convert_expr_impl(e, false)
697}
698
699fn convert_expr_impl(e: &Expr, top: bool) -> String {
700    let mut segments: Vec<String> = Vec::new();
701    let source = extract_pipe_source(e, &mut segments);
702    if !segments.is_empty() {
703        segments.reverse();
704        // 1-2 stages: direct call syntax (e.g., `print "$x"`, `uc lc $x`)
705        // 3+ stages: thread macro (e.g., `t $x lc uc print`)
706        if segments.len() <= 2 {
707            let result = format!("{} {}", segments.join(" "), source);
708            if !top {
709                return format!("({})", result);
710            }
711            return result;
712        }
713        // 3+ stages: use thread macro
714        let stages = segments.join(" ");
715        // Strip outer parens from source if it's a parenthesized list/thread
716        let source = if source.starts_with("(t ") || source.starts_with("((") {
717            source[1..source.len() - 1].to_string()
718        } else {
719            source
720        };
721        let result = format!("t {} {}", source, stages);
722        if !top {
723            return format!("({})", result);
724        }
725        return result;
726    }
727    // No pipe chain — format with recursive sub-expression conversion.
728    convert_expr_direct(e, top)
729}
730
731// ── Pipe chain extraction ───────────────────────────────────────────────────
732//
733// Walks the expression tree from the outermost call inward, peeling off
734// each pipeable layer as a segment string.  Segments are pushed in
735// outer-to-inner order; the caller reverses before joining with `|>`.
736
737fn extract_pipe_source(e: &Expr, segments: &mut Vec<String>) -> String {
738    match &e.kind {
739        // ── Unary builtins ──────────────────────────────────────────────
740        ExprKind::Uc(inner) => {
741            segments.push("uc".into());
742            extract_pipe_source(inner, segments)
743        }
744        ExprKind::Lc(inner) => {
745            segments.push("lc".into());
746            extract_pipe_source(inner, segments)
747        }
748        ExprKind::Ucfirst(inner) => {
749            segments.push("ucfirst".into());
750            extract_pipe_source(inner, segments)
751        }
752        ExprKind::Lcfirst(inner) => {
753            segments.push("lcfirst".into());
754            extract_pipe_source(inner, segments)
755        }
756        ExprKind::Fc(inner) => {
757            segments.push("fc".into());
758            extract_pipe_source(inner, segments)
759        }
760        ExprKind::Chomp(inner) => {
761            segments.push("chomp".into());
762            extract_pipe_source(inner, segments)
763        }
764        ExprKind::Chop(inner) => {
765            segments.push("chop".into());
766            extract_pipe_source(inner, segments)
767        }
768        ExprKind::Length(inner) => {
769            segments.push("length".into());
770            extract_pipe_source(inner, segments)
771        }
772        ExprKind::Abs(inner) => {
773            segments.push("abs".into());
774            extract_pipe_source(inner, segments)
775        }
776        ExprKind::Int(inner) => {
777            segments.push("int".into());
778            extract_pipe_source(inner, segments)
779        }
780        ExprKind::Sqrt(inner) => {
781            segments.push("sqrt".into());
782            extract_pipe_source(inner, segments)
783        }
784        ExprKind::Sin(inner) => {
785            segments.push("sin".into());
786            extract_pipe_source(inner, segments)
787        }
788        ExprKind::Cos(inner) => {
789            segments.push("cos".into());
790            extract_pipe_source(inner, segments)
791        }
792        ExprKind::Exp(inner) => {
793            segments.push("exp".into());
794            extract_pipe_source(inner, segments)
795        }
796        ExprKind::Log(inner) => {
797            segments.push("log".into());
798            extract_pipe_source(inner, segments)
799        }
800        ExprKind::Hex(inner) => {
801            segments.push("hex".into());
802            extract_pipe_source(inner, segments)
803        }
804        ExprKind::Oct(inner) => {
805            segments.push("oct".into());
806            extract_pipe_source(inner, segments)
807        }
808        ExprKind::Chr(inner) => {
809            segments.push("chr".into());
810            extract_pipe_source(inner, segments)
811        }
812        ExprKind::Ord(inner) => {
813            segments.push("ord".into());
814            extract_pipe_source(inner, segments)
815        }
816        ExprKind::Defined(inner) => {
817            segments.push("defined".into());
818            extract_pipe_source(inner, segments)
819        }
820        ExprKind::Ref(inner) => {
821            segments.push("ref".into());
822            extract_pipe_source(inner, segments)
823        }
824        ExprKind::ScalarContext(inner) => {
825            segments.push("scalar".into());
826            extract_pipe_source(inner, segments)
827        }
828        ExprKind::Keys(inner) => {
829            segments.push("keys".into());
830            extract_pipe_source(inner, segments)
831        }
832        ExprKind::Values(inner) => {
833            segments.push("values".into());
834            extract_pipe_source(inner, segments)
835        }
836        ExprKind::Each(inner) => {
837            segments.push("each".into());
838            extract_pipe_source(inner, segments)
839        }
840        ExprKind::Pop(inner) => {
841            segments.push("pop".into());
842            extract_pipe_source(inner, segments)
843        }
844        ExprKind::Shift(inner) => {
845            segments.push("shift".into());
846            extract_pipe_source(inner, segments)
847        }
848        ExprKind::ReverseExpr(inner) => {
849            segments.push("reverse".into());
850            extract_pipe_source(inner, segments)
851        }
852        ExprKind::Slurp(inner) => {
853            segments.push("slurp".into());
854            extract_pipe_source(inner, segments)
855        }
856        ExprKind::Swallow(inner) => {
857            segments.push("swallow".into());
858            extract_pipe_source(inner, segments)
859        }
860        ExprKind::Burp(inner) => {
861            segments.push("burp".into());
862            extract_pipe_source(inner, segments)
863        }
864        ExprKind::God(inner) => {
865            segments.push("god".into());
866            extract_pipe_source(inner, segments)
867        }
868        ExprKind::Ingest(inner) => {
869            segments.push("ingest".into());
870            extract_pipe_source(inner, segments)
871        }
872        ExprKind::Chdir(inner) => {
873            segments.push("chdir".into());
874            extract_pipe_source(inner, segments)
875        }
876        ExprKind::Stat(inner) => {
877            segments.push("stat".into());
878            extract_pipe_source(inner, segments)
879        }
880        ExprKind::Lstat(inner) => {
881            segments.push("lstat".into());
882            extract_pipe_source(inner, segments)
883        }
884        ExprKind::Readlink(inner) => {
885            segments.push("readlink".into());
886            extract_pipe_source(inner, segments)
887        }
888        ExprKind::Study(inner) => {
889            segments.push("study".into());
890            extract_pipe_source(inner, segments)
891        }
892        ExprKind::Close(inner) => {
893            segments.push("close".into());
894            extract_pipe_source(inner, segments)
895        }
896        ExprKind::Readdir(inner) => {
897            segments.push("readdir".into());
898            extract_pipe_source(inner, segments)
899        }
900        ExprKind::Eval(inner) => {
901            segments.push("eval".into());
902            extract_pipe_source(inner, segments)
903        }
904        ExprKind::Require(inner) => {
905            segments.push("require".into());
906            extract_pipe_source(inner, segments)
907        }
908
909        // ── List-taking higher-order builtins ────────────────────────────
910        ExprKind::MapExpr {
911            block,
912            list,
913            flatten_array_refs,
914            stream,
915        } => {
916            let kw = match (*flatten_array_refs, *stream) {
917                (true, true) => "flat_maps",
918                (true, false) => "flat_map",
919                (false, true) => "maps",
920                (false, false) => "map",
921            };
922            segments.push(format!("{} {{\n{}\n}}", kw, convert_block(block, 0)));
923            extract_pipe_source(list, segments)
924        }
925        ExprKind::MapExprComma {
926            expr,
927            list,
928            flatten_array_refs,
929            stream,
930        } => {
931            let kw = match (*flatten_array_refs, *stream) {
932                (true, true) => "flat_maps",
933                (true, false) => "flat_map",
934                (false, true) => "maps",
935                (false, false) => "map",
936            };
937            // Convert comma form to block form for cleaner pipe syntax.
938            segments.push(format!("{} {{ {} }}", kw, convert_expr_top(expr)));
939            extract_pipe_source(list, segments)
940        }
941        ExprKind::GrepExpr {
942            block,
943            list,
944            keyword,
945        } => {
946            segments.push(format!(
947                "{} {{\n{}\n}}",
948                keyword.as_str(),
949                convert_block(block, 0)
950            ));
951            extract_pipe_source(list, segments)
952        }
953        ExprKind::GrepExprComma {
954            expr,
955            list,
956            keyword,
957        } => {
958            segments.push(format!(
959                "{} {{ {} }}",
960                keyword.as_str(),
961                convert_expr_top(expr)
962            ));
963            extract_pipe_source(list, segments)
964        }
965        ExprKind::SortExpr { cmp, list } => {
966            let seg = match cmp {
967                Some(SortComparator::Block(b)) => {
968                    format!("sort {{\n{}\n}}", convert_block(b, 0))
969                }
970                Some(SortComparator::Code(e)) => {
971                    format!("sort {}", convert_expr(e))
972                }
973                None => "sort".to_string(),
974            };
975            segments.push(seg);
976            extract_pipe_source(list, segments)
977        }
978        ExprKind::JoinExpr { separator, list } => {
979            segments.push(format!("join {}", convert_expr(separator)));
980            extract_pipe_source(list, segments)
981        }
982        ExprKind::ReduceExpr { block, list } => {
983            segments.push(format!("reduce {{\n{}\n}}", convert_block(block, 0)));
984            extract_pipe_source(list, segments)
985        }
986        ExprKind::ForEachExpr { block, list } => {
987            segments.push(format!("fore {{\n{}\n}}", convert_block(block, 0)));
988            extract_pipe_source(list, segments)
989        }
990
991        // ── Parallel higher-order builtins ───────────────────────────────
992        ExprKind::PMapExpr {
993            block,
994            list,
995            progress,
996            flat_outputs,
997            on_cluster,
998            stream: _,
999        } if progress.is_none() && on_cluster.is_none() => {
1000            let kw = if *flat_outputs { "pflat_map" } else { "pmap" };
1001            segments.push(format!("{} {{\n{}\n}}", kw, convert_block(block, 0)));
1002            extract_pipe_source(list, segments)
1003        }
1004        ExprKind::PGrepExpr {
1005            block,
1006            list,
1007            progress,
1008            stream: _,
1009        } if progress.is_none() => {
1010            segments.push(format!("pgrep {{\n{}\n}}", convert_block(block, 0)));
1011            extract_pipe_source(list, segments)
1012        }
1013        ExprKind::PSortExpr {
1014            cmp,
1015            list,
1016            progress,
1017        } if progress.is_none() => {
1018            let seg = match cmp {
1019                Some(b) => format!("psort {{\n{}\n}}", convert_block(b, 0)),
1020                None => "psort".to_string(),
1021            };
1022            segments.push(seg);
1023            extract_pipe_source(list, segments)
1024        }
1025
1026        // ── Print / say with single arg → pipe ───────────────────────────
1027        // say adds newline → p; print does not → print
1028        ExprKind::Say { handle: None, args } if args.len() == 1 => {
1029            segments.push("p".into());
1030            extract_pipe_source(&args[0], segments)
1031        }
1032        ExprKind::Print { handle: None, args } if args.len() == 1 => {
1033            segments.push("print".into());
1034            extract_pipe_source(&args[0], segments)
1035        }
1036
1037        // ── Generic function calls ───────────────────────────────────────
1038        ExprKind::FuncCall { name, args } if !args.is_empty() => {
1039            let seg = if args.len() == 1 {
1040                name.clone()
1041            } else {
1042                let rest = args[1..]
1043                    .iter()
1044                    .map(convert_expr)
1045                    .collect::<Vec<_>>()
1046                    .join(", ");
1047                format!("{} {}", name, rest)
1048            };
1049            segments.push(seg);
1050            extract_pipe_source(&args[0], segments)
1051        }
1052
1053        // ── Substitution with /r flag (value-returning) ──────────────────
1054        ExprKind::Substitution {
1055            expr,
1056            pattern,
1057            replacement,
1058            flags,
1059            delim,
1060        } if flags.contains('r') => {
1061            // `$str =~ s/old/new/r` → `$str |> s/old/new/r`
1062            // In pipe context the parser auto-injects `r`, but keeping it
1063            // is harmless and explicit.
1064            let d = choose_delim(*delim);
1065            segments.push(format!(
1066                "s{}{}{}{}{}{}",
1067                d,
1068                fmt::escape_regex_part(pattern),
1069                d,
1070                fmt::escape_regex_part(replacement),
1071                d,
1072                flags
1073            ));
1074            extract_pipe_source(expr, segments)
1075        }
1076
1077        // ── Transliterate with /r flag ───────────────────────────────────
1078        ExprKind::Transliterate {
1079            expr,
1080            from,
1081            to,
1082            flags,
1083            delim,
1084        } if flags.contains('r') => {
1085            let d = choose_delim(*delim);
1086            segments.push(format!(
1087                "tr{}{}{}{}{}{}",
1088                d,
1089                fmt::escape_regex_part(from),
1090                d,
1091                fmt::escape_regex_part(to),
1092                d,
1093                flags
1094            ));
1095            extract_pipe_source(expr, segments)
1096        }
1097
1098        // ── Single-element list: unwrap and continue extraction ──────────
1099        ExprKind::List(elems) if elems.len() == 1 => extract_pipe_source(&elems[0], segments),
1100
1101        // ── Base case: not pipeable ──────────────────────────────────────
1102        _ => convert_expr_direct(e, false),
1103    }
1104}
1105
1106// ── Direct expression formatting (no pipe extraction) ───────────────────────
1107//
1108// Handles the common expression types with recursive `convert_expr` calls
1109// for sub-expressions.  Rare / complex variants delegate to `fmt::format_expr`.
1110
1111fn convert_expr_direct(e: &Expr, top: bool) -> String {
1112    match &e.kind {
1113        // ── Leaf / simple (delegate to fmt) ──────────────────────────────
1114        ExprKind::Integer(_)
1115        | ExprKind::Float(_)
1116        | ExprKind::String(_)
1117        | ExprKind::Bareword(_)
1118        | ExprKind::Regex(..)
1119        | ExprKind::QW(_)
1120        | ExprKind::Undef
1121        | ExprKind::MagicConst(_)
1122        | ExprKind::ScalarVar(_)
1123        | ExprKind::ArrayVar(_)
1124        | ExprKind::HashVar(_)
1125        | ExprKind::Typeglob(_)
1126        | ExprKind::Wantarray
1127        | ExprKind::SubroutineRef(_)
1128        | ExprKind::SubroutineCodeRef(_) => fmt::format_expr(e),
1129
1130        // ── Interpolated strings — parts may embed expressions ───────────
1131        ExprKind::InterpolatedString(parts) => {
1132            format!(
1133                "\"{}\"",
1134                parts.iter().map(convert_string_part).collect::<String>()
1135            )
1136        }
1137
1138        // ── Binary operations ────────────────────────────────────────────
1139        ExprKind::BinOp { left, op, right } => {
1140            format!(
1141                "{} {} {}",
1142                convert_expr(left),
1143                fmt::format_binop(*op),
1144                convert_expr(right)
1145            )
1146        }
1147
1148        // ── Unary / postfix ──────────────────────────────────────────────
1149        ExprKind::UnaryOp { op, expr } => {
1150            format!("{}{}", fmt::format_unary(*op), convert_expr(expr))
1151        }
1152        ExprKind::PostfixOp { expr, op } => {
1153            format!("{}{}", convert_expr(expr), fmt::format_postfix(*op))
1154        }
1155
1156        // ── Assignment ───────────────────────────────────────────────────
1157        ExprKind::Assign { target, value } => {
1158            format!("{} = {}", convert_expr(target), convert_expr_top(value))
1159        }
1160        ExprKind::CompoundAssign { target, op, value } => format!(
1161            "{} {}= {}",
1162            convert_expr(target),
1163            fmt::format_binop(*op),
1164            convert_expr_top(value)
1165        ),
1166
1167        // ── Ternary ──────────────────────────────────────────────────────
1168        ExprKind::Ternary {
1169            condition,
1170            then_expr,
1171            else_expr,
1172        } => format!(
1173            "{} ? {} : {}",
1174            convert_expr(condition),
1175            convert_expr(then_expr),
1176            convert_expr(else_expr)
1177        ),
1178
1179        // ── Range / repeat ───────────────────────────────────────────────
1180        ExprKind::SliceRange { from, to, step } => {
1181            let f = from.as_ref().map(|e| convert_expr(e)).unwrap_or_default();
1182            let t = to.as_ref().map(|e| convert_expr(e)).unwrap_or_default();
1183            match step {
1184                Some(s) => format!("{}:{}:{}", f, t, convert_expr(s)),
1185                None => format!("{}:{}", f, t),
1186            }
1187        }
1188        ExprKind::Range {
1189            from,
1190            to,
1191            exclusive,
1192            step,
1193        } => {
1194            let op = if *exclusive { "..." } else { ".." };
1195            if let Some(s) = step {
1196                format!(
1197                    "{} {} {}:{}",
1198                    convert_expr(from),
1199                    op,
1200                    convert_expr(to),
1201                    convert_expr(s)
1202                )
1203            } else {
1204                format!("{} {} {}", convert_expr(from), op, convert_expr(to))
1205            }
1206        }
1207        ExprKind::Repeat {
1208            expr,
1209            count,
1210            list_repeat,
1211        } => {
1212            // Re-emit the parens for list-repeat so `(0) x 5` round-trips as
1213            // list-repeat rather than collapsing to scalar `0 x 5`.
1214            if *list_repeat && !matches!(expr.kind, ExprKind::List(_) | ExprKind::QW(_)) {
1215                format!("({}) x {}", convert_expr(expr), convert_expr(count))
1216            } else {
1217                format!("{} x {}", convert_expr(expr), convert_expr(count))
1218            }
1219        }
1220
1221        // ── Calls ────────────────────────────────────────────────────────
1222        ExprKind::FuncCall { name, args } => format!("{}({})", name, convert_expr_list(args)),
1223        ExprKind::MethodCall {
1224            object,
1225            method,
1226            args,
1227            super_call,
1228        } => {
1229            let m = if *super_call {
1230                format!("SUPER::{}", method)
1231            } else {
1232                method.clone()
1233            };
1234            format!(
1235                "{}->{}({})",
1236                convert_expr(object),
1237                m,
1238                convert_expr_list(args)
1239            )
1240        }
1241        ExprKind::IndirectCall {
1242            target,
1243            args,
1244            ampersand,
1245            pass_caller_arglist,
1246        } => {
1247            if *pass_caller_arglist && args.is_empty() {
1248                format!("&{}", convert_expr(target))
1249            } else {
1250                let inner = format!("{}({})", convert_expr(target), convert_expr_list(args));
1251                if *ampersand {
1252                    format!("&{}", inner)
1253                } else {
1254                    inner
1255                }
1256            }
1257        }
1258
1259        // ── Data structures ──────────────────────────────────────────────
1260        ExprKind::List(exprs) => format!("({})", convert_expr_list(exprs)),
1261        ExprKind::ArrayRef(elems) => format!("[{}]", convert_expr_list(elems)),
1262        ExprKind::HashRef(pairs) => {
1263            let inner = pairs
1264                .iter()
1265                .map(|(k, v)| format!("{} => {}", convert_expr(k), convert_expr(v)))
1266                .collect::<Vec<_>>()
1267                .join(", ");
1268            format!("{{{}}}", inner)
1269        }
1270        ExprKind::CodeRef { params, body } => {
1271            if params.is_empty() {
1272                format!("fn {{\n{}\n}}", convert_block(body, 0))
1273            } else {
1274                let sig = params
1275                    .iter()
1276                    .map(fmt::format_sub_sig_param)
1277                    .collect::<Vec<_>>()
1278                    .join(", ");
1279                format!("fn ({}) {{\n{}\n}}", sig, convert_block(body, 0))
1280            }
1281        }
1282
1283        // ── Access / deref ───────────────────────────────────────────────
1284        ExprKind::ArrayElement { array, index } => {
1285            format!("${}[{}]", array, convert_expr(index))
1286        }
1287        ExprKind::HashElement { hash, key } => {
1288            format!("${}{{{}}}", hash, convert_expr(key))
1289        }
1290        ExprKind::ScalarRef(inner) => format!("\\{}", convert_expr(inner)),
1291        ExprKind::ArrowDeref { expr, index, kind } => match kind {
1292            DerefKind::Array => {
1293                format!("({})->[{}]", convert_expr(expr), convert_expr(index))
1294            }
1295            DerefKind::Hash => {
1296                format!("({})->{{{}}}", convert_expr(expr), convert_expr(index))
1297            }
1298            DerefKind::Call => {
1299                format!("({})->({})", convert_expr(expr), convert_expr(index))
1300            }
1301        },
1302        ExprKind::Deref { expr, kind } => match kind {
1303            Sigil::Scalar => format!("${{{}}}", convert_expr(expr)),
1304            Sigil::Array => format!("@{{${}}}", convert_expr(expr)),
1305            Sigil::Hash => format!("%{{${}}}", convert_expr(expr)),
1306            Sigil::Typeglob => format!("*{{${}}}", convert_expr(expr)),
1307        },
1308
1309        // ── Print / say / die / warn ─────────────────────────────────────
1310        // print has no newline; say/p adds newline
1311        ExprKind::Print { handle, args } => {
1312            let h = handle
1313                .as_ref()
1314                .map(|h| format!("{} ", h))
1315                .unwrap_or_default();
1316            format!("print {}{}", h, convert_expr_list(args))
1317        }
1318        ExprKind::Say { handle, args } => {
1319            if let Some(h) = handle {
1320                format!("say {} {}", h, convert_expr_list(args))
1321            } else {
1322                format!("p {}", convert_expr_list(args))
1323            }
1324        }
1325        ExprKind::Printf { handle, args } => {
1326            let h = handle
1327                .as_ref()
1328                .map(|h| format!("{} ", h))
1329                .unwrap_or_default();
1330            format!("printf {}{}", h, convert_expr_list(args))
1331        }
1332        ExprKind::Die(args) => {
1333            if args.is_empty() {
1334                "die".to_string()
1335            } else {
1336                format!("die {}", convert_expr_list(args))
1337            }
1338        }
1339        ExprKind::Warn(args) => {
1340            if args.is_empty() {
1341                "warn".to_string()
1342            } else {
1343                format!("warn {}", convert_expr_list(args))
1344            }
1345        }
1346
1347        // ── Regex (non-piped) ────────────────────────────────────────────
1348        ExprKind::Match {
1349            expr,
1350            pattern,
1351            flags,
1352            delim,
1353            ..
1354        } => {
1355            let d = choose_delim(*delim);
1356            format!(
1357                "{} =~ {}{}{}{}",
1358                convert_expr(expr),
1359                d,
1360                fmt::escape_regex_part(pattern),
1361                d,
1362                flags
1363            )
1364        }
1365        ExprKind::Substitution {
1366            expr,
1367            pattern,
1368            replacement,
1369            flags,
1370            delim,
1371        } => {
1372            let d = choose_delim(*delim);
1373            format!(
1374                "{} =~ s{}{}{}{}{}{}",
1375                convert_expr(expr),
1376                d,
1377                fmt::escape_regex_part(pattern),
1378                d,
1379                fmt::escape_regex_part(replacement),
1380                d,
1381                flags
1382            )
1383        }
1384        ExprKind::Transliterate {
1385            expr,
1386            from,
1387            to,
1388            flags,
1389            delim,
1390        } => {
1391            let d = choose_delim(*delim);
1392            format!(
1393                "{} =~ tr{}{}{}{}{}{}",
1394                convert_expr(expr),
1395                d,
1396                fmt::escape_regex_part(from),
1397                d,
1398                fmt::escape_regex_part(to),
1399                d,
1400                flags
1401            )
1402        }
1403
1404        // ── Postfix modifiers ────────────────────────────────────────────
1405        ExprKind::PostfixIf { expr, condition } => {
1406            format!("{} if {}", convert_expr_top(expr), convert_expr(condition))
1407        }
1408        ExprKind::PostfixUnless { expr, condition } => {
1409            format!(
1410                "{} unless {}",
1411                convert_expr_top(expr),
1412                convert_expr(condition)
1413            )
1414        }
1415        ExprKind::PostfixWhile { expr, condition } => {
1416            format!(
1417                "{} while {}",
1418                convert_expr_top(expr),
1419                convert_expr(condition)
1420            )
1421        }
1422        ExprKind::PostfixUntil { expr, condition } => {
1423            format!(
1424                "{} until {}",
1425                convert_expr_top(expr),
1426                convert_expr(condition)
1427            )
1428        }
1429        ExprKind::PostfixForeach { expr, list } => {
1430            format!("{} for {}", convert_expr_top(expr), convert_expr(list))
1431        }
1432
1433        // ── Higher-order forms (fallback when not piped — e.g. empty list) ─
1434        ExprKind::MapExpr {
1435            block,
1436            list,
1437            flatten_array_refs,
1438            stream,
1439        } => {
1440            let kw = match (*flatten_array_refs, *stream) {
1441                (true, true) => "flat_maps",
1442                (true, false) => "flat_map",
1443                (false, true) => "maps",
1444                (false, false) => "map",
1445            };
1446            format!(
1447                "{} {{\n{}\n}} {}",
1448                kw,
1449                convert_block(block, 0),
1450                convert_expr(list)
1451            )
1452        }
1453        ExprKind::GrepExpr {
1454            block,
1455            list,
1456            keyword,
1457        } => {
1458            format!(
1459                "{} {{\n{}\n}} {}",
1460                keyword.as_str(),
1461                convert_block(block, 0),
1462                convert_expr(list)
1463            )
1464        }
1465        ExprKind::SortExpr { cmp, list } => match cmp {
1466            Some(SortComparator::Block(b)) => {
1467                format!(
1468                    "sort {{\n{}\n}} {}",
1469                    convert_block(b, 0),
1470                    convert_expr(list)
1471                )
1472            }
1473            Some(SortComparator::Code(e)) => {
1474                format!("sort {} {}", convert_expr(e), convert_expr(list))
1475            }
1476            None => format!("sort {}", convert_expr(list)),
1477        },
1478        ExprKind::JoinExpr { separator, list } => {
1479            format!("join({}, {})", convert_expr(separator), convert_expr(list))
1480        }
1481        ExprKind::SplitExpr {
1482            pattern,
1483            string,
1484            limit,
1485        } => match limit {
1486            Some(l) => format!(
1487                "split({}, {}, {})",
1488                convert_expr(pattern),
1489                convert_expr(string),
1490                convert_expr(l)
1491            ),
1492            None => format!("split({}, {})", convert_expr(pattern), convert_expr(string)),
1493        },
1494
1495        // ── Bless ────────────────────────────────────────────────────────
1496        ExprKind::Bless { ref_expr, class } => match class {
1497            Some(c) => format!("bless({}, {})", convert_expr(ref_expr), convert_expr(c)),
1498            None => format!("bless({})", convert_expr(ref_expr)),
1499        },
1500
1501        // ── Push / unshift / splice ──────────────────────────────────────
1502        ExprKind::Push { array, values } => {
1503            format!(
1504                "push({}, {})",
1505                convert_expr(array),
1506                convert_expr_list(values)
1507            )
1508        }
1509        ExprKind::Unshift { array, values } => {
1510            format!(
1511                "unshift({}, {})",
1512                convert_expr(array),
1513                convert_expr_list(values)
1514            )
1515        }
1516
1517        // ── Algebraic match ──────────────────────────────────────────────
1518        ExprKind::AlgebraicMatch { subject, arms } => {
1519            let arms_s = arms
1520                .iter()
1521                .map(|a| {
1522                    let guard_s = a
1523                        .guard
1524                        .as_ref()
1525                        .map(|g| format!(" if {}", convert_expr(g)))
1526                        .unwrap_or_default();
1527                    format!(
1528                        "{}{} => {}",
1529                        fmt::format_match_pattern(&a.pattern),
1530                        guard_s,
1531                        convert_expr(&a.body)
1532                    )
1533                })
1534                .collect::<Vec<_>>()
1535                .join(", ");
1536            format!("match ({}) {{ {} }}", convert_expr(subject), arms_s)
1537        }
1538
1539        // ── Everything else: delegate to fmt ─────────────────────────────
1540        _ => fmt::format_expr(e),
1541    }
1542}
1543
1544// ── Tests ───────────────────────────────────────────────────────────────────
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549    use crate::parse;
1550
1551    /// Helper: convert code and strip the shebang line for easier assertions.
1552    fn convert(code: &str) -> String {
1553        let p = parse(code).expect("parse failed");
1554        let out = convert_program(&p);
1555        // Strip shebang line for test comparisons
1556        out.strip_prefix("#!/usr/bin/env stryke\n")
1557            .unwrap_or(&out)
1558            .to_string()
1559    }
1560
1561    #[test]
1562    fn unary_builtin_direct() {
1563        // Single-stage: direct call syntax
1564        assert_eq!(convert("uc($x)"), "uc $x");
1565        assert_eq!(convert("length($str)"), "length $str");
1566    }
1567
1568    #[test]
1569    fn nested_unary_direct() {
1570        // 2-stage: direct call syntax (inner-to-outer order)
1571        let out = convert("uc(lc($x))");
1572        assert_eq!(out, "lc uc $x");
1573    }
1574
1575    #[test]
1576    fn nested_builtin_chain_thread() {
1577        let out = convert("chomp(lc(uc($x)))");
1578        assert_eq!(out, "t $x uc lc chomp");
1579    }
1580
1581    #[test]
1582    fn deeply_nested_thread() {
1583        let out = convert("length(chomp(lc(uc($x))))");
1584        assert_eq!(out, "t $x uc lc chomp length");
1585    }
1586
1587    #[test]
1588    fn map_grep_sort_thread() {
1589        let out = convert("sort { $a <=> $b } map { $_ * 2 } grep { $_ > 0 } @numbers");
1590        assert!(out.contains("t @numbers grep"));
1591        assert!(out.contains(" map"));
1592        assert!(out.contains(" sort"));
1593    }
1594
1595    #[test]
1596    fn join_direct() {
1597        let out = convert(r#"join(",", sort(@arr))"#);
1598        // 2-stage: direct call (inner-to-outer)
1599        assert!(out.contains("sort join \",\" @arr"));
1600    }
1601
1602    #[test]
1603    fn no_semicolons() {
1604        let out = convert("my $x = 1;\nmy $y = 2");
1605        assert!(!out.contains(';'));
1606        assert!(out.contains("my $x = 1"));
1607        assert!(out.contains("my $y = 2"));
1608    }
1609
1610    #[test]
1611    fn assignment_rhs_direct() {
1612        let out = convert("my $x = uc(lc($str))");
1613        // 2-stage: direct call
1614        assert_eq!(out, "my $x = lc uc $str");
1615    }
1616
1617    #[test]
1618    fn chain_in_subexpression_parenthesized() {
1619        let out = convert("$x + uc(lc($str))");
1620        // 2-stage chain should be parenthesized inside the binary op.
1621        assert!(out.contains("(lc uc $str)"));
1622    }
1623
1624    #[test]
1625    fn fn_body_indented() {
1626        let out = convert("fn foo { return uc(lc($x)); }");
1627        assert!(out.contains("fn foo"));
1628        // 2-stage: direct call
1629        assert!(out.contains("lc uc $x"));
1630        // Body should be indented
1631        assert!(out.contains("    return"));
1632    }
1633
1634    #[test]
1635    fn if_condition_converted() {
1636        let out = convert("if (defined(length($x))) { 1; }");
1637        // 2-stage: direct call
1638        assert!(out.contains("length defined $x"));
1639    }
1640
1641    #[test]
1642    fn method_call_preserved() {
1643        let out = convert("$obj->method($x)");
1644        assert!(out.contains("->method"));
1645    }
1646
1647    #[test]
1648    fn substitution_r_flag_direct() {
1649        // Single stage: direct syntax
1650        let out = convert(r#"($str =~ s/old/new/r)"#);
1651        assert!(out.contains("s/old/new/r $str"));
1652    }
1653
1654    #[test]
1655    fn user_func_call_direct() {
1656        let out = convert("fn Str::trim { } Str::trim(uc($x))");
1657        assert!(out.contains("fn Str::trim"));
1658        // 2-stage: direct call (inner-to-outer)
1659        assert!(out.contains("uc Str::trim $x"));
1660    }
1661
1662    #[test]
1663    fn user_func_extra_args_direct() {
1664        let out = convert("fn process { } process(uc($x), 42)");
1665        assert!(out.contains("fn process"));
1666        // Direct call (inner-to-outer): uc process 42 $x
1667        assert!(out.contains("uc process 42 $x"));
1668    }
1669
1670    #[test]
1671    fn map_grep_sort_chain_thread() {
1672        let out = convert("join(',', sort { $a <=> $b } map { $_ * 2 } grep { $_ > 0 } @nums)");
1673        assert!(out.contains("t @nums grep"));
1674        assert!(out.contains(" map"));
1675        assert!(out.contains(" sort"));
1676        assert!(out.contains(" join"));
1677    }
1678
1679    #[test]
1680    fn reduce_direct() {
1681        // Single stage with block: direct syntax
1682        let out = convert("reduce { $a + $b } @nums");
1683        assert!(out.contains("reduce {\n$a + $b\n} @nums"));
1684    }
1685
1686    #[test]
1687    fn shebang_prepended() {
1688        let p = parse("print 1").expect("parse failed");
1689        let out = convert_program(&p);
1690        assert!(out.starts_with("#!/usr/bin/env stryke\n"));
1691    }
1692
1693    #[test]
1694    fn indentation_in_blocks() {
1695        let out = convert("if ($x) { print 1; print 2; }");
1696        // Single stage: direct call syntax
1697        assert!(out.contains("\n    print 1\n    print 2\n"));
1698    }
1699
1700    #[test]
1701    fn binop_no_parens_at_top() {
1702        let out = convert("my $x = $a + $b");
1703        // At top level / assignment RHS, no parens around binop
1704        assert!(out.contains("= $a + $b"));
1705        assert!(!out.contains("= ($a + $b)"));
1706    }
1707
1708    fn convert_with_delim(code: &str, delim: char) -> String {
1709        let p = parse(code).expect("parse failed");
1710        let opts = ConvertOptions {
1711            output_delim: Some(delim),
1712            ..Default::default()
1713        };
1714        let out = convert_program_with_options(&p, &opts);
1715        out.strip_prefix("#!/usr/bin/env stryke\n")
1716            .unwrap_or(&out)
1717            .to_string()
1718    }
1719
1720    #[test]
1721    fn output_delim_substitution() {
1722        let out = convert_with_delim("$x =~ s/foo/bar/g;", '|');
1723        assert_eq!(out, "$x =~ s|foo|bar|g");
1724    }
1725
1726    #[test]
1727    fn output_delim_transliterate() {
1728        let out = convert_with_delim("$y =~ tr/a-z/A-Z/;", '#');
1729        assert_eq!(out, "$y =~ tr#a-z#A-Z#");
1730    }
1731
1732    #[test]
1733    fn output_delim_match() {
1734        let out = convert_with_delim("$z =~ m/pattern/i;", '!');
1735        assert_eq!(out, "$z =~ !pattern!i");
1736    }
1737
1738    #[test]
1739    fn output_delim_preserves_original_when_none() {
1740        let out = convert("$x =~ s#old#new#g");
1741        assert_eq!(out, "$x =~ s#old#new#g");
1742    }
1743
1744    // ─── convert_statement coverage — variant-by-variant ────────────────────
1745    //
1746    // The 49 `StmtKind::*` arms inside `convert_statement` had only the
1747    // common shapes covered (If/Expression/SubDecl/binop forms above).
1748    // The tests below pin one representative round-trip per variant
1749    // so a stray change to any arm breaks something concrete, not the
1750    // intent (the lower variants — BEGIN/END/INIT/CHECK/Package/Local/
1751    // Our/State/Goto/labels — were historically smoke-tested via the
1752    // integration corpus but never anchored at the convert.rs level).
1753
1754    #[test]
1755    fn convert_begin_block() {
1756        let out = convert("BEGIN { my $x = 1; }");
1757        assert!(out.starts_with("BEGIN {"), "got {out:?}");
1758        assert!(out.contains("my $x = 1"));
1759    }
1760
1761    #[test]
1762    fn convert_end_block() {
1763        let out = convert("END { print 'bye'; }");
1764        assert!(out.starts_with("END {"), "got {out:?}");
1765        // String literal quoting form is convert-internal (may rewrap
1766        // 'bye' → "bye"), so anchor only on the inner literal.
1767        assert!(out.contains("bye"), "got {out:?}");
1768    }
1769
1770    #[test]
1771    fn convert_init_block() {
1772        let out = convert("INIT { my $x = 1; }");
1773        assert!(out.starts_with("INIT {"), "got {out:?}");
1774    }
1775
1776    #[test]
1777    fn convert_check_block() {
1778        let out = convert("CHECK { my $x = 1; }");
1779        assert!(out.starts_with("CHECK {"), "got {out:?}");
1780    }
1781
1782    #[test]
1783    fn convert_package_decl() {
1784        let out = convert("package Foo::Bar;");
1785        assert_eq!(out, "package Foo::Bar");
1786    }
1787
1788    #[test]
1789    fn convert_local_decl() {
1790        let out = convert("local $x;");
1791        assert_eq!(out, "local $x");
1792    }
1793
1794    #[test]
1795    fn convert_state_decl() {
1796        let out = convert("state $counter;");
1797        assert_eq!(out, "state $counter");
1798    }
1799
1800    #[test]
1801    fn convert_our_decl() {
1802        let out = convert("our $shared;");
1803        assert_eq!(out, "our $shared");
1804    }
1805
1806    #[test]
1807    fn convert_goto_label() {
1808        let out = convert("goto LABEL;");
1809        assert!(out.contains("goto"));
1810        assert!(out.contains("LABEL"));
1811    }
1812
1813    #[test]
1814    fn convert_last_with_label() {
1815        let out = convert("LOOP: while ($x) { last LOOP; }");
1816        assert!(out.contains("LOOP:"));
1817        assert!(out.contains("last LOOP"));
1818    }
1819
1820    #[test]
1821    fn convert_next_with_label() {
1822        let out = convert("LOOP: while ($x) { next LOOP; }");
1823        assert!(out.contains("next LOOP"));
1824    }
1825
1826    #[test]
1827    fn convert_redo_with_label() {
1828        let out = convert("LOOP: while ($x) { redo LOOP; }");
1829        assert!(out.contains("redo LOOP"));
1830    }
1831
1832    #[test]
1833    fn convert_bare_block_emits_braces() {
1834        let out = convert("{ my $x = 1; }");
1835        // StmtKind::Block — bare lexical block with no leading keyword.
1836        assert!(out.starts_with("{") && out.contains("my $x = 1"));
1837    }
1838
1839    #[test]
1840    fn convert_continue_block() {
1841        let out = convert("while ($x) { print 1; } continue { $x--; }");
1842        assert!(out.contains("continue {"), "got {out:?}");
1843        assert!(out.contains("$x--"));
1844    }
1845
1846    #[test]
1847    fn convert_do_while() {
1848        let out = convert("do { $x++; } while ($x < 5);");
1849        assert!(out.contains("do {"));
1850        assert!(out.contains("} while ($x < 5)"));
1851    }
1852
1853    #[test]
1854    fn convert_foreach_with_my() {
1855        let out = convert("foreach my $e (@arr) { print $e; }");
1856        // Foreach lowers to `for $var (LIST) { ... }` in the
1857        // converter (stryke's preferred surface). What matters is
1858        // that the var, iterable, and body all survived.
1859        assert!(out.contains("for $e"), "got {out:?}");
1860        assert!(out.contains("@arr"));
1861        assert!(out.contains("print $e"));
1862    }
1863
1864    #[test]
1865    fn convert_for_three_part() {
1866        let out = convert("for (my $i = 0; $i < 10; $i++) { print $i; }");
1867        // C-style for: init / cond / step / body.
1868        assert!(out.contains("for ("));
1869        assert!(out.contains("$i = 0"));
1870        assert!(out.contains("$i < 10"));
1871        assert!(out.contains("$i++"));
1872    }
1873
1874    #[test]
1875    fn convert_use_module_imports() {
1876        let out = convert("use Foo::Bar qw(baz quux);");
1877        assert!(out.contains("use Foo::Bar"));
1878    }
1879
1880    #[test]
1881    fn convert_no_module() {
1882        let out = convert("no strict;");
1883        assert!(out.contains("no strict"));
1884    }
1885
1886    #[test]
1887    fn convert_return_expr() {
1888        // `fn double` collides with the stryke builtin `double`; use a
1889        // user-defined name that won't trip the redefine guard.
1890        let out = convert("fn my_doubler { return $_[0] * 2; }");
1891        assert!(out.contains("return"));
1892        assert!(out.contains("* 2"));
1893    }
1894
1895    #[test]
1896    fn convert_return_void() {
1897        let out = convert("fn my_done { return; }");
1898        // Bare return (no expression) → just `return` keyword.
1899        assert!(out.contains("return"));
1900    }
1901
1902    #[test]
1903    fn convert_empty_statement_collapses() {
1904        // `;` alone is `StmtKind::Empty` — should produce no output beyond
1905        // the surrounding context.
1906        let out = convert("print 1;;print 2;");
1907        // Two prints separated; the empty `;` shouldn't insert garbage.
1908        assert!(out.contains("print 1"));
1909        assert!(out.contains("print 2"));
1910        assert!(
1911            !out.contains(";;"),
1912            "empty stmt leaked literal `;;`: {out:?}"
1913        );
1914    }
1915
1916    // ─── extract_pipe_source — threading chains ─────────────────────────────
1917    //
1918    // `extract_pipe_source` is 367 LOC of pattern-match across the
1919    // ExprKind builtin variants — when convert spots a chain of
1920    // unary fns (`uc(lc(...))`), it walks down via this helper
1921    // collecting fn names, then emits `t SRC fn1 fn2 ...` (the
1922    // threading form). The tests below pin one chain per category.
1923
1924    #[test]
1925    fn pipe_string_fns_chain() {
1926        // uc / lc / ucfirst / lcfirst — all in one chain.
1927        let out = convert("uc(lc(ucfirst(lcfirst($s))))");
1928        assert!(out.contains("t $s lcfirst ucfirst lc uc"), "got {out:?}");
1929    }
1930
1931    #[test]
1932    fn pipe_chomp_chop_length() {
1933        let out = convert("length(chop(chomp($s)))");
1934        assert!(out.contains("t $s chomp chop length"), "got {out:?}");
1935    }
1936
1937    #[test]
1938    fn pipe_numeric_chain() {
1939        // abs / int / sqrt — all numeric unary builtins.
1940        let out = convert("sqrt(abs(int($x)))");
1941        assert!(out.contains("t $x int abs sqrt"), "got {out:?}");
1942    }
1943
1944    #[test]
1945    fn pipe_mixed_str_num_chain() {
1946        // String → numeric chain. The threading-pipe form fires only
1947        // for 3+ nested unary builtins; 2-deep uses direct call
1948        // syntax (`uc length $s`). Pin the direct form here.
1949        let out = convert("length(uc($s))");
1950        assert_eq!(out, "uc length $s", "got {out:?}");
1951    }
1952
1953    #[test]
1954    fn pipe_terminates_at_array_source() {
1955        // chain terminates when source is an array, not another builtin.
1956        let out = convert("sort(@xs)");
1957        assert!(out.contains("@xs"));
1958        assert!(out.contains("sort"));
1959    }
1960
1961    #[test]
1962    fn pipe_single_unary_direct() {
1963        // No chain — single unary builtin uses direct call, not threading.
1964        let out = convert("uc($x)");
1965        // Should NOT produce `t $x uc` (that would only fire for nested).
1966        assert!(out.contains("uc"));
1967        assert!(out.contains("$x"));
1968    }
1969}