Skip to main content

run/
repl.rs

1use std::borrow::Cow;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::Instant;
7
8use anyhow::{Context, Result, bail};
9use rustyline::completion::{Completer, Pair};
10use rustyline::error::ReadlineError;
11use rustyline::highlight::Highlighter;
12use rustyline::hint::Hinter;
13use rustyline::history::DefaultHistory;
14use rustyline::validate::Validator;
15use rustyline::{Editor, Helper};
16
17use crate::engine::{
18    ExecutionOutcome, ExecutionPayload, LanguageRegistry, LanguageSession, build_install_command,
19};
20use crate::highlight;
21use crate::language::LanguageSpec;
22use crate::output;
23
24const HISTORY_FILE: &str = ".run_history";
25const BOOKMARKS_FILE: &str = ".run_bookmarks";
26const REPL_CONFIG_FILE: &str = ".run_repl_config";
27const MAX_DIR_STACK: usize = 20;
28
29/// Exception/stderr display mode for the REPL.
30#[derive(Clone, Copy, PartialEq, Eq)]
31enum XMode {
32    /// First line of stderr only (compact).
33    Plain,
34    /// First few lines (e.g. 5) for context.
35    Context,
36    /// Full stderr (default).
37    Verbose,
38}
39
40struct ReplHelper {
41    language_id: String,
42    session_vars: Vec<String>,
43}
44
45impl ReplHelper {
46    fn new(language_id: String) -> Self {
47        Self {
48            language_id,
49            session_vars: Vec::new(),
50        }
51    }
52
53    fn update_language(&mut self, language_id: String) {
54        self.language_id = language_id;
55    }
56
57    fn update_session_vars(&mut self, vars: Vec<String>) {
58        self.session_vars = vars;
59    }
60}
61
62const META_COMMANDS: &[&str] = &[
63    ":! ",
64    ":!! ",
65    ":help",
66    ":help ",
67    ":? ",
68    ":debug",
69    ":debug ",
70    ":commands",
71    ":quickref",
72    ":exit",
73    ":quit",
74    ":languages",
75    ":lang ",
76    ":detect ",
77    ":reset",
78    ":cd ",
79    ":cd -b ",
80    ":dhist",
81    ":bookmark ",
82    ":bookmark -l",
83    ":bookmark -d ",
84    ":env",
85    ":last",
86    ":load ",
87    ":edit",
88    ":edit ",
89    ":run ",
90    ":logstart",
91    ":logstart ",
92    ":logstop",
93    ":logstate",
94    ":macro ",
95    ":macro run ",
96    ":time ",
97    ":who",
98    ":whos",
99    ":whos ",
100    ":xmode",
101    ":xmode ",
102    ":config",
103    ":config ",
104    ":paste",
105    ":end",
106    ":precision",
107    ":precision ",
108    ":save ",
109    ":history",
110    ":install ",
111    ":bench ",
112    ":type",
113];
114
115/// (name without colon, one-line description) for :help, :commands, :quickref, :help :cmd
116const CMD_HELP: &[(&str, &str)] = &[
117    ("help", "Show this help"),
118    (
119        "?",
120        "Show doc/source for name (e.g. :? print); Python session only",
121    ),
122    (
123        "debug",
124        "Run last snippet or :debug CODE under debugger (Python: pdb)",
125    ),
126    ("lang", "Switch language"),
127    ("languages", "List available languages"),
128    ("versions", "Show toolchain versions"),
129    ("detect", "Toggle auto language detection"),
130    ("reset", "Clear current session state"),
131    (
132        "cd",
133        "Change directory; :cd - = previous, :cd -b <name> = bookmark",
134    ),
135    ("dhist", "Directory history (default 10)"),
136    ("bookmark", "Save bookmark; -l list, -d <name> delete"),
137    ("env", "List env, get VAR, or set VAR=val"),
138    ("load", "Load and execute a file or http(s) URL"),
139    ("last", "Print last execution stdout"),
140    ("edit", "Open $EDITOR; on save, execute in current session"),
141    ("run", "Load file/URL or run macro by name"),
142    (
143        "logstart",
144        "Start logging input to file (default: run_log.txt)",
145    ),
146    ("logstop", "Stop logging"),
147    ("logstate", "Show whether logging and path"),
148    (
149        "macro",
150        "Save history range as macro; :macro run NAME to run",
151    ),
152    ("time", "Run code once and print elapsed time"),
153    ("who", "List names tracked in current session"),
154    ("whos", "Like :who with optional name filter"),
155    ("save", "Save session history to file"),
156    (
157        "history",
158        "Show history; -g PATTERN, -f FILE, 4-6 or 4- or -6",
159    ),
160    ("install", "Install a package for current language"),
161    ("bench", "Benchmark code N times (default: 10)"),
162    ("type", "Show current language and session status"),
163    ("!", "Run shell command (inherit stdout/stderr)"),
164    ("!!", "Run shell command and print captured output"),
165    ("exit", "Leave the REPL"),
166    ("quit", "Leave the REPL"),
167    (
168        "xmode",
169        "Exception display: plain (first line) | context (5 lines) | verbose (full)",
170    ),
171    (
172        "config",
173        "Get/set REPL config (detect, xmode); persists in ~/.run_repl_config",
174    ),
175    (
176        "paste",
177        "Paste mode: collect lines until :end or Ctrl-D, then execute (strip >>> / ...)",
178    ),
179    (
180        "end",
181        "End paste mode and execute buffer (only in paste mode)",
182    ),
183    (
184        "precision",
185        "Float display precision (0–32) for last result; :precision N to set, persists in config",
186    ),
187];
188
189fn language_keywords(lang: &str) -> &'static [&'static str] {
190    match lang {
191        "python" | "py" | "python3" | "py3" => &[
192            "False",
193            "None",
194            "True",
195            "and",
196            "as",
197            "assert",
198            "async",
199            "await",
200            "break",
201            "class",
202            "continue",
203            "def",
204            "del",
205            "elif",
206            "else",
207            "except",
208            "finally",
209            "for",
210            "from",
211            "global",
212            "if",
213            "import",
214            "in",
215            "is",
216            "lambda",
217            "nonlocal",
218            "not",
219            "or",
220            "pass",
221            "raise",
222            "return",
223            "try",
224            "while",
225            "with",
226            "yield",
227            "print",
228            "len",
229            "range",
230            "enumerate",
231            "zip",
232            "map",
233            "filter",
234            "sorted",
235            "list",
236            "dict",
237            "set",
238            "tuple",
239            "str",
240            "int",
241            "float",
242            "bool",
243            "type",
244            "isinstance",
245            "hasattr",
246            "getattr",
247            "setattr",
248            "open",
249            "input",
250        ],
251        "javascript" | "js" | "node" => &[
252            "async",
253            "await",
254            "break",
255            "case",
256            "catch",
257            "class",
258            "const",
259            "continue",
260            "debugger",
261            "default",
262            "delete",
263            "do",
264            "else",
265            "export",
266            "extends",
267            "false",
268            "finally",
269            "for",
270            "function",
271            "if",
272            "import",
273            "in",
274            "instanceof",
275            "let",
276            "new",
277            "null",
278            "of",
279            "return",
280            "static",
281            "super",
282            "switch",
283            "this",
284            "throw",
285            "true",
286            "try",
287            "typeof",
288            "undefined",
289            "var",
290            "void",
291            "while",
292            "with",
293            "yield",
294            "console",
295            "require",
296            "module",
297            "process",
298            "Promise",
299            "Array",
300            "Object",
301            "String",
302            "Number",
303            "Boolean",
304            "Math",
305            "JSON",
306            "Date",
307            "RegExp",
308            "Map",
309            "Set",
310        ],
311        "typescript" | "ts" => &[
312            "abstract",
313            "any",
314            "as",
315            "async",
316            "await",
317            "boolean",
318            "break",
319            "case",
320            "catch",
321            "class",
322            "const",
323            "continue",
324            "debugger",
325            "declare",
326            "default",
327            "delete",
328            "do",
329            "else",
330            "enum",
331            "export",
332            "extends",
333            "false",
334            "finally",
335            "for",
336            "from",
337            "function",
338            "get",
339            "if",
340            "implements",
341            "import",
342            "in",
343            "infer",
344            "instanceof",
345            "interface",
346            "is",
347            "keyof",
348            "let",
349            "module",
350            "namespace",
351            "never",
352            "new",
353            "null",
354            "number",
355            "object",
356            "of",
357            "private",
358            "protected",
359            "public",
360            "readonly",
361            "return",
362            "set",
363            "static",
364            "string",
365            "super",
366            "switch",
367            "symbol",
368            "this",
369            "throw",
370            "true",
371            "try",
372            "type",
373            "typeof",
374            "undefined",
375            "unique",
376            "unknown",
377            "var",
378            "void",
379            "while",
380            "with",
381            "yield",
382        ],
383        "rust" | "rs" => &[
384            "as",
385            "async",
386            "await",
387            "break",
388            "const",
389            "continue",
390            "crate",
391            "dyn",
392            "else",
393            "enum",
394            "extern",
395            "false",
396            "fn",
397            "for",
398            "if",
399            "impl",
400            "in",
401            "let",
402            "loop",
403            "match",
404            "mod",
405            "move",
406            "mut",
407            "pub",
408            "ref",
409            "return",
410            "self",
411            "Self",
412            "static",
413            "struct",
414            "super",
415            "trait",
416            "true",
417            "type",
418            "unsafe",
419            "use",
420            "where",
421            "while",
422            "println!",
423            "eprintln!",
424            "format!",
425            "vec!",
426            "String",
427            "Vec",
428            "Option",
429            "Result",
430            "Some",
431            "None",
432            "Ok",
433            "Err",
434        ],
435        "go" | "golang" => &[
436            "break",
437            "case",
438            "chan",
439            "const",
440            "continue",
441            "default",
442            "defer",
443            "else",
444            "fallthrough",
445            "for",
446            "func",
447            "go",
448            "goto",
449            "if",
450            "import",
451            "interface",
452            "map",
453            "package",
454            "range",
455            "return",
456            "select",
457            "struct",
458            "switch",
459            "type",
460            "var",
461            "fmt",
462            "Println",
463            "Printf",
464            "Sprintf",
465            "errors",
466            "strings",
467            "strconv",
468        ],
469        "ruby" | "rb" => &[
470            "alias",
471            "and",
472            "begin",
473            "break",
474            "case",
475            "class",
476            "def",
477            "defined?",
478            "do",
479            "else",
480            "elsif",
481            "end",
482            "ensure",
483            "false",
484            "for",
485            "if",
486            "in",
487            "module",
488            "next",
489            "nil",
490            "not",
491            "or",
492            "redo",
493            "rescue",
494            "retry",
495            "return",
496            "self",
497            "super",
498            "then",
499            "true",
500            "undef",
501            "unless",
502            "until",
503            "when",
504            "while",
505            "yield",
506            "puts",
507            "print",
508            "require",
509            "require_relative",
510        ],
511        "java" => &[
512            "abstract",
513            "assert",
514            "boolean",
515            "break",
516            "byte",
517            "case",
518            "catch",
519            "char",
520            "class",
521            "const",
522            "continue",
523            "default",
524            "do",
525            "double",
526            "else",
527            "enum",
528            "extends",
529            "final",
530            "finally",
531            "float",
532            "for",
533            "goto",
534            "if",
535            "implements",
536            "import",
537            "instanceof",
538            "int",
539            "interface",
540            "long",
541            "native",
542            "new",
543            "package",
544            "private",
545            "protected",
546            "public",
547            "return",
548            "short",
549            "static",
550            "strictfp",
551            "super",
552            "switch",
553            "synchronized",
554            "this",
555            "throw",
556            "throws",
557            "transient",
558            "try",
559            "void",
560            "volatile",
561            "while",
562            "System",
563            "String",
564        ],
565        _ => &[],
566    }
567}
568
569fn complete_file_path(partial: &str) -> Vec<Pair> {
570    let (dir_part, file_prefix) = if let Some(sep_pos) = partial.rfind('/') {
571        (&partial[..=sep_pos], &partial[sep_pos + 1..])
572    } else {
573        ("", partial)
574    };
575
576    let search_dir = if dir_part.is_empty() { "." } else { dir_part };
577
578    let mut results = Vec::new();
579    if let Ok(entries) = std::fs::read_dir(search_dir) {
580        for entry in entries.flatten() {
581            let name = entry.file_name().to_string_lossy().to_string();
582            if name.starts_with('.') {
583                continue; // skip dotfiles
584            }
585            if name.starts_with(file_prefix) {
586                let full = format!("{dir_part}{name}");
587                let display = if entry.path().is_dir() {
588                    format!("{name}/")
589                } else {
590                    name.clone()
591                };
592                results.push(Pair {
593                    display,
594                    replacement: full,
595                });
596            }
597        }
598    }
599    results
600}
601
602impl Completer for ReplHelper {
603    type Candidate = Pair;
604
605    fn complete(
606        &self,
607        line: &str,
608        pos: usize,
609        _ctx: &rustyline::Context<'_>,
610    ) -> rustyline::Result<(usize, Vec<Pair>)> {
611        let line_up_to = &line[..pos];
612
613        // Meta command completion
614        if line_up_to.starts_with(':') {
615            // File path completion for :load and :run
616            if let Some(rest) = line_up_to
617                .strip_prefix(":load ")
618                .or_else(|| line_up_to.strip_prefix(":run "))
619                .or_else(|| line_up_to.strip_prefix(":save "))
620            {
621                let start = pos - rest.len();
622                return Ok((start, complete_file_path(rest)));
623            }
624
625            let candidates: Vec<Pair> = META_COMMANDS
626                .iter()
627                .filter(|cmd| cmd.starts_with(line_up_to))
628                .map(|cmd| Pair {
629                    display: cmd.to_string(),
630                    replacement: cmd.to_string(),
631                })
632                .collect();
633            return Ok((0, candidates));
634        }
635
636        // Find the word being typed
637        let word_start = line_up_to
638            .rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '!')
639            .map(|i| i + 1)
640            .unwrap_or(0);
641        let prefix = &line_up_to[word_start..];
642
643        if prefix.is_empty() {
644            return Ok((pos, Vec::new()));
645        }
646
647        let mut candidates: Vec<Pair> = Vec::new();
648
649        // Language keywords
650        for kw in language_keywords(&self.language_id) {
651            if kw.starts_with(prefix) {
652                candidates.push(Pair {
653                    display: kw.to_string(),
654                    replacement: kw.to_string(),
655                });
656            }
657        }
658
659        // Session variables
660        for var in &self.session_vars {
661            if var.starts_with(prefix) && !candidates.iter().any(|c| c.replacement == *var) {
662                candidates.push(Pair {
663                    display: var.clone(),
664                    replacement: var.clone(),
665                });
666            }
667        }
668
669        Ok((word_start, candidates))
670    }
671}
672
673impl Hinter for ReplHelper {
674    type Hint = String;
675}
676
677impl Validator for ReplHelper {}
678
679impl Highlighter for ReplHelper {
680    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
681        if line.trim_start().starts_with(':') {
682            return Cow::Borrowed(line);
683        }
684
685        let highlighted = highlight::highlight_repl_input(line, &self.language_id);
686        Cow::Owned(highlighted)
687    }
688
689    fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
690        true
691    }
692}
693
694impl Helper for ReplHelper {}
695
696pub fn run_repl(
697    initial_language: LanguageSpec,
698    registry: LanguageRegistry,
699    detect_enabled: bool,
700) -> Result<i32> {
701    let helper = ReplHelper::new(initial_language.canonical_id().to_string());
702    let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?;
703    editor.set_helper(Some(helper));
704
705    if let Some(path) = history_path() {
706        let _ = editor.load_history(&path);
707    }
708
709    let lang_count = registry.known_languages().len();
710    let mut state = ReplState::new(initial_language, registry, detect_enabled)?;
711
712    println!(
713        "\x1b[1mrun\x1b[0m \x1b[2mv{} — {}+ languages. Type :help for commands.\x1b[0m",
714        env!("CARGO_PKG_VERSION"),
715        lang_count
716    );
717    let mut pending: Option<PendingInput> = None;
718
719    loop {
720        let prompt = match &pending {
721            Some(p) => p.prompt(),
722            None => state.prompt(),
723        };
724        let mut pending_indent: Option<String> = None;
725        if let Some(p) = pending.as_ref()
726            && state.current_language().canonical_id() == "python"
727        {
728            let indent = python_prompt_indent(p.buffer());
729            if !indent.is_empty() {
730                pending_indent = Some(indent);
731            }
732        }
733
734        if let Some(helper) = editor.helper_mut() {
735            helper.update_language(state.current_language().canonical_id().to_string());
736        }
737
738        let line_result = match pending_indent.as_deref() {
739            Some(indent) => editor.readline_with_initial(&prompt, (indent, "")),
740            None => editor.readline(&prompt),
741        };
742
743        match line_result {
744            Ok(line) => {
745                let raw = line.trim_end_matches(['\r', '\n']);
746
747                if let Some(p) = pending.as_mut() {
748                    if raw.trim() == ":cancel" {
749                        pending = None;
750                        continue;
751                    }
752
753                    p.push_line_auto_with_indent(
754                        state.current_language().canonical_id(),
755                        raw,
756                        pending_indent.as_deref(),
757                    );
758                    if p.needs_more_input(state.current_language().canonical_id()) {
759                        continue;
760                    }
761
762                    let code = p.take();
763                    pending = None;
764                    let trimmed = code.trim_end();
765                    if !trimmed.is_empty() {
766                        let _ = editor.add_history_entry(trimmed);
767                        state.history_entries.push(trimmed.to_string());
768                        state.log_input(trimmed);
769                        state.execute_snippet(trimmed)?;
770                        if let Some(helper) = editor.helper_mut() {
771                            helper.update_session_vars(state.session_var_names());
772                        }
773                    }
774                    continue;
775                }
776
777                if raw.trim().is_empty() {
778                    continue;
779                }
780
781                if state.paste_buffer.is_some() {
782                    if raw.trim() == ":end" {
783                        let lines = state.paste_buffer.take().unwrap();
784                        let code = strip_paste_prompts(&lines);
785                        if !code.trim().is_empty() {
786                            let _ = editor.add_history_entry(code.trim());
787                            state.history_entries.push(code.trim().to_string());
788                            state.log_input(code.trim());
789                            if let Err(e) = state.execute_snippet(code.trim()) {
790                                println!("\x1b[31m[run]\x1b[0m {e}");
791                            }
792                            if let Some(helper) = editor.helper_mut() {
793                                helper.update_session_vars(state.session_var_names());
794                            }
795                        }
796                        println!("\x1b[2m[paste done]\x1b[0m");
797                    } else {
798                        state.paste_buffer.as_mut().unwrap().push(raw.to_string());
799                    }
800                    continue;
801                }
802
803                if raw.trim_start().starts_with(':') {
804                    let trimmed = raw.trim();
805                    let _ = editor.add_history_entry(trimmed);
806                    state.log_input(trimmed);
807                    if state.handle_meta(trimmed)? {
808                        break;
809                    }
810                    continue;
811                }
812
813                let mut p = PendingInput::new();
814                p.push_line(raw);
815                if p.needs_more_input(state.current_language().canonical_id()) {
816                    pending = Some(p);
817                    continue;
818                }
819
820                let trimmed = raw.trim_end();
821                let _ = editor.add_history_entry(trimmed);
822                state.history_entries.push(trimmed.to_string());
823                state.log_input(trimmed);
824                state.execute_snippet(trimmed)?;
825                if let Some(helper) = editor.helper_mut() {
826                    helper.update_session_vars(state.session_var_names());
827                }
828            }
829            Err(ReadlineError::Interrupted) => {
830                println!("^C");
831                pending = None;
832                continue;
833            }
834            Err(ReadlineError::Eof) => {
835                if let Some(lines) = state.paste_buffer.take() {
836                    let code = strip_paste_prompts(&lines);
837                    if !code.trim().is_empty() {
838                        let _ = editor.add_history_entry(code.trim());
839                        state.history_entries.push(code.trim().to_string());
840                        state.log_input(code.trim());
841                        if let Err(e) = state.execute_snippet(code.trim()) {
842                            println!("\x1b[31m[run]\x1b[0m {e}");
843                        }
844                    }
845                    println!("\x1b[2m[paste done]\x1b[0m");
846                }
847                println!("bye");
848                break;
849            }
850            Err(err) => {
851                bail!("readline error: {err}");
852            }
853        }
854    }
855
856    if let Some(path) = history_path() {
857        let _ = editor.save_history(&path);
858    }
859
860    state.shutdown();
861    Ok(0)
862}
863
864struct ReplState {
865    registry: LanguageRegistry,
866    sessions: HashMap<String, Box<dyn LanguageSession>>, // keyed by canonical id
867    current_language: LanguageSpec,
868    detect_enabled: bool,
869    defined_names: HashSet<String>,
870    history_entries: Vec<String>,
871    dir_stack: Vec<PathBuf>,
872    bookmarks: HashMap<String, PathBuf>,
873    log_path: Option<PathBuf>,
874    macros: HashMap<String, String>,
875    xmode: XMode,
876    /// When Some, we are in paste mode; lines are collected until :end or Ctrl-D.
877    paste_buffer: Option<Vec<String>>,
878    /// Float display precision for last result (when we show it). None = default.
879    precision: Option<u32>,
880    /// In[n] counter for numbered prompts (e.g. python [3]>>>).
881    in_count: usize,
882    /// Last execution stdout, for :last.
883    last_stdout: Option<String>,
884    /// Whether to show [n] in prompt (config: numbered_prompts).
885    numbered_prompts: bool,
886}
887
888struct PendingInput {
889    buf: String,
890}
891
892impl PendingInput {
893    fn new() -> Self {
894        Self { buf: String::new() }
895    }
896
897    fn prompt(&self) -> String {
898        "... ".to_string()
899    }
900
901    fn buffer(&self) -> &str {
902        &self.buf
903    }
904
905    fn push_line(&mut self, line: &str) {
906        self.buf.push_str(line);
907        self.buf.push('\n');
908    }
909
910    #[cfg(test)]
911    fn push_line_auto(&mut self, language_id: &str, line: &str) {
912        self.push_line_auto_with_indent(language_id, line, None);
913    }
914
915    fn push_line_auto_with_indent(
916        &mut self,
917        language_id: &str,
918        line: &str,
919        expected_indent: Option<&str>,
920    ) {
921        match language_id {
922            "python" | "py" | "python3" | "py3" => {
923                let adjusted = python_auto_indent_with_expected(line, &self.buf, expected_indent);
924                self.push_line(&adjusted);
925            }
926            _ => self.push_line(line),
927        }
928    }
929
930    fn take(&mut self) -> String {
931        std::mem::take(&mut self.buf)
932    }
933
934    fn needs_more_input(&self, language_id: &str) -> bool {
935        needs_more_input(language_id, &self.buf)
936    }
937}
938
939fn needs_more_input(language_id: &str, code: &str) -> bool {
940    match language_id {
941        "python" | "py" | "python3" | "py3" => needs_more_input_python(code),
942
943        _ => has_unclosed_delimiters(code) || generic_line_looks_incomplete(code),
944    }
945}
946
947fn generic_line_looks_incomplete(code: &str) -> bool {
948    let mut last: Option<&str> = None;
949    for line in code.lines().rev() {
950        let trimmed = line.trim_end();
951        if trimmed.trim().is_empty() {
952            continue;
953        }
954        last = Some(trimmed);
955        break;
956    }
957    let Some(line) = last else { return false };
958    let line = line.trim();
959    if line.is_empty() {
960        return false;
961    }
962    if line.starts_with('#') {
963        return false;
964    }
965
966    if line.ends_with('\\') {
967        return true;
968    }
969
970    const TAILS: [&str; 24] = [
971        "=", "+", "-", "*", "/", "%", "&", "|", "^", "!", "<", ">", "&&", "||", "??", "?:", "?",
972        ":", ".", ",", "=>", "->", "::", "..",
973    ];
974    if TAILS.iter().any(|tok| line.ends_with(tok)) {
975        return true;
976    }
977
978    const PREFIXES: [&str; 9] = [
979        "return", "throw", "yield", "await", "import", "from", "export", "case", "else",
980    ];
981    let lowered = line.to_ascii_lowercase();
982    if PREFIXES
983        .iter()
984        .any(|kw| lowered == *kw || lowered.ends_with(&format!(" {kw}")))
985    {
986        return true;
987    }
988
989    false
990}
991
992fn needs_more_input_python(code: &str) -> bool {
993    if has_unclosed_delimiters(code) {
994        return true;
995    }
996
997    let mut last_nonempty: Option<&str> = None;
998    let mut saw_block_header = false;
999    let mut has_body_after_header = false;
1000
1001    for line in code.lines() {
1002        let trimmed = line.trim_end();
1003        if trimmed.trim().is_empty() {
1004            continue;
1005        }
1006        last_nonempty = Some(trimmed);
1007        if is_python_block_header(trimmed.trim()) {
1008            saw_block_header = true;
1009            has_body_after_header = false;
1010        } else if saw_block_header {
1011            has_body_after_header = true;
1012        }
1013    }
1014
1015    if !saw_block_header {
1016        return false;
1017    }
1018
1019    // A blank line terminates a block
1020    if code.ends_with("\n\n") {
1021        return false;
1022    }
1023
1024    // If we have a header but no body yet, we need more input
1025    if !has_body_after_header {
1026        return true;
1027    }
1028
1029    // If the last line is still indented, we're still inside the block
1030    if let Some(last) = last_nonempty
1031        && (last.starts_with(' ') || last.starts_with('\t'))
1032    {
1033        return true;
1034    }
1035
1036    false
1037}
1038
1039/// Check if a trimmed Python line is a block header (def, class, if, for, etc.)
1040/// rather than a line that just happens to end with `:` (dict literal, slice, etc.)
1041fn is_python_block_header(line: &str) -> bool {
1042    if !line.ends_with(':') {
1043        return false;
1044    }
1045    let lowered = line.to_ascii_lowercase();
1046    const BLOCK_KEYWORDS: &[&str] = &[
1047        "def ",
1048        "class ",
1049        "if ",
1050        "elif ",
1051        "else:",
1052        "for ",
1053        "while ",
1054        "try:",
1055        "except",
1056        "finally:",
1057        "with ",
1058        "async def ",
1059        "async for ",
1060        "async with ",
1061    ];
1062    BLOCK_KEYWORDS.iter().any(|kw| lowered.starts_with(kw))
1063}
1064
1065fn python_auto_indent(line: &str, existing: &str) -> String {
1066    python_auto_indent_with_expected(line, existing, None)
1067}
1068
1069fn python_auto_indent_with_expected(
1070    line: &str,
1071    existing: &str,
1072    expected_indent: Option<&str>,
1073) -> String {
1074    let trimmed = line.trim_end_matches(['\r', '\n']);
1075    let raw = trimmed;
1076    if raw.trim().is_empty() {
1077        return raw.to_string();
1078    }
1079
1080    let (raw_indent, raw_content) = split_indent(raw);
1081
1082    let mut last_nonempty: Option<&str> = None;
1083    for l in existing.lines().rev() {
1084        if l.trim().is_empty() {
1085            continue;
1086        }
1087        last_nonempty = Some(l);
1088        break;
1089    }
1090
1091    let Some(prev) = last_nonempty else {
1092        return raw.to_string();
1093    };
1094    let prev_trimmed = prev.trim_end();
1095    let prev_indent = prev
1096        .chars()
1097        .take_while(|c| *c == ' ' || *c == '\t')
1098        .collect::<String>();
1099
1100    let lowered = raw.trim().to_ascii_lowercase();
1101    let is_dedent_keyword = lowered.starts_with("else:")
1102        || lowered.starts_with("elif ")
1103        || lowered.starts_with("except")
1104        || lowered.starts_with("finally:")
1105        || lowered.starts_with("return")
1106        || lowered.starts_with("yield")
1107        || lowered == "return"
1108        || lowered == "yield";
1109    let suggested = if lowered.starts_with("else:")
1110        || lowered.starts_with("elif ")
1111        || lowered.starts_with("except")
1112        || lowered.starts_with("finally:")
1113    {
1114        if prev_indent.is_empty() {
1115            None
1116        } else {
1117            Some(python_dedent_one_level(&prev_indent))
1118        }
1119    } else if lowered.starts_with("return")
1120        || lowered.starts_with("yield")
1121        || lowered == "return"
1122        || lowered == "yield"
1123    {
1124        python_last_def_indent(existing).map(|indent| format!("{indent}    "))
1125    } else if is_python_block_header(prev_trimmed.trim()) && prev_trimmed.ends_with(':') {
1126        Some(format!("{prev_indent}    "))
1127    } else if !prev_indent.is_empty() {
1128        Some(prev_indent)
1129    } else {
1130        None
1131    };
1132
1133    if let Some(indent) = suggested {
1134        if let Some(expected) = expected_indent {
1135            if raw_indent.len() < expected.len() {
1136                return raw.to_string();
1137            }
1138            if is_dedent_keyword
1139                && raw_indent.len() == expected.len()
1140                && raw_indent.len() > indent.len()
1141            {
1142                return format!("{indent}{raw_content}");
1143            }
1144        }
1145        if raw_indent.len() < indent.len() {
1146            return format!("{indent}{raw_content}");
1147        }
1148    }
1149
1150    raw.to_string()
1151}
1152
1153fn python_prompt_indent(existing: &str) -> String {
1154    if existing.trim().is_empty() {
1155        return String::new();
1156    }
1157    let adjusted = python_auto_indent("x", existing);
1158    let (indent, _content) = split_indent(&adjusted);
1159    indent
1160}
1161
1162fn python_dedent_one_level(indent: &str) -> String {
1163    if indent.is_empty() {
1164        return String::new();
1165    }
1166    if let Some(stripped) = indent.strip_suffix('\t') {
1167        return stripped.to_string();
1168    }
1169    let mut trimmed = indent.to_string();
1170    let mut removed = 0usize;
1171    while removed < 4 && trimmed.ends_with(' ') {
1172        trimmed.pop();
1173        removed += 1;
1174    }
1175    trimmed
1176}
1177
1178fn python_last_def_indent(existing: &str) -> Option<String> {
1179    for line in existing.lines().rev() {
1180        let trimmed = line.trim_end();
1181        if trimmed.trim().is_empty() {
1182            continue;
1183        }
1184        let lowered = trimmed.trim_start().to_ascii_lowercase();
1185        if lowered.starts_with("def ") || lowered.starts_with("async def ") {
1186            let indent = line
1187                .chars()
1188                .take_while(|c| *c == ' ' || *c == '\t')
1189                .collect::<String>();
1190            return Some(indent);
1191        }
1192    }
1193    None
1194}
1195
1196fn split_indent(line: &str) -> (String, &str) {
1197    let mut idx = 0;
1198    for (i, ch) in line.char_indices() {
1199        if ch == ' ' || ch == '\t' {
1200            idx = i + ch.len_utf8();
1201        } else {
1202            break;
1203        }
1204    }
1205    (line[..idx].to_string(), &line[idx..])
1206}
1207
1208fn has_unclosed_delimiters(code: &str) -> bool {
1209    let mut paren = 0i32;
1210    let mut bracket = 0i32;
1211    let mut brace = 0i32;
1212
1213    let mut in_single = false;
1214    let mut in_double = false;
1215    let mut in_backtick = false;
1216    let mut in_block_comment = false;
1217    let mut escape = false;
1218
1219    let chars: Vec<char> = code.chars().collect();
1220    let len = chars.len();
1221    let mut i = 0;
1222
1223    while i < len {
1224        let ch = chars[i];
1225
1226        if escape {
1227            escape = false;
1228            i += 1;
1229            continue;
1230        }
1231
1232        // Inside block comment /* ... */
1233        if in_block_comment {
1234            if ch == '*' && i + 1 < len && chars[i + 1] == '/' {
1235                in_block_comment = false;
1236                i += 2;
1237                continue;
1238            }
1239            i += 1;
1240            continue;
1241        }
1242
1243        if in_single {
1244            if ch == '\\' {
1245                escape = true;
1246            } else if ch == '\'' {
1247                in_single = false;
1248            }
1249            i += 1;
1250            continue;
1251        }
1252        if in_double {
1253            if ch == '\\' {
1254                escape = true;
1255            } else if ch == '"' {
1256                in_double = false;
1257            }
1258            i += 1;
1259            continue;
1260        }
1261        if in_backtick {
1262            if ch == '\\' {
1263                escape = true;
1264            } else if ch == '`' {
1265                in_backtick = false;
1266            }
1267            i += 1;
1268            continue;
1269        }
1270
1271        // Check for line comments (// and #)
1272        if ch == '/' && i + 1 < len && chars[i + 1] == '/' {
1273            // Skip rest of line
1274            while i < len && chars[i] != '\n' {
1275                i += 1;
1276            }
1277            continue;
1278        }
1279        if ch == '#' {
1280            // Python/Ruby/etc. line comment - skip rest of line
1281            while i < len && chars[i] != '\n' {
1282                i += 1;
1283            }
1284            continue;
1285        }
1286        // Check for block comments /* ... */
1287        if ch == '/' && i + 1 < len && chars[i + 1] == '*' {
1288            in_block_comment = true;
1289            i += 2;
1290            continue;
1291        }
1292
1293        match ch {
1294            '\'' => in_single = true,
1295            '"' => in_double = true,
1296            '`' => in_backtick = true,
1297            '(' => paren += 1,
1298            ')' => paren -= 1,
1299            '[' => bracket += 1,
1300            ']' => bracket -= 1,
1301            '{' => brace += 1,
1302            '}' => brace -= 1,
1303            _ => {}
1304        }
1305
1306        i += 1;
1307    }
1308
1309    paren > 0 || bracket > 0 || brace > 0 || in_block_comment
1310}
1311
1312impl ReplState {
1313    fn new(
1314        initial_language: LanguageSpec,
1315        registry: LanguageRegistry,
1316        detect_enabled: bool,
1317    ) -> Result<Self> {
1318        let bookmarks = load_bookmarks().unwrap_or_default();
1319        let mut state = Self {
1320            registry,
1321            sessions: HashMap::new(),
1322            current_language: initial_language,
1323            detect_enabled,
1324            defined_names: HashSet::new(),
1325            history_entries: Vec::new(),
1326            dir_stack: Vec::new(),
1327            bookmarks,
1328            log_path: None,
1329            macros: HashMap::new(),
1330            xmode: XMode::Verbose,
1331            paste_buffer: None,
1332            precision: None,
1333            in_count: 0,
1334            last_stdout: None,
1335            numbered_prompts: false,
1336        };
1337        if let Ok(cfg) = load_repl_config() {
1338            if let Some(v) = cfg.get("detect") {
1339                state.detect_enabled = matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
1340            }
1341            if let Some(v) = cfg.get("xmode") {
1342                state.xmode = match v.to_lowercase().as_str() {
1343                    "plain" => XMode::Plain,
1344                    "context" => XMode::Context,
1345                    _ => XMode::Verbose,
1346                };
1347            }
1348            if let Some(v) = cfg.get("precision")
1349                && let Ok(n) = v.parse::<u32>()
1350            {
1351                state.precision = Some(n.min(32));
1352            }
1353            if let Some(v) = cfg.get("numbered_prompts") {
1354                state.numbered_prompts = matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
1355            }
1356        }
1357        state.ensure_current_language()?;
1358        Ok(state)
1359    }
1360
1361    fn current_language(&self) -> &LanguageSpec {
1362        &self.current_language
1363    }
1364
1365    fn prompt(&self) -> String {
1366        if self.numbered_prompts {
1367            format!(
1368                "{} [{}]>>> ",
1369                self.current_language.canonical_id(),
1370                self.in_count + 1
1371            )
1372        } else {
1373            format!("{}>>> ", self.current_language.canonical_id())
1374        }
1375    }
1376
1377    fn ensure_current_language(&mut self) -> Result<()> {
1378        if self.registry.resolve(&self.current_language).is_none() {
1379            bail!(
1380                "language '{}' is not available",
1381                self.current_language.canonical_id()
1382            );
1383        }
1384        Ok(())
1385    }
1386
1387    fn handle_meta(&mut self, line: &str) -> Result<bool> {
1388        let command = line.trim_start_matches(':').trim();
1389        if command.is_empty() {
1390            return Ok(false);
1391        }
1392
1393        // Shell escape :!! (capture) and :! (inherit)
1394        if let Some(stripped) = command.strip_prefix("!!") {
1395            let shell_cmd = stripped.trim_start();
1396            if shell_cmd.is_empty() {
1397                println!("usage: :!! <cmd>");
1398            } else {
1399                run_shell(shell_cmd, true);
1400            }
1401            return Ok(false);
1402        }
1403        if let Some(stripped) = command.strip_prefix('!') {
1404            let shell_cmd = stripped.trim_start();
1405            if shell_cmd.is_empty() {
1406                println!("usage: :! <cmd>");
1407            } else {
1408                run_shell(shell_cmd, false);
1409            }
1410            return Ok(false);
1411        }
1412
1413        let mut parts = command.split_whitespace();
1414        let Some(head) = parts.next() else {
1415            return Ok(false);
1416        };
1417        match head {
1418            "exit" | "quit" => return Ok(true),
1419            "help" => {
1420                if let Some(arg) = parts.next() {
1421                    Self::print_cmd_help(arg);
1422                } else {
1423                    self.print_help();
1424                }
1425                return Ok(false);
1426            }
1427            "commands" => {
1428                Self::print_commands_machine();
1429                return Ok(false);
1430            }
1431            "quickref" => {
1432                Self::print_quickref();
1433                return Ok(false);
1434            }
1435            "?" => {
1436                let expr = parts.collect::<Vec<_>>().join(" ").trim().to_string();
1437                if expr.is_empty() {
1438                    println!(
1439                        "usage: :? <name>  — show doc/source for <name> (e.g. :? print). Supported in Python session."
1440                    );
1441                } else if !expr
1442                    .chars()
1443                    .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
1444                {
1445                    println!(
1446                        "\x1b[31m[run]\x1b[0m :? only accepts names (letters, digits, dots, underscores)."
1447                    );
1448                } else if let Err(e) = self.run_introspect(&expr) {
1449                    println!("\x1b[31m[run]\x1b[0m {e}");
1450                }
1451                return Ok(false);
1452            }
1453            "debug" => {
1454                let rest: String = parts.collect::<Vec<_>>().join(" ");
1455                let code = rest.trim();
1456                let code = if code.is_empty() {
1457                    self.history_entries
1458                        .last()
1459                        .map(String::as_str)
1460                        .unwrap_or("")
1461                } else {
1462                    code
1463                };
1464                if code.is_empty() {
1465                    println!(
1466                        "usage: :debug [CODE]  — run last snippet (or CODE) under debugger. Python: pdb."
1467                    );
1468                } else if let Err(e) = self.run_debug(code) {
1469                    println!("\x1b[31m[run]\x1b[0m {e}");
1470                }
1471                return Ok(false);
1472            }
1473            "languages" => {
1474                self.print_languages();
1475                return Ok(false);
1476            }
1477            "versions" => {
1478                if let Some(lang) = parts.next() {
1479                    let spec = LanguageSpec::new(lang.to_string());
1480                    if self.registry.resolve(&spec).is_some() {
1481                        self.print_versions(Some(spec))?;
1482                    } else {
1483                        let available = self.registry.known_languages().join(", ");
1484                        println!(
1485                            "language '{}' not supported. Available: {available}",
1486                            spec.canonical_id()
1487                        );
1488                    }
1489                } else {
1490                    self.print_versions(None)?;
1491                }
1492                return Ok(false);
1493            }
1494            "detect" => {
1495                if let Some(arg) = parts.next() {
1496                    match arg {
1497                        "on" | "true" | "1" => {
1498                            self.detect_enabled = true;
1499                            println!("auto-detect enabled");
1500                        }
1501                        "off" | "false" | "0" => {
1502                            self.detect_enabled = false;
1503                            println!("auto-detect disabled");
1504                        }
1505                        "toggle" => {
1506                            self.detect_enabled = !self.detect_enabled;
1507                            println!(
1508                                "auto-detect {}",
1509                                if self.detect_enabled {
1510                                    "enabled"
1511                                } else {
1512                                    "disabled"
1513                                }
1514                            );
1515                        }
1516                        _ => println!("usage: :detect <on|off|toggle>"),
1517                    }
1518                } else {
1519                    println!(
1520                        "auto-detect is {}",
1521                        if self.detect_enabled {
1522                            "enabled"
1523                        } else {
1524                            "disabled"
1525                        }
1526                    );
1527                }
1528                return Ok(false);
1529            }
1530            "lang" => {
1531                if let Some(lang) = parts.next() {
1532                    self.switch_language(LanguageSpec::new(lang.to_string()))?;
1533                } else {
1534                    println!("usage: :lang <language>");
1535                }
1536                return Ok(false);
1537            }
1538            "reset" => {
1539                self.reset_current_session();
1540                println!(
1541                    "session for '{}' reset",
1542                    self.current_language.canonical_id()
1543                );
1544                return Ok(false);
1545            }
1546            "cd" => {
1547                let arg = parts.next();
1548                if let Some("-b") = arg {
1549                    if let Some(name) = parts.next() {
1550                        if let Some(path) = self.bookmarks.get(name) {
1551                            if let Ok(cwd) = std::env::current_dir() {
1552                                if self.dir_stack.len() < MAX_DIR_STACK {
1553                                    self.dir_stack.push(cwd);
1554                                } else {
1555                                    self.dir_stack.remove(0);
1556                                    self.dir_stack.push(cwd);
1557                                }
1558                            }
1559                            if std::env::set_current_dir(path).is_ok() {
1560                                println!("{}", path.display());
1561                            } else {
1562                                println!(
1563                                    "\x1b[31m[run]\x1b[0m cd: {}: no such directory",
1564                                    path.display()
1565                                );
1566                            }
1567                        } else {
1568                            println!("\x1b[31m[run]\x1b[0m bookmark '{}' not found", name);
1569                        }
1570                    } else {
1571                        println!("usage: :cd -b <bookmark>");
1572                    }
1573                } else if let Some(dir) = arg {
1574                    if dir == "-" {
1575                        if let Some(prev) = self.dir_stack.pop() {
1576                            if std::env::set_current_dir(&prev).is_ok() {
1577                                println!("{}", prev.display());
1578                            }
1579                        } else {
1580                            println!("\x1b[2m[run]\x1b[0m directory stack empty");
1581                        }
1582                    } else {
1583                        let path = PathBuf::from(dir);
1584                        if let Ok(cwd) = std::env::current_dir() {
1585                            if self.dir_stack.len() < MAX_DIR_STACK {
1586                                self.dir_stack.push(cwd);
1587                            } else {
1588                                self.dir_stack.remove(0);
1589                                self.dir_stack.push(cwd);
1590                            }
1591                        }
1592                        if std::env::set_current_dir(&path).is_ok() {
1593                            println!("{}", path.display());
1594                        } else {
1595                            println!(
1596                                "\x1b[31m[run]\x1b[0m cd: {}: no such directory",
1597                                path.display()
1598                            );
1599                        }
1600                    }
1601                } else if let Ok(cwd) = std::env::current_dir() {
1602                    println!("{}", cwd.display());
1603                }
1604                return Ok(false);
1605            }
1606            "dhist" => {
1607                let n: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(10);
1608                let len = self.dir_stack.len();
1609                let start = len.saturating_sub(n);
1610                if self.dir_stack.is_empty() {
1611                    println!("\x1b[2m(no directory history)\x1b[0m");
1612                } else {
1613                    for (i, p) in self.dir_stack[start..].iter().enumerate() {
1614                        let num = start + i + 1;
1615                        println!("\x1b[2m[{num:>2}]\x1b[0m {}", p.display());
1616                    }
1617                }
1618                return Ok(false);
1619            }
1620            "env" => {
1621                let a = parts.next();
1622                let b = parts.next();
1623                match (a, b) {
1624                    (None, _) => {
1625                        let vars: BTreeMap<String, String> = std::env::vars().collect();
1626                        for (k, v) in vars {
1627                            println!("{k}={v}");
1628                        }
1629                    }
1630                    (Some(var), None) => {
1631                        if let Some((k, v)) = var.split_once('=') {
1632                            unsafe { std::env::set_var(k, v) };
1633                        } else if let Ok(v) = std::env::var(var) {
1634                            println!("{v}");
1635                        }
1636                    }
1637                    (Some(var), Some(val)) => {
1638                        if val == "=" {
1639                            if let Some(v) = parts.next() {
1640                                unsafe { std::env::set_var(var, v) };
1641                            }
1642                        } else if val.starts_with('=') {
1643                            unsafe { std::env::set_var(var, val.trim_start_matches('=')) };
1644                        } else {
1645                            unsafe { std::env::set_var(var, val) };
1646                        }
1647                    }
1648                }
1649                return Ok(false);
1650            }
1651            "bookmark" => {
1652                let arg = parts.next();
1653                match arg {
1654                    Some("-l") => {
1655                        if self.bookmarks.is_empty() {
1656                            println!("\x1b[2m(no bookmarks)\x1b[0m");
1657                        } else {
1658                            let mut names: Vec<_> = self.bookmarks.keys().collect();
1659                            names.sort();
1660                            for name in names {
1661                                let path = self.bookmarks.get(name).unwrap();
1662                                println!("  {name}\t{}", path.display());
1663                            }
1664                        }
1665                    }
1666                    Some("-d") => {
1667                        if let Some(name) = parts.next() {
1668                            if self.bookmarks.remove(name).is_some() {
1669                                let _ = save_bookmarks(&self.bookmarks);
1670                                println!("\x1b[2m[removed bookmark '{name}']\x1b[0m");
1671                            } else {
1672                                println!("\x1b[31m[run]\x1b[0m bookmark '{}' not found", name);
1673                            }
1674                        } else {
1675                            println!("usage: :bookmark -d <name>");
1676                        }
1677                    }
1678                    Some(name) if !name.starts_with('-') => {
1679                        let path = parts
1680                            .next()
1681                            .map(PathBuf::from)
1682                            .or_else(|| std::env::current_dir().ok());
1683                        if let Some(p) = path {
1684                            if p.is_absolute() {
1685                                self.bookmarks.insert(name.to_string(), p.clone());
1686                                let _ = save_bookmarks(&self.bookmarks);
1687                                println!("\x1b[2m[bookmark '{name}' -> {}]\x1b[0m", p.display());
1688                            } else {
1689                                println!("\x1b[31m[run]\x1b[0m bookmark path must be absolute");
1690                            }
1691                        } else {
1692                            println!("\x1b[31m[run]\x1b[0m could not get current directory");
1693                        }
1694                    }
1695                    _ => {
1696                        println!(
1697                            "usage: :bookmark <name> [path] | :bookmark -l | :bookmark -d <name>"
1698                        );
1699                    }
1700                }
1701                return Ok(false);
1702            }
1703            "load" | "run" => {
1704                if let Some(token) = parts.next() {
1705                    if let Some(code) = self.macros.get(token) {
1706                        self.execute_payload(ExecutionPayload::Inline {
1707                            code: code.clone(),
1708                            args: Vec::new(),
1709                        })?;
1710                    } else {
1711                        let path = if token.starts_with("http://") || token.starts_with("https://")
1712                        {
1713                            match fetch_url_to_temp(token) {
1714                                Ok(p) => p,
1715                                Err(e) => {
1716                                    println!("\x1b[31m[run]\x1b[0m fetch failed: {e}");
1717                                    return Ok(false);
1718                                }
1719                            }
1720                        } else {
1721                            PathBuf::from(token)
1722                        };
1723                        self.execute_payload(ExecutionPayload::File {
1724                            path,
1725                            args: Vec::new(),
1726                        })?;
1727                    }
1728                } else {
1729                    println!("usage: :load <path|url>  or  :run <macro|path|url>");
1730                }
1731                return Ok(false);
1732            }
1733            "edit" => {
1734                let path = if let Some(token) = parts.next() {
1735                    PathBuf::from(token)
1736                } else {
1737                    match edit_temp_file() {
1738                        Ok(p) => p,
1739                        Err(e) => {
1740                            println!("\x1b[31m[run]\x1b[0m edit: {e}");
1741                            return Ok(false);
1742                        }
1743                    }
1744                };
1745                if run_editor(path.as_path()).is_err() {
1746                    println!("\x1b[31m[run]\x1b[0m editor failed or $EDITOR not set");
1747                    return Ok(false);
1748                }
1749                if path.exists() {
1750                    self.execute_payload(ExecutionPayload::File {
1751                        path,
1752                        args: Vec::new(),
1753                    })?;
1754                }
1755                return Ok(false);
1756            }
1757            "last" => {
1758                if let Some(ref s) = self.last_stdout {
1759                    print!("{}", ensure_trailing_newline(s));
1760                } else {
1761                    println!("\x1b[2m(no last output)\x1b[0m");
1762                }
1763                return Ok(false);
1764            }
1765            "logstart" => {
1766                let path = parts.next().map(PathBuf::from).unwrap_or_else(|| {
1767                    std::env::current_dir()
1768                        .unwrap_or_else(|_| PathBuf::from("."))
1769                        .join("run_log.txt")
1770                });
1771                self.log_path = Some(path.clone());
1772                self.log_input(line);
1773                println!("\x1b[2m[logging to {}]\x1b[0m", path.display());
1774                return Ok(false);
1775            }
1776            "logstop" => {
1777                if self.log_path.take().is_some() {
1778                    println!("\x1b[2m[logging stopped]\x1b[0m");
1779                } else {
1780                    println!("\x1b[2m(not logging)\x1b[0m");
1781                }
1782                return Ok(false);
1783            }
1784            "logstate" => {
1785                if let Some(ref p) = self.log_path {
1786                    println!("\x1b[2mlogging: {}\x1b[0m", p.display());
1787                } else {
1788                    println!("\x1b[2m(not logging)\x1b[0m");
1789                }
1790                return Ok(false);
1791            }
1792            "macro" => {
1793                let sub = parts.next();
1794                if sub == Some("run") {
1795                    if let Some(name) = parts.next() {
1796                        if let Some(code) = self.macros.get(name) {
1797                            self.execute_payload(ExecutionPayload::Inline {
1798                                code: code.clone(),
1799                                args: Vec::new(),
1800                            })?;
1801                        } else {
1802                            println!("\x1b[31m[run]\x1b[0m unknown macro: {name}");
1803                        }
1804                    } else {
1805                        println!("usage: :macro run <NAME>");
1806                    }
1807                } else if let Some(name) = sub {
1808                    let len = self.history_entries.len();
1809                    let mut indices: Vec<usize> = Vec::new();
1810                    for part in parts {
1811                        let (s, e) = parse_history_range(part, len);
1812                        for i in s..e {
1813                            indices.push(i);
1814                        }
1815                    }
1816                    indices.sort_unstable();
1817                    indices.dedup();
1818                    let code: String = indices
1819                        .into_iter()
1820                        .filter_map(|i| self.history_entries.get(i))
1821                        .cloned()
1822                        .collect::<Vec<_>>()
1823                        .join("\n");
1824                    if code.is_empty() {
1825                        println!("\x1b[31m[run]\x1b[0m no history entries for range");
1826                    } else {
1827                        self.macros.insert(name.to_string(), code);
1828                        println!("\x1b[2m[macro '{name}' saved]\x1b[0m");
1829                    }
1830                } else {
1831                    println!("usage: :macro <NAME> <range>...  or  :macro run <NAME>");
1832                }
1833                return Ok(false);
1834            }
1835            "time" => {
1836                let code = parts.collect::<Vec<_>>().join(" ").trim().to_string();
1837                if code.is_empty() {
1838                    println!("usage: :time <CODE>");
1839                    return Ok(false);
1840                }
1841                let start = Instant::now();
1842                self.execute_payload(ExecutionPayload::Inline {
1843                    code,
1844                    args: Vec::new(),
1845                })?;
1846                let elapsed = start.elapsed();
1847                println!("\x1b[2m[elapsed: {:?}]\x1b[0m", elapsed);
1848                return Ok(false);
1849            }
1850            "who" => {
1851                let mut names: Vec<_> = self.defined_names.iter().cloned().collect();
1852                names.sort();
1853                if names.is_empty() {
1854                    println!("\x1b[2m(no names tracked)\x1b[0m");
1855                } else {
1856                    for n in &names {
1857                        println!("  {n}");
1858                    }
1859                }
1860                return Ok(false);
1861            }
1862            "whos" => {
1863                let pattern = parts.next();
1864                let mut names: Vec<_> = self.defined_names.iter().cloned().collect();
1865                if let Some(pat) = pattern {
1866                    names.retain(|n| n.contains(pat));
1867                }
1868                names.sort();
1869                if names.is_empty() {
1870                    println!("\x1b[2m(no names tracked)\x1b[0m");
1871                } else {
1872                    for n in &names {
1873                        println!("  {n}");
1874                    }
1875                }
1876                return Ok(false);
1877            }
1878            "xmode" => {
1879                match parts.next().map(|s| s.to_lowercase()) {
1880                    Some(ref m) if m == "plain" => self.xmode = XMode::Plain,
1881                    Some(ref m) if m == "context" => self.xmode = XMode::Context,
1882                    Some(ref m) if m == "verbose" => self.xmode = XMode::Verbose,
1883                    _ => {
1884                        let current = match self.xmode {
1885                            XMode::Plain => "plain",
1886                            XMode::Context => "context",
1887                            XMode::Verbose => "verbose",
1888                        };
1889                        println!(
1890                            "\x1b[2mexception display: {current} (plain | context | verbose)\x1b[0m"
1891                        );
1892                    }
1893                }
1894                return Ok(false);
1895            }
1896            "paste" => {
1897                self.paste_buffer = Some(Vec::new());
1898                println!("\x1b[2m[paste mode — type :end or Ctrl-D to execute]\x1b[0m");
1899                return Ok(false);
1900            }
1901            "end" => {
1902                if self.paste_buffer.is_some() {
1903                    // Handled in main loop (needs editor); show message if somehow we get here
1904                    println!("\x1b[2m[paste done]\x1b[0m");
1905                } else {
1906                    println!("\x1b[31m[run]\x1b[0m not in paste mode");
1907                }
1908                return Ok(false);
1909            }
1910            "precision" => {
1911                match parts.next() {
1912                    None => match self.precision {
1913                        Some(n) => println!("\x1b[2mprecision: {n}\x1b[0m"),
1914                        None => println!("\x1b[2mprecision: (default)\x1b[0m"),
1915                    },
1916                    Some(s) => {
1917                        if let Ok(n) = s.parse::<u32>() {
1918                            let n = n.min(32);
1919                            self.precision = Some(n);
1920                            let mut cfg = load_repl_config().unwrap_or_default();
1921                            cfg.insert("precision".to_string(), n.to_string());
1922                            if save_repl_config(&cfg).is_err() {
1923                                println!("\x1b[31m[run]\x1b[0m failed to save config");
1924                            } else {
1925                                println!("\x1b[2m[precision = {n}]\x1b[0m");
1926                            }
1927                        } else {
1928                            println!("\x1b[31m[run]\x1b[0m precision must be a number (0–32)");
1929                        }
1930                    }
1931                }
1932                return Ok(false);
1933            }
1934            "config" => {
1935                let key = parts.next().map(|s| s.to_lowercase());
1936                let val = parts.next();
1937                match (key.as_deref(), val) {
1938                    (None, _) => {
1939                        let detect = if self.detect_enabled { "on" } else { "off" };
1940                        let xmode = match self.xmode {
1941                            XMode::Plain => "plain",
1942                            XMode::Context => "context",
1943                            XMode::Verbose => "verbose",
1944                        };
1945                        let precision = self
1946                            .precision
1947                            .map(|n| n.to_string())
1948                            .unwrap_or_else(|| "default".to_string());
1949                        let numbered = if self.numbered_prompts { "on" } else { "off" };
1950                        println!("\x1b[2mdetect\t{detect}\x1b[0m");
1951                        println!("\x1b[2mxmode\t{xmode}\x1b[0m");
1952                        println!("\x1b[2mprecision\t{precision}\x1b[0m");
1953                        println!("\x1b[2mnumbered_prompts\t{numbered}\x1b[0m");
1954                    }
1955                    (Some(k), None) => {
1956                        let v: Option<String> = match k {
1957                            "detect" => {
1958                                Some(if self.detect_enabled { "on" } else { "off" }.to_string())
1959                            }
1960                            "xmode" => Some(
1961                                match self.xmode {
1962                                    XMode::Plain => "plain",
1963                                    XMode::Context => "context",
1964                                    XMode::Verbose => "verbose",
1965                                }
1966                                .to_string(),
1967                            ),
1968                            "precision" => Some(
1969                                self.precision
1970                                    .map(|n| n.to_string())
1971                                    .unwrap_or_else(|| "default".to_string()),
1972                            ),
1973                            "numbered_prompts" => {
1974                                Some(if self.numbered_prompts { "on" } else { "off" }.to_string())
1975                            }
1976                            _ => None,
1977                        };
1978                        if let Some(v) = v {
1979                            println!("{v}");
1980                        } else {
1981                            println!("\x1b[31m[run]\x1b[0m unknown config key: {k}");
1982                        }
1983                    }
1984                    (Some(k), Some(v)) => {
1985                        let mut cfg = load_repl_config().unwrap_or_default();
1986                        match k {
1987                            "detect" => {
1988                                self.detect_enabled =
1989                                    matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
1990                                cfg.insert("detect".to_string(), v.to_string());
1991                            }
1992                            "xmode" => {
1993                                self.xmode = match v.to_lowercase().as_str() {
1994                                    "plain" => XMode::Plain,
1995                                    "context" => XMode::Context,
1996                                    _ => XMode::Verbose,
1997                                };
1998                                cfg.insert("xmode".to_string(), v.to_string());
1999                            }
2000                            "precision" => {
2001                                if let Ok(n) = v.parse::<u32>() {
2002                                    self.precision = Some(n.min(32));
2003                                    cfg.insert(
2004                                        "precision".to_string(),
2005                                        self.precision.unwrap().to_string(),
2006                                    );
2007                                }
2008                            }
2009                            "numbered_prompts" => {
2010                                self.numbered_prompts =
2011                                    matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
2012                                cfg.insert("numbered_prompts".to_string(), v.to_string());
2013                            }
2014                            _ => {
2015                                println!("\x1b[31m[run]\x1b[0m unknown config key: {k}");
2016                                return Ok(false);
2017                            }
2018                        }
2019                        if save_repl_config(&cfg).is_err() {
2020                            println!("\x1b[31m[run]\x1b[0m failed to save config");
2021                        } else {
2022                            println!("\x1b[2m[{k} = {}]\x1b[0m", v.trim());
2023                        }
2024                    }
2025                }
2026                return Ok(false);
2027            }
2028            "save" => {
2029                if let Some(token) = parts.next() {
2030                    let path = Path::new(token);
2031                    match self.save_session(path) {
2032                        Ok(count) => println!(
2033                            "\x1b[2m[saved {count} entries to {}]\x1b[0m",
2034                            path.display()
2035                        ),
2036                        Err(e) => println!("error saving session: {e}"),
2037                    }
2038                } else {
2039                    println!("usage: :save <path>");
2040                }
2041                return Ok(false);
2042            }
2043            "history" => {
2044                let rest: Vec<&str> = parts.collect();
2045                let mut grep_pattern: Option<&str> = None;
2046                let mut out_file: Option<&str> = None;
2047                let mut unique = false;
2048                let mut range_or_limit: Option<String> = None;
2049                let mut i = 0;
2050                while i < rest.len() {
2051                    match rest[i] {
2052                        "-g" => {
2053                            i += 1;
2054                            if i < rest.len() {
2055                                grep_pattern = Some(rest[i]);
2056                                i += 1;
2057                            } else {
2058                                println!("usage: :history -g <pattern>");
2059                                return Ok(false);
2060                            }
2061                        }
2062                        "-f" => {
2063                            i += 1;
2064                            if i < rest.len() {
2065                                out_file = Some(rest[i]);
2066                                i += 1;
2067                            } else {
2068                                println!("usage: :history -f <file>");
2069                                return Ok(false);
2070                            }
2071                        }
2072                        "-u" => {
2073                            unique = true;
2074                            i += 1;
2075                        }
2076                        _ => {
2077                            range_or_limit = Some(rest[i].to_string());
2078                            i += 1;
2079                        }
2080                    }
2081                }
2082                let entries = &self.history_entries;
2083                let len = entries.len();
2084                let (start, end) = if let Some(ref r) = range_or_limit {
2085                    parse_history_range(r, len)
2086                } else {
2087                    (len.saturating_sub(25), len)
2088                };
2089                let end = end.min(len);
2090                let slice = if start < len && start < end {
2091                    &entries[start..end]
2092                } else {
2093                    &entries[0..0]
2094                };
2095                let mut selected: Vec<(usize, &String)> = slice
2096                    .iter()
2097                    .enumerate()
2098                    .map(|(i, e)| (start + i + 1, e))
2099                    .filter(|(_, e)| grep_pattern.map(|p| e.contains(p)).unwrap_or(true))
2100                    .collect();
2101                if unique {
2102                    let mut seen = HashSet::new();
2103                    selected.retain(|(_, e)| seen.insert((*e).clone()));
2104                }
2105                if let Some(path) = out_file {
2106                    let path = Path::new(path);
2107                    if let Ok(mut f) = std::fs::OpenOptions::new()
2108                        .create(true)
2109                        .append(true)
2110                        .open(path)
2111                    {
2112                        use std::io::Write;
2113                        for (_, e) in &selected {
2114                            let _ = writeln!(f, "{e}");
2115                        }
2116                        println!(
2117                            "\x1b[2m[appended {} entries to {}]\x1b[0m",
2118                            selected.len(),
2119                            path.display()
2120                        );
2121                    } else {
2122                        println!(
2123                            "\x1b[31m[run]\x1b[0m could not open {} for writing",
2124                            path.display()
2125                        );
2126                    }
2127                } else if selected.is_empty() {
2128                    println!("\x1b[2m(no history)\x1b[0m");
2129                } else {
2130                    for (num, entry) in selected {
2131                        let first_line = entry.lines().next().unwrap_or(entry.as_str());
2132                        let is_multiline = entry.contains('\n');
2133                        if is_multiline {
2134                            println!("\x1b[2m[{num:>4}]\x1b[0m {first_line} \x1b[2m(...)\x1b[0m");
2135                        } else {
2136                            println!("\x1b[2m[{num:>4}]\x1b[0m {entry}");
2137                        }
2138                    }
2139                }
2140                return Ok(false);
2141            }
2142            "install" => {
2143                if let Some(pkg) = parts.next() {
2144                    self.install_package(pkg);
2145                } else {
2146                    println!("usage: :install <package>");
2147                }
2148                return Ok(false);
2149            }
2150            "bench" => {
2151                let n: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(10);
2152                let code = parts.collect::<Vec<_>>().join(" ");
2153                if code.is_empty() {
2154                    println!("usage: :bench [N] <code>");
2155                    println!("  Runs <code> N times (default: 10) and reports timing stats.");
2156                } else {
2157                    self.bench_code(&code, n)?;
2158                }
2159                return Ok(false);
2160            }
2161            "type" | "which" => {
2162                let lang = &self.current_language;
2163                println!(
2164                    "\x1b[1m{}\x1b[0m \x1b[2m({})\x1b[0m",
2165                    lang.canonical_id(),
2166                    if self.sessions.contains_key(lang.canonical_id()) {
2167                        "session active"
2168                    } else {
2169                        "no session"
2170                    }
2171                );
2172                return Ok(false);
2173            }
2174            alias => {
2175                let spec = LanguageSpec::new(alias);
2176                if self.registry.resolve(&spec).is_some() {
2177                    self.switch_language(spec)?;
2178                    return Ok(false);
2179                }
2180                println!("unknown command: :{alias}. Type :help for help.");
2181            }
2182        }
2183
2184        Ok(false)
2185    }
2186
2187    fn switch_language(&mut self, spec: LanguageSpec) -> Result<()> {
2188        if self.current_language.canonical_id() == spec.canonical_id() {
2189            println!("already using {}", spec.canonical_id());
2190            return Ok(());
2191        }
2192        if self.registry.resolve(&spec).is_none() {
2193            let available = self.registry.known_languages().join(", ");
2194            bail!(
2195                "language '{}' not supported. Available: {available}",
2196                spec.canonical_id()
2197            );
2198        }
2199        self.current_language = spec;
2200        println!("switched to {}", self.current_language.canonical_id());
2201        Ok(())
2202    }
2203
2204    fn reset_current_session(&mut self) {
2205        let key = self.current_language.canonical_id().to_string();
2206        if let Some(mut session) = self.sessions.remove(&key) {
2207            let _ = session.shutdown();
2208        }
2209    }
2210
2211    fn execute_snippet(&mut self, code: &str) -> Result<()> {
2212        if self.detect_enabled
2213            && let Some(detected) = crate::detect::detect_language_from_snippet(code)
2214            && detected != self.current_language.canonical_id()
2215        {
2216            let spec = LanguageSpec::new(detected.to_string());
2217            if self.registry.resolve(&spec).is_some() {
2218                println!(
2219                    "[auto-detect] switching {} -> {}",
2220                    self.current_language.canonical_id(),
2221                    spec.canonical_id()
2222                );
2223                self.current_language = spec;
2224            }
2225        }
2226
2227        // Track defined variable names for tab completion
2228        self.defined_names.extend(extract_defined_names(
2229            code,
2230            self.current_language.canonical_id(),
2231        ));
2232
2233        let payload = ExecutionPayload::Inline {
2234            code: code.to_string(),
2235            args: Vec::new(),
2236        };
2237        self.execute_payload(payload)
2238    }
2239
2240    fn session_var_names(&self) -> Vec<String> {
2241        self.defined_names.iter().cloned().collect()
2242    }
2243
2244    fn execute_payload(&mut self, payload: ExecutionPayload) -> Result<()> {
2245        let language = self.current_language.clone();
2246        let outcome = match payload {
2247            ExecutionPayload::Inline { code, .. } => {
2248                if self.engine_supports_sessions(&language)? {
2249                    self.eval_in_session(&language, &code)?
2250                } else {
2251                    let engine = self
2252                        .registry
2253                        .resolve(&language)
2254                        .context("language engine not found")?;
2255                    engine.execute(&ExecutionPayload::Inline {
2256                        code,
2257                        args: Vec::new(),
2258                    })?
2259                }
2260            }
2261            ExecutionPayload::File { ref path, .. } => {
2262                // Read the file and feed it through the session so variables persist
2263                if self.engine_supports_sessions(&language)? {
2264                    let code = std::fs::read_to_string(path)
2265                        .with_context(|| format!("failed to read file: {}", path.display()))?;
2266                    println!("\x1b[2m[loaded {}]\x1b[0m", path.display());
2267                    self.eval_in_session(&language, &code)?
2268                } else {
2269                    let engine = self
2270                        .registry
2271                        .resolve(&language)
2272                        .context("language engine not found")?;
2273                    engine.execute(&payload)?
2274                }
2275            }
2276            ExecutionPayload::Stdin { code, .. } => {
2277                if self.engine_supports_sessions(&language)? {
2278                    self.eval_in_session(&language, &code)?
2279                } else {
2280                    let engine = self
2281                        .registry
2282                        .resolve(&language)
2283                        .context("language engine not found")?;
2284                    engine.execute(&ExecutionPayload::Stdin {
2285                        code,
2286                        args: Vec::new(),
2287                    })?
2288                }
2289            }
2290        };
2291        render_outcome(&outcome, self.xmode);
2292        self.last_stdout = Some(outcome.stdout.clone());
2293        self.in_count += 1;
2294        Ok(())
2295    }
2296
2297    fn engine_supports_sessions(&self, language: &LanguageSpec) -> Result<bool> {
2298        Ok(self
2299            .registry
2300            .resolve(language)
2301            .context("language engine not found")?
2302            .supports_sessions())
2303    }
2304
2305    fn eval_in_session(&mut self, language: &LanguageSpec, code: &str) -> Result<ExecutionOutcome> {
2306        use std::collections::hash_map::Entry;
2307        let key = language.canonical_id().to_string();
2308        match self.sessions.entry(key) {
2309            Entry::Occupied(mut entry) => entry.get_mut().eval(code),
2310            Entry::Vacant(entry) => {
2311                let engine = self
2312                    .registry
2313                    .resolve(language)
2314                    .context("language engine not found")?;
2315                let mut session = engine.start_session().with_context(|| {
2316                    format!("failed to start {} session", language.canonical_id())
2317                })?;
2318                let outcome = session.eval(code)?;
2319                entry.insert(session);
2320                Ok(outcome)
2321            }
2322        }
2323    }
2324
2325    /// Run introspection (:? EXPR). Python: help(expr) in session. Others: not available.
2326    fn run_introspect(&mut self, expr: &str) -> Result<()> {
2327        let language = self.current_language.clone();
2328        let lang = language.canonical_id();
2329        if lang == "python" {
2330            let code = format!("help({expr})");
2331            let outcome = self.eval_in_session(&language, &code)?;
2332            render_outcome(&outcome, self.xmode);
2333            Ok(())
2334        } else {
2335            println!(
2336                "\x1b[2mIntrospection not available for {lang}. Use :? in a Python session.\x1b[0m"
2337            );
2338            Ok(())
2339        }
2340    }
2341
2342    /// Run :debug [CODE]. Python: pdb on temp file. Others: not available.
2343    fn run_debug(&self, code: &str) -> Result<()> {
2344        let lang = self.current_language.canonical_id();
2345        if lang != "python" {
2346            println!(
2347                "\x1b[2mDebug not available for {lang}. Use :debug in a Python session (pdb).\x1b[0m"
2348            );
2349            return Ok(());
2350        }
2351        let mut tmp = tempfile::NamedTempFile::new().context("create temp file for :debug")?;
2352        tmp.as_file_mut()
2353            .write_all(code.as_bytes())
2354            .context("write debug script")?;
2355        let path = tmp.path();
2356        let status = Command::new("python3")
2357            .args(["-m", "pdb", path.to_str().unwrap_or("")])
2358            .stdin(Stdio::inherit())
2359            .stdout(Stdio::inherit())
2360            .stderr(Stdio::inherit())
2361            .status()
2362            .context("run pdb")?;
2363        if !status.success() {
2364            println!("\x1b[2m[pdb exit {status}]\x1b[0m");
2365        }
2366        Ok(())
2367    }
2368
2369    fn print_languages(&self) {
2370        let mut languages = self.registry.known_languages();
2371        languages.sort();
2372        println!("available languages: {}", languages.join(", "));
2373    }
2374
2375    fn print_versions(&self, language: Option<LanguageSpec>) -> Result<()> {
2376        println!("language toolchain versions...\n");
2377
2378        let mut available = 0u32;
2379        let mut missing = 0u32;
2380
2381        let mut languages: Vec<String> = if let Some(lang) = language {
2382            vec![lang.canonical_id().to_string()]
2383        } else {
2384            self.registry
2385                .known_languages()
2386                .into_iter()
2387                .map(|value| value.to_string())
2388                .collect()
2389        };
2390        languages.sort();
2391
2392        for lang_id in &languages {
2393            let spec = LanguageSpec::new(lang_id.to_string());
2394            if let Some(engine) = self.registry.resolve(&spec) {
2395                match engine.toolchain_version() {
2396                    Ok(Some(version)) => {
2397                        available += 1;
2398                        println!(
2399                            "  [\x1b[32m OK \x1b[0m] {:<14} {} - {}",
2400                            engine.display_name(),
2401                            lang_id,
2402                            version
2403                        );
2404                    }
2405                    Ok(None) => {
2406                        available += 1;
2407                        println!(
2408                            "  [\x1b[33m ?? \x1b[0m] {:<14} {} - unknown",
2409                            engine.display_name(),
2410                            lang_id
2411                        );
2412                    }
2413                    Err(_) => {
2414                        missing += 1;
2415                        println!(
2416                            "  [\x1b[31mMISS\x1b[0m] {:<14} {}",
2417                            engine.display_name(),
2418                            lang_id
2419                        );
2420                    }
2421                }
2422            }
2423        }
2424
2425        println!();
2426        println!(
2427            "  {} available, {} missing, {} total",
2428            available,
2429            missing,
2430            available + missing
2431        );
2432
2433        if missing > 0 {
2434            println!("\n  Tip: Install missing toolchains to enable those languages.");
2435        }
2436
2437        Ok(())
2438    }
2439
2440    fn install_package(&self, package: &str) {
2441        let lang_id = self.current_language.canonical_id();
2442        let override_key = format!("RUN_INSTALL_COMMAND_{}", lang_id.to_ascii_uppercase());
2443        let override_value = std::env::var(&override_key).ok();
2444        let Some(mut cmd) = build_install_command(lang_id, package) else {
2445            if override_value.is_some() {
2446                println!(
2447                    "Error: {override_key} is set but could not be parsed.\n\
2448                     Provide a valid command, e.g. {override_key}=\"uv pip install {{package}}\""
2449                );
2450                return;
2451            }
2452            println!(
2453                "No package manager available for '{lang_id}'.\n\
2454                 Tip: set {override_key}=\"<cmd> {{package}}\"",
2455            );
2456            return;
2457        };
2458
2459        println!("\x1b[36m[run]\x1b[0m Installing '{package}' for {lang_id}...");
2460
2461        match cmd
2462            .stdin(std::process::Stdio::inherit())
2463            .stdout(std::process::Stdio::inherit())
2464            .stderr(std::process::Stdio::inherit())
2465            .status()
2466        {
2467            Ok(status) if status.success() => {
2468                println!("\x1b[32m[run]\x1b[0m Successfully installed '{package}'");
2469            }
2470            Ok(_) => {
2471                println!("\x1b[31m[run]\x1b[0m Failed to install '{package}'");
2472            }
2473            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2474                let program = cmd.get_program().to_string_lossy();
2475                println!("\x1b[31m[run]\x1b[0m Package manager not found: {program}");
2476                println!("Tip: install it or set {override_key}=\"<cmd> {{package}}\"");
2477            }
2478            Err(e) => {
2479                println!("\x1b[31m[run]\x1b[0m Error running package manager: {e}");
2480            }
2481        }
2482    }
2483
2484    fn bench_code(&mut self, code: &str, iterations: u32) -> Result<()> {
2485        let language = self.current_language.clone();
2486
2487        // Warmup
2488        let warmup = self.eval_in_session(&language, code)?;
2489        if !warmup.success() {
2490            println!("\x1b[31mError:\x1b[0m Code failed during warmup");
2491            if !warmup.stderr.is_empty() {
2492                print!("{}", warmup.stderr);
2493            }
2494            return Ok(());
2495        }
2496        println!("\x1b[2m  warmup: {}ms\x1b[0m", warmup.duration.as_millis());
2497
2498        let mut times: Vec<f64> = Vec::with_capacity(iterations as usize);
2499        for i in 0..iterations {
2500            let outcome = self.eval_in_session(&language, code)?;
2501            let ms = outcome.duration.as_secs_f64() * 1000.0;
2502            times.push(ms);
2503            if i < 3 || i == iterations - 1 {
2504                println!("\x1b[2m  run {}: {:.2}ms\x1b[0m", i + 1, ms);
2505            }
2506        }
2507
2508        times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2509        let total: f64 = times.iter().sum();
2510        let avg = total / times.len() as f64;
2511        let min = times.first().copied().unwrap_or(0.0);
2512        let max = times.last().copied().unwrap_or(0.0);
2513        let median = if times.len().is_multiple_of(2) && times.len() >= 2 {
2514            (times[times.len() / 2 - 1] + times[times.len() / 2]) / 2.0
2515        } else {
2516            times[times.len() / 2]
2517        };
2518        let variance: f64 =
2519            times.iter().map(|t| (t - avg).powi(2)).sum::<f64>() / times.len() as f64;
2520        let stddev = variance.sqrt();
2521
2522        println!();
2523        println!("\x1b[1mResults ({iterations} runs):\x1b[0m");
2524        println!("  min:    \x1b[32m{min:.2}ms\x1b[0m");
2525        println!("  max:    \x1b[33m{max:.2}ms\x1b[0m");
2526        println!("  avg:    \x1b[36m{avg:.2}ms\x1b[0m");
2527        println!("  median: \x1b[36m{median:.2}ms\x1b[0m");
2528        println!("  stddev: {stddev:.2}ms");
2529        Ok(())
2530    }
2531
2532    fn log_input(&self, line: &str) {
2533        if let Some(ref p) = self.log_path {
2534            let _ = std::fs::OpenOptions::new()
2535                .create(true)
2536                .append(true)
2537                .open(p)
2538                .and_then(|mut f| writeln!(f, "{line}"));
2539        }
2540    }
2541
2542    fn save_session(&self, path: &Path) -> Result<usize> {
2543        use std::io::Write;
2544        let mut file = std::fs::File::create(path)
2545            .with_context(|| format!("failed to create {}", path.display()))?;
2546        let count = self.history_entries.len();
2547        for entry in &self.history_entries {
2548            writeln!(file, "{entry}")?;
2549        }
2550        Ok(count)
2551    }
2552
2553    fn print_help(&self) {
2554        println!("\x1b[1mCommands\x1b[0m");
2555        for (name, desc) in CMD_HELP {
2556            println!("  \x1b[36m:{name}\x1b[0m  \x1b[2m{desc}\x1b[0m");
2557        }
2558        println!("\x1b[2mLanguage shortcuts: :py, :js, :rs, :go, :cpp, :java, ...\x1b[0m");
2559        println!(
2560            "\x1b[2mIn session languages (e.g. Python), _ is the last expression result.\x1b[0m"
2561        );
2562    }
2563
2564    fn print_cmd_help(cmd_name: &str) {
2565        let key = cmd_name.trim_start_matches(':').trim().to_lowercase();
2566        for (name, desc) in CMD_HELP {
2567            if name.to_lowercase() == key {
2568                println!("  \x1b[36m:{name}\x1b[0m  \x1b[2m{desc}\x1b[0m");
2569                return;
2570            }
2571        }
2572        println!("\x1b[31m[run]\x1b[0m unknown command :{cmd_name}");
2573    }
2574
2575    fn print_commands_machine() {
2576        for (name, desc) in CMD_HELP {
2577            println!(":{name}\t{desc}");
2578        }
2579    }
2580
2581    fn print_quickref() {
2582        println!("\x1b[1mQuick reference\x1b[0m");
2583        for (name, desc) in CMD_HELP {
2584            println!("  :{name}\t{desc}");
2585        }
2586        println!(
2587            "\x1b[2m:py :js :rs :go :cpp :java ...  In Python (session), _ = last result.\x1b[0m"
2588        );
2589    }
2590
2591    fn shutdown(&mut self) {
2592        for (_, mut session) in self.sessions.drain() {
2593            let _ = session.shutdown();
2594        }
2595    }
2596}
2597
2598/// Strip REPL prompts (>>> and ...) from pasted lines and dedent.
2599fn strip_paste_prompts(lines: &[String]) -> String {
2600    let stripped: Vec<String> = lines
2601        .iter()
2602        .map(|s| {
2603            let t = s.trim_start();
2604            let t = t.strip_prefix(">>>").map(|r| r.trim_start()).unwrap_or(t);
2605            let t = t.strip_prefix("...").map(|r| r.trim_start()).unwrap_or(t);
2606            t.to_string()
2607        })
2608        .collect();
2609    let non_empty: Vec<&str> = stripped
2610        .iter()
2611        .map(String::as_str)
2612        .filter(|s| !s.is_empty())
2613        .collect();
2614    if non_empty.is_empty() {
2615        return stripped.join("\n");
2616    }
2617    let min_indent = non_empty
2618        .iter()
2619        .map(|s| s.len() - s.trim_start().len())
2620        .min()
2621        .unwrap_or(0);
2622    let out: Vec<String> = stripped
2623        .iter()
2624        .map(|s| {
2625            if s.is_empty() {
2626                s.clone()
2627            } else {
2628                let n = (s.len() - s.trim_start().len()).min(min_indent);
2629                s[n..].to_string()
2630            }
2631        })
2632        .collect();
2633    out.join("\n")
2634}
2635
2636fn apply_xmode(stderr: &str, xmode: XMode) -> String {
2637    let lines: Vec<&str> = stderr.lines().collect();
2638    match xmode {
2639        XMode::Plain => lines.first().map(|s| (*s).to_string()).unwrap_or_default(),
2640        XMode::Context => lines.iter().take(5).cloned().collect::<Vec<_>>().join("\n"),
2641        XMode::Verbose => stderr.to_string(),
2642    }
2643}
2644
2645/// Render execution outcome. Stdout is passed through unchanged so engine ANSI/rich output is preserved.
2646fn render_outcome(outcome: &ExecutionOutcome, xmode: XMode) {
2647    if !outcome.stdout.is_empty() {
2648        print!("{}", ensure_trailing_newline(&outcome.stdout));
2649    }
2650    if !outcome.stderr.is_empty() {
2651        let formatted =
2652            output::format_stderr(&outcome.language, &outcome.stderr, outcome.success());
2653        let trimmed = apply_xmode(&formatted, xmode);
2654        if !trimmed.is_empty() {
2655            eprint!("\x1b[31m{}\x1b[0m", ensure_trailing_newline(&trimmed));
2656        }
2657    }
2658
2659    let millis = outcome.duration.as_millis();
2660    if let Some(code) = outcome.exit_code
2661        && code != 0
2662    {
2663        println!("\x1b[2m[exit {code}] {}\x1b[0m", format_duration(millis));
2664        return;
2665    }
2666
2667    // Show execution timing
2668    if millis > 0 {
2669        println!("\x1b[2m{}\x1b[0m", format_duration(millis));
2670    }
2671}
2672
2673fn format_duration(millis: u128) -> String {
2674    if millis >= 60_000 {
2675        let mins = millis / 60_000;
2676        let secs = (millis % 60_000) / 1000;
2677        format!("{mins}m {secs}s")
2678    } else if millis >= 1000 {
2679        let secs = millis as f64 / 1000.0;
2680        format!("{secs:.2}s")
2681    } else {
2682        format!("{millis}ms")
2683    }
2684}
2685
2686fn ensure_trailing_newline(text: &str) -> String {
2687    if text.ends_with('\n') {
2688        text.to_string()
2689    } else {
2690        let mut owned = text.to_string();
2691        owned.push('\n');
2692        owned
2693    }
2694}
2695
2696/// Run $EDITOR (or vi/notepad) on the given path. Blocks until editor exits.
2697fn run_editor(path: &Path) -> Result<()> {
2698    let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
2699        #[cfg(unix)]
2700        let default = "vi";
2701        #[cfg(windows)]
2702        let default = "notepad";
2703        default.to_string()
2704    });
2705    let path_str = path.to_string_lossy();
2706    let status = Command::new(&editor)
2707        .arg(path_str.as_ref())
2708        .stdin(std::process::Stdio::inherit())
2709        .stdout(std::process::Stdio::inherit())
2710        .stderr(std::process::Stdio::inherit())
2711        .status()
2712        .context("run editor")?;
2713    if !status.success() {
2714        bail!("editor exited with {}", status);
2715    }
2716    Ok(())
2717}
2718
2719/// Create a temp file, run the editor on it, return the path (temp file is kept).
2720fn edit_temp_file() -> Result<PathBuf> {
2721    let tmp = tempfile::NamedTempFile::new().context("create temp file for :edit")?;
2722    let path = tmp.path().to_path_buf();
2723    run_editor(&path)?;
2724    std::mem::forget(tmp);
2725    Ok(path)
2726}
2727
2728/// Fetch URL to a temp file and return its path. Caller runs and then temp file is left in /tmp.
2729fn fetch_url_to_temp(url: &str) -> Result<PathBuf> {
2730    let tmp = tempfile::NamedTempFile::new().context("create temp file for :load url")?;
2731    let path = tmp.path().to_path_buf();
2732
2733    #[cfg(unix)]
2734    let ok = Command::new("curl")
2735        .args(["-sSL", "-o", path.to_str().unwrap_or(""), url])
2736        .status()
2737        .map(|s| s.success())
2738        .unwrap_or(false);
2739
2740    #[cfg(windows)]
2741    let ok = Command::new("curl")
2742        .args(["-sSL", "-o", path.to_str().unwrap_or(""), url])
2743        .status()
2744        .map(|s| s.success())
2745        .unwrap_or_else(|_| {
2746            // Fallback: PowerShell
2747            let ps = format!(
2748                "Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing",
2749                url.replace('\'', "''"),
2750                path.to_string_lossy().replace('\'', "''")
2751            );
2752            Command::new("powershell")
2753                .args(["-NoProfile", "-Command", &ps])
2754                .status()
2755                .map(|s| s.success())
2756                .unwrap_or(false)
2757        });
2758
2759    if !ok {
2760        let _ = tmp.close();
2761        bail!("fetch failed (curl or download failed)");
2762    }
2763    std::mem::forget(tmp);
2764    Ok(path)
2765}
2766
2767/// Run a shell command. If `capture` is true, run and print stdout/stderr; otherwise inherit.
2768fn run_shell(cmd: &str, capture: bool) {
2769    #[cfg(unix)]
2770    let mut c = Command::new("sh");
2771    #[cfg(unix)]
2772    c.arg("-c").arg(cmd);
2773
2774    #[cfg(windows)]
2775    let mut c = {
2776        let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
2777        let mut com = Command::new(shell);
2778        com.arg("/c").arg(cmd);
2779        com
2780    };
2781
2782    if capture {
2783        match c
2784            .stdout(std::process::Stdio::piped())
2785            .stderr(std::process::Stdio::piped())
2786            .output()
2787        {
2788            Ok(out) => {
2789                let _ = std::io::stdout().write_all(&out.stdout);
2790                let _ = std::io::stderr().write_all(&out.stderr);
2791                if let Some(code) = out.status.code()
2792                    && code != 0
2793                {
2794                    println!("\x1b[2m[exit {code}]\x1b[0m");
2795                }
2796            }
2797            Err(e) => {
2798                eprintln!("\x1b[31m[run]\x1b[0m shell: {e}");
2799            }
2800        }
2801    } else {
2802        c.stdin(std::process::Stdio::inherit())
2803            .stdout(std::process::Stdio::inherit())
2804            .stderr(std::process::Stdio::inherit());
2805        if let Err(e) = c.status() {
2806            eprintln!("\x1b[31m[run]\x1b[0m shell: {e}");
2807        }
2808    }
2809}
2810
2811/// Parse :history range: "4-6", "4-", "-6", or "10" (last n). 1-based; returns (start, end) 0-based for entries[start..end].
2812fn parse_history_range(s: &str, len: usize) -> (usize, usize) {
2813    if s.contains('-') {
2814        let (a, b) = s.split_once('-').unwrap_or((s, ""));
2815        let a = a.trim();
2816        let b = b.trim();
2817        if a.is_empty() && b.is_empty() {
2818            return (len.saturating_sub(25), len);
2819        }
2820        if b.is_empty() {
2821            // "4-" = from 4 to end
2822            if let Ok(n) = a.parse::<usize>() {
2823                let start = n.saturating_sub(1).min(len);
2824                return (start, len);
2825            }
2826        } else if a.is_empty() {
2827            // "-6" = last 6
2828            if let Ok(n) = b.parse::<usize>() {
2829                let start = len.saturating_sub(n);
2830                return (start, len);
2831            }
2832        } else if let (Ok(lo), Ok(hi)) = (a.parse::<usize>(), b.parse::<usize>()) {
2833            // "4-6" = 4 to 6 inclusive
2834            let start = lo.saturating_sub(1).min(len);
2835            let end = hi.min(len).max(start);
2836            return (start, end);
2837        }
2838    } else if let Ok(n) = s.parse::<usize>() {
2839        // "10" = last 10
2840        let start = len.saturating_sub(n);
2841        return (start, len);
2842    }
2843    (len.saturating_sub(25), len)
2844}
2845
2846fn history_path() -> Option<PathBuf> {
2847    if let Ok(home) = std::env::var("HOME") {
2848        return Some(Path::new(&home).join(HISTORY_FILE));
2849    }
2850    #[cfg(windows)]
2851    if let Ok(home) = std::env::var("USERPROFILE") {
2852        return Some(Path::new(&home).join(HISTORY_FILE));
2853    }
2854    None
2855}
2856
2857fn bookmarks_path() -> Option<PathBuf> {
2858    if let Ok(home) = std::env::var("HOME") {
2859        return Some(Path::new(&home).join(BOOKMARKS_FILE));
2860    }
2861    #[cfg(windows)]
2862    if let Ok(home) = std::env::var("USERPROFILE") {
2863        return Some(Path::new(&home).join(BOOKMARKS_FILE));
2864    }
2865    None
2866}
2867
2868fn load_bookmarks() -> Result<HashMap<String, PathBuf>> {
2869    let path = bookmarks_path().context("no home dir for bookmarks")?;
2870    let content = match std::fs::read_to_string(&path) {
2871        Ok(c) => c,
2872        Err(_) => return Ok(HashMap::new()),
2873    };
2874    let mut out = HashMap::new();
2875    for line in content.lines() {
2876        let line = line.trim();
2877        if line.is_empty() || line.starts_with('#') {
2878            continue;
2879        }
2880        if let Some((name, rest)) = line.split_once('\t') {
2881            let name = name.trim().to_string();
2882            let p = PathBuf::from(rest.trim());
2883            if !name.is_empty() && p.is_absolute() {
2884                out.insert(name, p);
2885            }
2886        }
2887    }
2888    Ok(out)
2889}
2890
2891fn save_bookmarks(bookmarks: &HashMap<String, PathBuf>) -> Result<()> {
2892    let path = bookmarks_path().context("no home dir for bookmarks")?;
2893    let mut lines: Vec<String> = bookmarks
2894        .iter()
2895        .map(|(k, v)| format!("{}\t{}", k, v.display()))
2896        .collect();
2897    lines.sort();
2898    std::fs::write(path, lines.join("\n") + "\n")?;
2899    Ok(())
2900}
2901
2902fn repl_config_path() -> Option<PathBuf> {
2903    #[cfg(unix)]
2904    if let Ok(home) = std::env::var("HOME") {
2905        return Some(PathBuf::from(&home).join(REPL_CONFIG_FILE));
2906    }
2907    #[cfg(windows)]
2908    if let Ok(home) = std::env::var("USERPROFILE") {
2909        return Some(PathBuf::from(&home).join(REPL_CONFIG_FILE));
2910    }
2911    None
2912}
2913
2914fn load_repl_config() -> Result<HashMap<String, String>> {
2915    let path = repl_config_path().context("no home dir for REPL config")?;
2916    let content = match std::fs::read_to_string(&path) {
2917        Ok(c) => c,
2918        Err(_) => return Ok(HashMap::new()),
2919    };
2920    let mut out = HashMap::new();
2921    for line in content.lines() {
2922        let line = line.trim();
2923        if line.is_empty() || line.starts_with('#') {
2924            continue;
2925        }
2926        if let Some((k, v)) = line.split_once('=') {
2927            let k = k.trim().to_lowercase();
2928            let v = v.trim().trim_matches('"').to_string();
2929            if !k.is_empty() {
2930                out.insert(k, v);
2931            }
2932        }
2933    }
2934    Ok(out)
2935}
2936
2937fn save_repl_config(cfg: &HashMap<String, String>) -> Result<()> {
2938    let path = repl_config_path().context("no home dir for REPL config")?;
2939    let mut keys: Vec<_> = cfg.keys().collect();
2940    keys.sort();
2941    let lines: Vec<String> = keys
2942        .iter()
2943        .map(|k| {
2944            let v = cfg.get(*k).unwrap();
2945            format!("{}={}", k, v)
2946        })
2947        .collect();
2948    std::fs::write(path, lines.join("\n") + "\n")?;
2949    Ok(())
2950}
2951
2952/// Extract variable/function/class names defined in a code snippet for tab completion.
2953fn extract_defined_names(code: &str, language_id: &str) -> Vec<String> {
2954    let mut names = Vec::new();
2955    for line in code.lines() {
2956        let trimmed = line.trim();
2957        match language_id {
2958            "python" | "py" | "python3" | "py3" => {
2959                // x = ..., def foo(...), class Bar:, import x, from x import y
2960                if let Some(rest) = trimmed.strip_prefix("def ") {
2961                    if let Some(name) = rest.split('(').next() {
2962                        let n = name.trim();
2963                        if !n.is_empty() {
2964                            names.push(n.to_string());
2965                        }
2966                    }
2967                } else if let Some(rest) = trimmed.strip_prefix("class ") {
2968                    let name = rest.split(['(', ':']).next().unwrap_or("").trim();
2969                    if !name.is_empty() {
2970                        names.push(name.to_string());
2971                    }
2972                } else if let Some(rest) = trimmed.strip_prefix("import ") {
2973                    for part in rest.split(',') {
2974                        let name = if let Some(alias) = part.split(" as ").nth(1) {
2975                            alias.trim()
2976                        } else {
2977                            part.trim().split('.').next_back().unwrap_or("")
2978                        };
2979                        if !name.is_empty() {
2980                            names.push(name.to_string());
2981                        }
2982                    }
2983                } else if trimmed.starts_with("from ") && trimmed.contains("import ") {
2984                    if let Some(imports) = trimmed.split("import ").nth(1) {
2985                        for part in imports.split(',') {
2986                            let name = if let Some(alias) = part.split(" as ").nth(1) {
2987                                alias.trim()
2988                            } else {
2989                                part.trim()
2990                            };
2991                            if !name.is_empty() {
2992                                names.push(name.to_string());
2993                            }
2994                        }
2995                    }
2996                } else if let Some(eq_pos) = trimmed.find('=') {
2997                    let lhs = &trimmed[..eq_pos];
2998                    if !lhs.contains('(')
2999                        && !lhs.contains('[')
3000                        && !trimmed[eq_pos..].starts_with("==")
3001                    {
3002                        for part in lhs.split(',') {
3003                            let name = part.trim().split(':').next().unwrap_or("").trim();
3004                            if !name.is_empty()
3005                                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
3006                            {
3007                                names.push(name.to_string());
3008                            }
3009                        }
3010                    }
3011                }
3012            }
3013            "javascript" | "js" | "node" | "typescript" | "ts" => {
3014                // let/const/var x = ..., function foo(...), class Bar
3015                for prefix in ["let ", "const ", "var "] {
3016                    if let Some(rest) = trimmed.strip_prefix(prefix) {
3017                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
3018                        if !name.is_empty() {
3019                            names.push(name.to_string());
3020                        }
3021                    }
3022                }
3023                if let Some(rest) = trimmed.strip_prefix("function ") {
3024                    let name = rest.split('(').next().unwrap_or("").trim();
3025                    if !name.is_empty() {
3026                        names.push(name.to_string());
3027                    }
3028                } else if let Some(rest) = trimmed.strip_prefix("class ") {
3029                    let name = rest.split(['{', ' ']).next().unwrap_or("").trim();
3030                    if !name.is_empty() {
3031                        names.push(name.to_string());
3032                    }
3033                }
3034            }
3035            "rust" | "rs" => {
3036                for prefix in ["let ", "let mut "] {
3037                    if let Some(rest) = trimmed.strip_prefix(prefix) {
3038                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
3039                        if !name.is_empty() {
3040                            names.push(name.to_string());
3041                        }
3042                    }
3043                }
3044                if let Some(rest) = trimmed.strip_prefix("fn ") {
3045                    let name = rest.split(['(', '<']).next().unwrap_or("").trim();
3046                    if !name.is_empty() {
3047                        names.push(name.to_string());
3048                    }
3049                } else if let Some(rest) = trimmed.strip_prefix("struct ") {
3050                    let name = rest.split(['{', '(', '<', ' ']).next().unwrap_or("").trim();
3051                    if !name.is_empty() {
3052                        names.push(name.to_string());
3053                    }
3054                }
3055            }
3056            _ => {
3057                // Generic: catch x = ... assignments
3058                if let Some(eq_pos) = trimmed.find('=') {
3059                    let lhs = trimmed[..eq_pos].trim();
3060                    if !lhs.is_empty()
3061                        && !trimmed[eq_pos..].starts_with("==")
3062                        && lhs
3063                            .chars()
3064                            .all(|c| c.is_alphanumeric() || c == '_' || c == ' ')
3065                        && let Some(name) = lhs.split_whitespace().last()
3066                    {
3067                        names.push(name.to_string());
3068                    }
3069                }
3070            }
3071        }
3072    }
3073    names
3074}
3075
3076#[cfg(test)]
3077mod tests {
3078    use super::*;
3079
3080    #[test]
3081    fn language_aliases_resolve_in_registry() {
3082        let registry = LanguageRegistry::bootstrap();
3083        let aliases = [
3084            "python",
3085            "py",
3086            "python3",
3087            "rust",
3088            "rs",
3089            "go",
3090            "golang",
3091            "csharp",
3092            "cs",
3093            "c#",
3094            "typescript",
3095            "ts",
3096            "javascript",
3097            "js",
3098            "node",
3099            "ruby",
3100            "rb",
3101            "lua",
3102            "bash",
3103            "sh",
3104            "zsh",
3105            "java",
3106            "php",
3107            "kotlin",
3108            "kt",
3109            "c",
3110            "cpp",
3111            "c++",
3112            "swift",
3113            "swiftlang",
3114            "perl",
3115            "pl",
3116            "julia",
3117            "jl",
3118        ];
3119
3120        for alias in aliases {
3121            let spec = LanguageSpec::new(alias);
3122            assert!(
3123                registry.resolve(&spec).is_some(),
3124                "alias {alias} should resolve to a registered language"
3125            );
3126        }
3127    }
3128
3129    #[test]
3130    fn python_multiline_def_requires_blank_line_to_execute() {
3131        let mut p = PendingInput::new();
3132        p.push_line("def fib(n):");
3133        assert!(p.needs_more_input("python"));
3134        p.push_line("    return n");
3135        assert!(p.needs_more_input("python"));
3136        p.push_line(""); // blank line ends block
3137        assert!(!p.needs_more_input("python"));
3138    }
3139
3140    #[test]
3141    fn python_dict_literal_colon_does_not_trigger_block() {
3142        let mut p = PendingInput::new();
3143        p.push_line("x = {'key': 'value'}");
3144        assert!(
3145            !p.needs_more_input("python"),
3146            "dict literal should not trigger multi-line"
3147        );
3148    }
3149
3150    #[test]
3151    fn python_class_block_needs_body() {
3152        let mut p = PendingInput::new();
3153        p.push_line("class Foo:");
3154        assert!(p.needs_more_input("python"));
3155        p.push_line("    pass");
3156        assert!(p.needs_more_input("python")); // still indented
3157        p.push_line(""); // blank line ends
3158        assert!(!p.needs_more_input("python"));
3159    }
3160
3161    #[test]
3162    fn python_if_block_with_dedented_body_is_complete() {
3163        let mut p = PendingInput::new();
3164        p.push_line("if True:");
3165        assert!(p.needs_more_input("python"));
3166        p.push_line("    print('yes')");
3167        assert!(p.needs_more_input("python"));
3168        p.push_line(""); // blank line terminates
3169        assert!(!p.needs_more_input("python"));
3170    }
3171
3172    #[test]
3173    fn python_auto_indents_first_line_after_colon_header() {
3174        let mut p = PendingInput::new();
3175        p.push_line("def cool():");
3176        p.push_line_auto("python", r#"print("ok")"#);
3177        let code = p.take();
3178        assert!(
3179            code.contains("    print(\"ok\")\n"),
3180            "expected auto-indented print line, got:\n{code}"
3181        );
3182    }
3183
3184    #[test]
3185    fn python_auto_indents_nested_blocks() {
3186        let mut p = PendingInput::new();
3187        p.push_line("def bubble_sort(arr):");
3188        p.push_line_auto("python", "n = len(arr)");
3189        p.push_line_auto("python", "for i in range(n):");
3190        p.push_line_auto("python", "for j in range(0, n-i-1):");
3191        p.push_line_auto("python", "if arr[j] > arr[j+1]:");
3192        p.push_line_auto("python", "arr[j], arr[j+1] = arr[j+1], arr[j]");
3193        let code = p.take();
3194        assert!(
3195            code.contains("    n = len(arr)\n"),
3196            "missing indent for len"
3197        );
3198        assert!(
3199            code.contains("    for i in range(n):\n"),
3200            "missing indent for outer loop"
3201        );
3202        assert!(
3203            code.contains("        for j in range(0, n-i-1):\n"),
3204            "missing indent for inner loop"
3205        );
3206        assert!(
3207            code.contains("            if arr[j] > arr[j+1]:\n"),
3208            "missing indent for if"
3209        );
3210        assert!(
3211            code.contains("                arr[j], arr[j+1] = arr[j+1], arr[j]\n"),
3212            "missing indent for swap"
3213        );
3214    }
3215
3216    #[test]
3217    fn python_auto_dedents_else_like_blocks() {
3218        let mut p = PendingInput::new();
3219        p.push_line("def f():");
3220        p.push_line_auto("python", "if True:");
3221        p.push_line_auto("python", "print('yes')");
3222        p.push_line_auto("python", "else:");
3223        let code = p.take();
3224        assert!(
3225            code.contains("    else:\n"),
3226            "expected else to align with if block:\n{code}"
3227        );
3228    }
3229
3230    #[test]
3231    fn python_auto_dedents_return_to_def() {
3232        let mut p = PendingInput::new();
3233        p.push_line("def bubble_sort(arr):");
3234        p.push_line_auto("python", "for i in range(3):");
3235        p.push_line_auto("python", "for j in range(2):");
3236        p.push_line_auto("python", "if j:");
3237        p.push_line_auto("python", "pass");
3238        p.push_line_auto("python", "return arr");
3239        let code = p.take();
3240        assert!(
3241            code.contains("    return arr\n"),
3242            "expected return to align with def block:\n{code}"
3243        );
3244    }
3245
3246    #[test]
3247    fn generic_multiline_tracks_unclosed_delimiters() {
3248        let mut p = PendingInput::new();
3249        p.push_line("func(");
3250        assert!(p.needs_more_input("csharp"));
3251        p.push_line(")");
3252        assert!(!p.needs_more_input("csharp"));
3253    }
3254
3255    #[test]
3256    fn generic_multiline_tracks_trailing_equals() {
3257        let mut p = PendingInput::new();
3258        p.push_line("let x =");
3259        assert!(p.needs_more_input("rust"));
3260        p.push_line("10;");
3261        assert!(!p.needs_more_input("rust"));
3262    }
3263
3264    #[test]
3265    fn generic_multiline_tracks_trailing_dot() {
3266        let mut p = PendingInput::new();
3267        p.push_line("foo.");
3268        assert!(p.needs_more_input("csharp"));
3269        p.push_line("Bar()");
3270        assert!(!p.needs_more_input("csharp"));
3271    }
3272
3273    #[test]
3274    fn generic_multiline_accepts_preprocessor_lines() {
3275        let mut p = PendingInput::new();
3276        p.push_line("#include <stdio.h>");
3277        assert!(
3278            !p.needs_more_input("c"),
3279            "preprocessor lines should not force continuation"
3280        );
3281    }
3282}