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                if let Ok(n) = v.parse::<u32>() {
1350                    state.precision = Some(n.min(32));
1351                }
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 command.starts_with("!!") {
1395            let shell_cmd = command[2..].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 command.starts_with('!') {
1404            let shell_cmd = command[1..].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 {
1602                    if let Ok(cwd) = std::env::current_dir() {
1603                        println!("{}", cwd.display());
1604                    }
1605                }
1606                return Ok(false);
1607            }
1608            "dhist" => {
1609                let n: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(10);
1610                let len = self.dir_stack.len();
1611                let start = len.saturating_sub(n);
1612                if self.dir_stack.is_empty() {
1613                    println!("\x1b[2m(no directory history)\x1b[0m");
1614                } else {
1615                    for (i, p) in self.dir_stack[start..].iter().enumerate() {
1616                        let num = start + i + 1;
1617                        println!("\x1b[2m[{num:>2}]\x1b[0m {}", p.display());
1618                    }
1619                }
1620                return Ok(false);
1621            }
1622            "env" => {
1623                let a = parts.next();
1624                let b = parts.next();
1625                match (a, b) {
1626                    (None, _) => {
1627                        let vars: BTreeMap<String, String> = std::env::vars().collect();
1628                        for (k, v) in vars {
1629                            println!("{k}={v}");
1630                        }
1631                    }
1632                    (Some(var), None) => {
1633                        if let Some((k, v)) = var.split_once('=') {
1634                            unsafe { std::env::set_var(k, v) };
1635                        } else if let Ok(v) = std::env::var(var) {
1636                            println!("{v}");
1637                        }
1638                    }
1639                    (Some(var), Some(val)) => {
1640                        if val == "=" {
1641                            if let Some(v) = parts.next() {
1642                                unsafe { std::env::set_var(var, v) };
1643                            }
1644                        } else if val.starts_with('=') {
1645                            unsafe { std::env::set_var(var, val.trim_start_matches('=')) };
1646                        } else {
1647                            unsafe { std::env::set_var(var, val) };
1648                        }
1649                    }
1650                }
1651                return Ok(false);
1652            }
1653            "bookmark" => {
1654                let arg = parts.next();
1655                match arg {
1656                    Some("-l") => {
1657                        if self.bookmarks.is_empty() {
1658                            println!("\x1b[2m(no bookmarks)\x1b[0m");
1659                        } else {
1660                            let mut names: Vec<_> = self.bookmarks.keys().collect();
1661                            names.sort();
1662                            for name in names {
1663                                let path = self.bookmarks.get(name).unwrap();
1664                                println!("  {name}\t{}", path.display());
1665                            }
1666                        }
1667                    }
1668                    Some("-d") => {
1669                        if let Some(name) = parts.next() {
1670                            if self.bookmarks.remove(name).is_some() {
1671                                let _ = save_bookmarks(&self.bookmarks);
1672                                println!("\x1b[2m[removed bookmark '{name}']\x1b[0m");
1673                            } else {
1674                                println!("\x1b[31m[run]\x1b[0m bookmark '{}' not found", name);
1675                            }
1676                        } else {
1677                            println!("usage: :bookmark -d <name>");
1678                        }
1679                    }
1680                    Some(name) if !name.starts_with('-') => {
1681                        let path = parts
1682                            .next()
1683                            .map(PathBuf::from)
1684                            .or_else(|| std::env::current_dir().ok().map(PathBuf::from));
1685                        if let Some(p) = path {
1686                            if p.is_absolute() {
1687                                self.bookmarks.insert(name.to_string(), p.clone());
1688                                let _ = save_bookmarks(&self.bookmarks);
1689                                println!("\x1b[2m[bookmark '{name}' -> {}]\x1b[0m", p.display());
1690                            } else {
1691                                println!("\x1b[31m[run]\x1b[0m bookmark path must be absolute");
1692                            }
1693                        } else {
1694                            println!("\x1b[31m[run]\x1b[0m could not get current directory");
1695                        }
1696                    }
1697                    _ => {
1698                        println!(
1699                            "usage: :bookmark <name> [path] | :bookmark -l | :bookmark -d <name>"
1700                        );
1701                    }
1702                }
1703                return Ok(false);
1704            }
1705            "load" | "run" => {
1706                if let Some(token) = parts.next() {
1707                    if let Some(code) = self.macros.get(token) {
1708                        self.execute_payload(ExecutionPayload::Inline {
1709                            code: code.clone(),
1710                            args: Vec::new(),
1711                        })?;
1712                    } else {
1713                        let path = if token.starts_with("http://") || token.starts_with("https://")
1714                        {
1715                            match fetch_url_to_temp(token) {
1716                                Ok(p) => p,
1717                                Err(e) => {
1718                                    println!("\x1b[31m[run]\x1b[0m fetch failed: {e}");
1719                                    return Ok(false);
1720                                }
1721                            }
1722                        } else {
1723                            PathBuf::from(token)
1724                        };
1725                        self.execute_payload(ExecutionPayload::File {
1726                            path,
1727                            args: Vec::new(),
1728                        })?;
1729                    }
1730                } else {
1731                    println!("usage: :load <path|url>  or  :run <macro|path|url>");
1732                }
1733                return Ok(false);
1734            }
1735            "edit" => {
1736                let path = if let Some(token) = parts.next() {
1737                    PathBuf::from(token)
1738                } else {
1739                    match edit_temp_file() {
1740                        Ok(p) => p,
1741                        Err(e) => {
1742                            println!("\x1b[31m[run]\x1b[0m edit: {e}");
1743                            return Ok(false);
1744                        }
1745                    }
1746                };
1747                if run_editor(path.as_path()).is_err() {
1748                    println!("\x1b[31m[run]\x1b[0m editor failed or $EDITOR not set");
1749                    return Ok(false);
1750                }
1751                if path.exists() {
1752                    self.execute_payload(ExecutionPayload::File {
1753                        path,
1754                        args: Vec::new(),
1755                    })?;
1756                }
1757                return Ok(false);
1758            }
1759            "last" => {
1760                if let Some(ref s) = self.last_stdout {
1761                    print!("{}", ensure_trailing_newline(s));
1762                } else {
1763                    println!("\x1b[2m(no last output)\x1b[0m");
1764                }
1765                return Ok(false);
1766            }
1767            "logstart" => {
1768                let path = parts.next().map(PathBuf::from).unwrap_or_else(|| {
1769                    std::env::current_dir()
1770                        .unwrap_or_else(|_| PathBuf::from("."))
1771                        .join("run_log.txt")
1772                });
1773                self.log_path = Some(path.clone());
1774                self.log_input(line);
1775                println!("\x1b[2m[logging to {}]\x1b[0m", path.display());
1776                return Ok(false);
1777            }
1778            "logstop" => {
1779                if self.log_path.take().is_some() {
1780                    println!("\x1b[2m[logging stopped]\x1b[0m");
1781                } else {
1782                    println!("\x1b[2m(not logging)\x1b[0m");
1783                }
1784                return Ok(false);
1785            }
1786            "logstate" => {
1787                if let Some(ref p) = self.log_path {
1788                    println!("\x1b[2mlogging: {}\x1b[0m", p.display());
1789                } else {
1790                    println!("\x1b[2m(not logging)\x1b[0m");
1791                }
1792                return Ok(false);
1793            }
1794            "macro" => {
1795                let sub = parts.next();
1796                if sub == Some("run") {
1797                    if let Some(name) = parts.next() {
1798                        if let Some(code) = self.macros.get(name) {
1799                            self.execute_payload(ExecutionPayload::Inline {
1800                                code: code.clone(),
1801                                args: Vec::new(),
1802                            })?;
1803                        } else {
1804                            println!("\x1b[31m[run]\x1b[0m unknown macro: {name}");
1805                        }
1806                    } else {
1807                        println!("usage: :macro run <NAME>");
1808                    }
1809                } else if let Some(name) = sub {
1810                    let len = self.history_entries.len();
1811                    let mut indices: Vec<usize> = Vec::new();
1812                    for part in parts {
1813                        let (s, e) = parse_history_range(part, len);
1814                        for i in s..e {
1815                            indices.push(i);
1816                        }
1817                    }
1818                    indices.sort_unstable();
1819                    indices.dedup();
1820                    let code: String = indices
1821                        .into_iter()
1822                        .filter_map(|i| self.history_entries.get(i))
1823                        .cloned()
1824                        .collect::<Vec<_>>()
1825                        .join("\n");
1826                    if code.is_empty() {
1827                        println!("\x1b[31m[run]\x1b[0m no history entries for range");
1828                    } else {
1829                        self.macros.insert(name.to_string(), code);
1830                        println!("\x1b[2m[macro '{name}' saved]\x1b[0m");
1831                    }
1832                } else {
1833                    println!("usage: :macro <NAME> <range>...  or  :macro run <NAME>");
1834                }
1835                return Ok(false);
1836            }
1837            "time" => {
1838                let code = parts.collect::<Vec<_>>().join(" ").trim().to_string();
1839                if code.is_empty() {
1840                    println!("usage: :time <CODE>");
1841                    return Ok(false);
1842                }
1843                let start = Instant::now();
1844                self.execute_payload(ExecutionPayload::Inline {
1845                    code,
1846                    args: Vec::new(),
1847                })?;
1848                let elapsed = start.elapsed();
1849                println!("\x1b[2m[elapsed: {:?}]\x1b[0m", elapsed);
1850                return Ok(false);
1851            }
1852            "who" => {
1853                let mut names: Vec<_> = self.defined_names.iter().cloned().collect();
1854                names.sort();
1855                if names.is_empty() {
1856                    println!("\x1b[2m(no names tracked)\x1b[0m");
1857                } else {
1858                    for n in &names {
1859                        println!("  {n}");
1860                    }
1861                }
1862                return Ok(false);
1863            }
1864            "whos" => {
1865                let pattern = parts.next();
1866                let mut names: Vec<_> = self.defined_names.iter().cloned().collect();
1867                if let Some(pat) = pattern {
1868                    names.retain(|n| n.contains(pat));
1869                }
1870                names.sort();
1871                if names.is_empty() {
1872                    println!("\x1b[2m(no names tracked)\x1b[0m");
1873                } else {
1874                    for n in &names {
1875                        println!("  {n}");
1876                    }
1877                }
1878                return Ok(false);
1879            }
1880            "xmode" => {
1881                match parts.next().map(|s| s.to_lowercase()) {
1882                    Some(ref m) if m == "plain" => self.xmode = XMode::Plain,
1883                    Some(ref m) if m == "context" => self.xmode = XMode::Context,
1884                    Some(ref m) if m == "verbose" => self.xmode = XMode::Verbose,
1885                    _ => {
1886                        let current = match self.xmode {
1887                            XMode::Plain => "plain",
1888                            XMode::Context => "context",
1889                            XMode::Verbose => "verbose",
1890                        };
1891                        println!(
1892                            "\x1b[2mexception display: {current} (plain | context | verbose)\x1b[0m"
1893                        );
1894                    }
1895                }
1896                return Ok(false);
1897            }
1898            "paste" => {
1899                self.paste_buffer = Some(Vec::new());
1900                println!("\x1b[2m[paste mode — type :end or Ctrl-D to execute]\x1b[0m");
1901                return Ok(false);
1902            }
1903            "end" => {
1904                if self.paste_buffer.is_some() {
1905                    // Handled in main loop (needs editor); show message if somehow we get here
1906                    println!("\x1b[2m[paste done]\x1b[0m");
1907                } else {
1908                    println!("\x1b[31m[run]\x1b[0m not in paste mode");
1909                }
1910                return Ok(false);
1911            }
1912            "precision" => {
1913                match parts.next() {
1914                    None => match self.precision {
1915                        Some(n) => println!("\x1b[2mprecision: {n}\x1b[0m"),
1916                        None => println!("\x1b[2mprecision: (default)\x1b[0m"),
1917                    },
1918                    Some(s) => {
1919                        if let Ok(n) = s.parse::<u32>() {
1920                            let n = n.min(32);
1921                            self.precision = Some(n);
1922                            let mut cfg = load_repl_config().unwrap_or_default();
1923                            cfg.insert("precision".to_string(), n.to_string());
1924                            if save_repl_config(&cfg).is_err() {
1925                                println!("\x1b[31m[run]\x1b[0m failed to save config");
1926                            } else {
1927                                println!("\x1b[2m[precision = {n}]\x1b[0m");
1928                            }
1929                        } else {
1930                            println!("\x1b[31m[run]\x1b[0m precision must be a number (0–32)");
1931                        }
1932                    }
1933                }
1934                return Ok(false);
1935            }
1936            "config" => {
1937                let key = parts.next().map(|s| s.to_lowercase());
1938                let val = parts.next();
1939                match (key.as_deref(), val) {
1940                    (None, _) => {
1941                        let detect = if self.detect_enabled { "on" } else { "off" };
1942                        let xmode = match self.xmode {
1943                            XMode::Plain => "plain",
1944                            XMode::Context => "context",
1945                            XMode::Verbose => "verbose",
1946                        };
1947                        let precision = self
1948                            .precision
1949                            .map(|n| n.to_string())
1950                            .unwrap_or_else(|| "default".to_string());
1951                        let numbered = if self.numbered_prompts { "on" } else { "off" };
1952                        println!("\x1b[2mdetect\t{detect}\x1b[0m");
1953                        println!("\x1b[2mxmode\t{xmode}\x1b[0m");
1954                        println!("\x1b[2mprecision\t{precision}\x1b[0m");
1955                        println!("\x1b[2mnumbered_prompts\t{numbered}\x1b[0m");
1956                    }
1957                    (Some(k), None) => {
1958                        let v: Option<String> = match k {
1959                            "detect" => {
1960                                Some(if self.detect_enabled { "on" } else { "off" }.to_string())
1961                            }
1962                            "xmode" => Some(
1963                                match self.xmode {
1964                                    XMode::Plain => "plain",
1965                                    XMode::Context => "context",
1966                                    XMode::Verbose => "verbose",
1967                                }
1968                                .to_string(),
1969                            ),
1970                            "precision" => Some(
1971                                self.precision
1972                                    .map(|n| n.to_string())
1973                                    .unwrap_or_else(|| "default".to_string()),
1974                            ),
1975                            "numbered_prompts" => {
1976                                Some(if self.numbered_prompts { "on" } else { "off" }.to_string())
1977                            }
1978                            _ => None,
1979                        };
1980                        if let Some(v) = v {
1981                            println!("{v}");
1982                        } else {
1983                            println!("\x1b[31m[run]\x1b[0m unknown config key: {k}");
1984                        }
1985                    }
1986                    (Some(k), Some(v)) => {
1987                        let mut cfg = load_repl_config().unwrap_or_default();
1988                        match k {
1989                            "detect" => {
1990                                self.detect_enabled =
1991                                    matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
1992                                cfg.insert("detect".to_string(), v.to_string());
1993                            }
1994                            "xmode" => {
1995                                self.xmode = match v.to_lowercase().as_str() {
1996                                    "plain" => XMode::Plain,
1997                                    "context" => XMode::Context,
1998                                    _ => XMode::Verbose,
1999                                };
2000                                cfg.insert("xmode".to_string(), v.to_string());
2001                            }
2002                            "precision" => {
2003                                if let Ok(n) = v.parse::<u32>() {
2004                                    self.precision = Some(n.min(32));
2005                                    cfg.insert(
2006                                        "precision".to_string(),
2007                                        self.precision.unwrap().to_string(),
2008                                    );
2009                                }
2010                            }
2011                            "numbered_prompts" => {
2012                                self.numbered_prompts =
2013                                    matches!(v.to_lowercase().as_str(), "on" | "true" | "1");
2014                                cfg.insert("numbered_prompts".to_string(), v.to_string());
2015                            }
2016                            _ => {
2017                                println!("\x1b[31m[run]\x1b[0m unknown config key: {k}");
2018                                return Ok(false);
2019                            }
2020                        }
2021                        if save_repl_config(&cfg).is_err() {
2022                            println!("\x1b[31m[run]\x1b[0m failed to save config");
2023                        } else {
2024                            println!("\x1b[2m[{k} = {}]\x1b[0m", v.trim());
2025                        }
2026                    }
2027                }
2028                return Ok(false);
2029            }
2030            "save" => {
2031                if let Some(token) = parts.next() {
2032                    let path = Path::new(token);
2033                    match self.save_session(path) {
2034                        Ok(count) => println!(
2035                            "\x1b[2m[saved {count} entries to {}]\x1b[0m",
2036                            path.display()
2037                        ),
2038                        Err(e) => println!("error saving session: {e}"),
2039                    }
2040                } else {
2041                    println!("usage: :save <path>");
2042                }
2043                return Ok(false);
2044            }
2045            "history" => {
2046                let rest: Vec<&str> = parts.collect();
2047                let mut grep_pattern: Option<&str> = None;
2048                let mut out_file: Option<&str> = None;
2049                let mut unique = false;
2050                let mut range_or_limit: Option<String> = None;
2051                let mut i = 0;
2052                while i < rest.len() {
2053                    match rest[i] {
2054                        "-g" => {
2055                            i += 1;
2056                            if i < rest.len() {
2057                                grep_pattern = Some(rest[i]);
2058                                i += 1;
2059                            } else {
2060                                println!("usage: :history -g <pattern>");
2061                                return Ok(false);
2062                            }
2063                        }
2064                        "-f" => {
2065                            i += 1;
2066                            if i < rest.len() {
2067                                out_file = Some(rest[i]);
2068                                i += 1;
2069                            } else {
2070                                println!("usage: :history -f <file>");
2071                                return Ok(false);
2072                            }
2073                        }
2074                        "-u" => {
2075                            unique = true;
2076                            i += 1;
2077                        }
2078                        _ => {
2079                            range_or_limit = Some(rest[i].to_string());
2080                            i += 1;
2081                        }
2082                    }
2083                }
2084                let entries = &self.history_entries;
2085                let len = entries.len();
2086                let (start, end) = if let Some(ref r) = range_or_limit {
2087                    parse_history_range(r, len)
2088                } else {
2089                    (len.saturating_sub(25), len)
2090                };
2091                let end = end.min(len);
2092                let slice = if start < len && start < end {
2093                    &entries[start..end]
2094                } else {
2095                    &entries[0..0]
2096                };
2097                let mut selected: Vec<(usize, &String)> = slice
2098                    .iter()
2099                    .enumerate()
2100                    .map(|(i, e)| (start + i + 1, e))
2101                    .filter(|(_, e)| grep_pattern.map(|p| e.contains(p)).unwrap_or(true))
2102                    .collect();
2103                if unique {
2104                    let mut seen = HashSet::new();
2105                    selected.retain(|(_, e)| seen.insert((*e).clone()));
2106                }
2107                if let Some(path) = out_file {
2108                    let path = Path::new(path);
2109                    if let Ok(mut f) = std::fs::OpenOptions::new()
2110                        .create(true)
2111                        .append(true)
2112                        .open(path)
2113                    {
2114                        use std::io::Write;
2115                        for (_, e) in &selected {
2116                            let _ = writeln!(f, "{e}");
2117                        }
2118                        println!(
2119                            "\x1b[2m[appended {} entries to {}]\x1b[0m",
2120                            selected.len(),
2121                            path.display()
2122                        );
2123                    } else {
2124                        println!(
2125                            "\x1b[31m[run]\x1b[0m could not open {} for writing",
2126                            path.display()
2127                        );
2128                    }
2129                } else {
2130                    if selected.is_empty() {
2131                        println!("\x1b[2m(no history)\x1b[0m");
2132                    } else {
2133                        for (num, entry) in selected {
2134                            let first_line = entry.lines().next().unwrap_or(entry.as_str());
2135                            let is_multiline = entry.contains('\n');
2136                            if is_multiline {
2137                                println!(
2138                                    "\x1b[2m[{num:>4}]\x1b[0m {first_line} \x1b[2m(...)\x1b[0m"
2139                                );
2140                            } else {
2141                                println!("\x1b[2m[{num:>4}]\x1b[0m {entry}");
2142                            }
2143                        }
2144                    }
2145                }
2146                return Ok(false);
2147            }
2148            "install" => {
2149                if let Some(pkg) = parts.next() {
2150                    self.install_package(pkg);
2151                } else {
2152                    println!("usage: :install <package>");
2153                }
2154                return Ok(false);
2155            }
2156            "bench" => {
2157                let n: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(10);
2158                let code = parts.collect::<Vec<_>>().join(" ");
2159                if code.is_empty() {
2160                    println!("usage: :bench [N] <code>");
2161                    println!("  Runs <code> N times (default: 10) and reports timing stats.");
2162                } else {
2163                    self.bench_code(&code, n)?;
2164                }
2165                return Ok(false);
2166            }
2167            "type" | "which" => {
2168                let lang = &self.current_language;
2169                println!(
2170                    "\x1b[1m{}\x1b[0m \x1b[2m({})\x1b[0m",
2171                    lang.canonical_id(),
2172                    if self.sessions.contains_key(lang.canonical_id()) {
2173                        "session active"
2174                    } else {
2175                        "no session"
2176                    }
2177                );
2178                return Ok(false);
2179            }
2180            alias => {
2181                let spec = LanguageSpec::new(alias);
2182                if self.registry.resolve(&spec).is_some() {
2183                    self.switch_language(spec)?;
2184                    return Ok(false);
2185                }
2186                println!("unknown command: :{alias}. Type :help for help.");
2187            }
2188        }
2189
2190        Ok(false)
2191    }
2192
2193    fn switch_language(&mut self, spec: LanguageSpec) -> Result<()> {
2194        if self.current_language.canonical_id() == spec.canonical_id() {
2195            println!("already using {}", spec.canonical_id());
2196            return Ok(());
2197        }
2198        if self.registry.resolve(&spec).is_none() {
2199            let available = self.registry.known_languages().join(", ");
2200            bail!(
2201                "language '{}' not supported. Available: {available}",
2202                spec.canonical_id()
2203            );
2204        }
2205        self.current_language = spec;
2206        println!("switched to {}", self.current_language.canonical_id());
2207        Ok(())
2208    }
2209
2210    fn reset_current_session(&mut self) {
2211        let key = self.current_language.canonical_id().to_string();
2212        if let Some(mut session) = self.sessions.remove(&key) {
2213            let _ = session.shutdown();
2214        }
2215    }
2216
2217    fn execute_snippet(&mut self, code: &str) -> Result<()> {
2218        if self.detect_enabled
2219            && let Some(detected) = crate::detect::detect_language_from_snippet(code)
2220            && detected != self.current_language.canonical_id()
2221        {
2222            let spec = LanguageSpec::new(detected.to_string());
2223            if self.registry.resolve(&spec).is_some() {
2224                println!(
2225                    "[auto-detect] switching {} -> {}",
2226                    self.current_language.canonical_id(),
2227                    spec.canonical_id()
2228                );
2229                self.current_language = spec;
2230            }
2231        }
2232
2233        // Track defined variable names for tab completion
2234        self.defined_names.extend(extract_defined_names(
2235            code,
2236            self.current_language.canonical_id(),
2237        ));
2238
2239        let payload = ExecutionPayload::Inline {
2240            code: code.to_string(),
2241            args: Vec::new(),
2242        };
2243        self.execute_payload(payload)
2244    }
2245
2246    fn session_var_names(&self) -> Vec<String> {
2247        self.defined_names.iter().cloned().collect()
2248    }
2249
2250    fn execute_payload(&mut self, payload: ExecutionPayload) -> Result<()> {
2251        let language = self.current_language.clone();
2252        let outcome = match payload {
2253            ExecutionPayload::Inline { code, .. } => {
2254                if self.engine_supports_sessions(&language)? {
2255                    self.eval_in_session(&language, &code)?
2256                } else {
2257                    let engine = self
2258                        .registry
2259                        .resolve(&language)
2260                        .context("language engine not found")?;
2261                    engine.execute(&ExecutionPayload::Inline {
2262                        code,
2263                        args: Vec::new(),
2264                    })?
2265                }
2266            }
2267            ExecutionPayload::File { ref path, .. } => {
2268                // Read the file and feed it through the session so variables persist
2269                if self.engine_supports_sessions(&language)? {
2270                    let code = std::fs::read_to_string(path)
2271                        .with_context(|| format!("failed to read file: {}", path.display()))?;
2272                    println!("\x1b[2m[loaded {}]\x1b[0m", path.display());
2273                    self.eval_in_session(&language, &code)?
2274                } else {
2275                    let engine = self
2276                        .registry
2277                        .resolve(&language)
2278                        .context("language engine not found")?;
2279                    engine.execute(&payload)?
2280                }
2281            }
2282            ExecutionPayload::Stdin { code, .. } => {
2283                if self.engine_supports_sessions(&language)? {
2284                    self.eval_in_session(&language, &code)?
2285                } else {
2286                    let engine = self
2287                        .registry
2288                        .resolve(&language)
2289                        .context("language engine not found")?;
2290                    engine.execute(&ExecutionPayload::Stdin {
2291                        code,
2292                        args: Vec::new(),
2293                    })?
2294                }
2295            }
2296        };
2297        render_outcome(&outcome, self.xmode);
2298        self.last_stdout = Some(outcome.stdout.clone());
2299        self.in_count += 1;
2300        Ok(())
2301    }
2302
2303    fn engine_supports_sessions(&self, language: &LanguageSpec) -> Result<bool> {
2304        Ok(self
2305            .registry
2306            .resolve(language)
2307            .context("language engine not found")?
2308            .supports_sessions())
2309    }
2310
2311    fn eval_in_session(&mut self, language: &LanguageSpec, code: &str) -> Result<ExecutionOutcome> {
2312        use std::collections::hash_map::Entry;
2313        let key = language.canonical_id().to_string();
2314        match self.sessions.entry(key) {
2315            Entry::Occupied(mut entry) => entry.get_mut().eval(code),
2316            Entry::Vacant(entry) => {
2317                let engine = self
2318                    .registry
2319                    .resolve(language)
2320                    .context("language engine not found")?;
2321                let mut session = engine.start_session().with_context(|| {
2322                    format!("failed to start {} session", language.canonical_id())
2323                })?;
2324                let outcome = session.eval(code)?;
2325                entry.insert(session);
2326                Ok(outcome)
2327            }
2328        }
2329    }
2330
2331    /// Run introspection (:? EXPR). Python: help(expr) in session. Others: not available.
2332    fn run_introspect(&mut self, expr: &str) -> Result<()> {
2333        let language = self.current_language.clone();
2334        let lang = language.canonical_id();
2335        if lang == "python" {
2336            let code = format!("help({expr})");
2337            let outcome = self.eval_in_session(&language, &code)?;
2338            render_outcome(&outcome, self.xmode);
2339            Ok(())
2340        } else {
2341            println!(
2342                "\x1b[2mIntrospection not available for {lang}. Use :? in a Python session.\x1b[0m"
2343            );
2344            Ok(())
2345        }
2346    }
2347
2348    /// Run :debug [CODE]. Python: pdb on temp file. Others: not available.
2349    fn run_debug(&self, code: &str) -> Result<()> {
2350        let lang = self.current_language.canonical_id();
2351        if lang != "python" {
2352            println!(
2353                "\x1b[2mDebug not available for {lang}. Use :debug in a Python session (pdb).\x1b[0m"
2354            );
2355            return Ok(());
2356        }
2357        let mut tmp = tempfile::NamedTempFile::new().context("create temp file for :debug")?;
2358        tmp.as_file_mut()
2359            .write_all(code.as_bytes())
2360            .context("write debug script")?;
2361        let path = tmp.path();
2362        let status = Command::new("python3")
2363            .args(["-m", "pdb", path.to_str().unwrap_or("")])
2364            .stdin(Stdio::inherit())
2365            .stdout(Stdio::inherit())
2366            .stderr(Stdio::inherit())
2367            .status()
2368            .context("run pdb")?;
2369        if !status.success() {
2370            println!("\x1b[2m[pdb exit {status}]\x1b[0m");
2371        }
2372        Ok(())
2373    }
2374
2375    fn print_languages(&self) {
2376        let mut languages = self.registry.known_languages();
2377        languages.sort();
2378        println!("available languages: {}", languages.join(", "));
2379    }
2380
2381    fn print_versions(&self, language: Option<LanguageSpec>) -> Result<()> {
2382        println!("language toolchain versions...\n");
2383
2384        let mut available = 0u32;
2385        let mut missing = 0u32;
2386
2387        let mut languages: Vec<String> = if let Some(lang) = language {
2388            vec![lang.canonical_id().to_string()]
2389        } else {
2390            self.registry
2391                .known_languages()
2392                .into_iter()
2393                .map(|value| value.to_string())
2394                .collect()
2395        };
2396        languages.sort();
2397
2398        for lang_id in &languages {
2399            let spec = LanguageSpec::new(lang_id.to_string());
2400            if let Some(engine) = self.registry.resolve(&spec) {
2401                match engine.toolchain_version() {
2402                    Ok(Some(version)) => {
2403                        available += 1;
2404                        println!(
2405                            "  [\x1b[32m OK \x1b[0m] {:<14} {} - {}",
2406                            engine.display_name(),
2407                            lang_id,
2408                            version
2409                        );
2410                    }
2411                    Ok(None) => {
2412                        available += 1;
2413                        println!(
2414                            "  [\x1b[33m ?? \x1b[0m] {:<14} {} - unknown",
2415                            engine.display_name(),
2416                            lang_id
2417                        );
2418                    }
2419                    Err(_) => {
2420                        missing += 1;
2421                        println!(
2422                            "  [\x1b[31mMISS\x1b[0m] {:<14} {}",
2423                            engine.display_name(),
2424                            lang_id
2425                        );
2426                    }
2427                }
2428            }
2429        }
2430
2431        println!();
2432        println!(
2433            "  {} available, {} missing, {} total",
2434            available,
2435            missing,
2436            available + missing
2437        );
2438
2439        if missing > 0 {
2440            println!("\n  Tip: Install missing toolchains to enable those languages.");
2441        }
2442
2443        Ok(())
2444    }
2445
2446    fn install_package(&self, package: &str) {
2447        let lang_id = self.current_language.canonical_id();
2448        let override_key = format!("RUN_INSTALL_COMMAND_{}", lang_id.to_ascii_uppercase());
2449        let override_value = std::env::var(&override_key).ok();
2450        let Some(mut cmd) = build_install_command(lang_id, package) else {
2451            if override_value.is_some() {
2452                println!(
2453                    "Error: {override_key} is set but could not be parsed.\n\
2454                     Provide a valid command, e.g. {override_key}=\"uv pip install {{package}}\""
2455                );
2456                return;
2457            }
2458            println!(
2459                "No package manager available for '{lang_id}'.\n\
2460                 Tip: set {override_key}=\"<cmd> {{package}}\"",
2461            );
2462            return;
2463        };
2464
2465        println!("\x1b[36m[run]\x1b[0m Installing '{package}' for {lang_id}...");
2466
2467        match cmd
2468            .stdin(std::process::Stdio::inherit())
2469            .stdout(std::process::Stdio::inherit())
2470            .stderr(std::process::Stdio::inherit())
2471            .status()
2472        {
2473            Ok(status) if status.success() => {
2474                println!("\x1b[32m[run]\x1b[0m Successfully installed '{package}'");
2475            }
2476            Ok(_) => {
2477                println!("\x1b[31m[run]\x1b[0m Failed to install '{package}'");
2478            }
2479            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2480                let program = cmd.get_program().to_string_lossy();
2481                println!("\x1b[31m[run]\x1b[0m Package manager not found: {program}");
2482                println!("Tip: install it or set {override_key}=\"<cmd> {{package}}\"");
2483            }
2484            Err(e) => {
2485                println!("\x1b[31m[run]\x1b[0m Error running package manager: {e}");
2486            }
2487        }
2488    }
2489
2490    fn bench_code(&mut self, code: &str, iterations: u32) -> Result<()> {
2491        let language = self.current_language.clone();
2492
2493        // Warmup
2494        let warmup = self.eval_in_session(&language, code)?;
2495        if !warmup.success() {
2496            println!("\x1b[31mError:\x1b[0m Code failed during warmup");
2497            if !warmup.stderr.is_empty() {
2498                print!("{}", warmup.stderr);
2499            }
2500            return Ok(());
2501        }
2502        println!("\x1b[2m  warmup: {}ms\x1b[0m", warmup.duration.as_millis());
2503
2504        let mut times: Vec<f64> = Vec::with_capacity(iterations as usize);
2505        for i in 0..iterations {
2506            let outcome = self.eval_in_session(&language, code)?;
2507            let ms = outcome.duration.as_secs_f64() * 1000.0;
2508            times.push(ms);
2509            if i < 3 || i == iterations - 1 {
2510                println!("\x1b[2m  run {}: {:.2}ms\x1b[0m", i + 1, ms);
2511            }
2512        }
2513
2514        times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2515        let total: f64 = times.iter().sum();
2516        let avg = total / times.len() as f64;
2517        let min = times.first().copied().unwrap_or(0.0);
2518        let max = times.last().copied().unwrap_or(0.0);
2519        let median = if times.len().is_multiple_of(2) && times.len() >= 2 {
2520            (times[times.len() / 2 - 1] + times[times.len() / 2]) / 2.0
2521        } else {
2522            times[times.len() / 2]
2523        };
2524        let variance: f64 =
2525            times.iter().map(|t| (t - avg).powi(2)).sum::<f64>() / times.len() as f64;
2526        let stddev = variance.sqrt();
2527
2528        println!();
2529        println!("\x1b[1mResults ({iterations} runs):\x1b[0m");
2530        println!("  min:    \x1b[32m{min:.2}ms\x1b[0m");
2531        println!("  max:    \x1b[33m{max:.2}ms\x1b[0m");
2532        println!("  avg:    \x1b[36m{avg:.2}ms\x1b[0m");
2533        println!("  median: \x1b[36m{median:.2}ms\x1b[0m");
2534        println!("  stddev: {stddev:.2}ms");
2535        Ok(())
2536    }
2537
2538    fn log_input(&self, line: &str) {
2539        if let Some(ref p) = self.log_path {
2540            let _ = std::fs::OpenOptions::new()
2541                .create(true)
2542                .append(true)
2543                .open(p)
2544                .and_then(|mut f| writeln!(f, "{line}"));
2545        }
2546    }
2547
2548    fn save_session(&self, path: &Path) -> Result<usize> {
2549        use std::io::Write;
2550        let mut file = std::fs::File::create(path)
2551            .with_context(|| format!("failed to create {}", path.display()))?;
2552        let count = self.history_entries.len();
2553        for entry in &self.history_entries {
2554            writeln!(file, "{entry}")?;
2555        }
2556        Ok(count)
2557    }
2558
2559    fn print_help(&self) {
2560        println!("\x1b[1mCommands\x1b[0m");
2561        for (name, desc) in CMD_HELP {
2562            println!("  \x1b[36m:{name}\x1b[0m  \x1b[2m{desc}\x1b[0m");
2563        }
2564        println!("\x1b[2mLanguage shortcuts: :py, :js, :rs, :go, :cpp, :java, ...\x1b[0m");
2565        println!(
2566            "\x1b[2mIn session languages (e.g. Python), _ is the last expression result.\x1b[0m"
2567        );
2568    }
2569
2570    fn print_cmd_help(cmd_name: &str) {
2571        let key = cmd_name.trim_start_matches(':').trim().to_lowercase();
2572        for (name, desc) in CMD_HELP {
2573            if name.to_lowercase() == key {
2574                println!("  \x1b[36m:{name}\x1b[0m  \x1b[2m{desc}\x1b[0m");
2575                return;
2576            }
2577        }
2578        println!("\x1b[31m[run]\x1b[0m unknown command :{cmd_name}");
2579    }
2580
2581    fn print_commands_machine() {
2582        for (name, desc) in CMD_HELP {
2583            println!(":{name}\t{desc}");
2584        }
2585    }
2586
2587    fn print_quickref() {
2588        println!("\x1b[1mQuick reference\x1b[0m");
2589        for (name, desc) in CMD_HELP {
2590            println!("  :{name}\t{desc}");
2591        }
2592        println!(
2593            "\x1b[2m:py :js :rs :go :cpp :java ...  In Python (session), _ = last result.\x1b[0m"
2594        );
2595    }
2596
2597    fn shutdown(&mut self) {
2598        for (_, mut session) in self.sessions.drain() {
2599            let _ = session.shutdown();
2600        }
2601    }
2602}
2603
2604/// Strip REPL prompts (>>> and ...) from pasted lines and dedent.
2605fn strip_paste_prompts(lines: &[String]) -> String {
2606    let stripped: Vec<String> = lines
2607        .iter()
2608        .map(|s| {
2609            let t = s.trim_start();
2610            let t = t.strip_prefix(">>>").map(|r| r.trim_start()).unwrap_or(t);
2611            let t = t.strip_prefix("...").map(|r| r.trim_start()).unwrap_or(t);
2612            t.to_string()
2613        })
2614        .collect();
2615    let non_empty: Vec<&str> = stripped
2616        .iter()
2617        .map(String::as_str)
2618        .filter(|s| !s.is_empty())
2619        .collect();
2620    if non_empty.is_empty() {
2621        return stripped.join("\n");
2622    }
2623    let min_indent = non_empty
2624        .iter()
2625        .map(|s| s.len() - s.trim_start().len())
2626        .min()
2627        .unwrap_or(0);
2628    let out: Vec<String> = stripped
2629        .iter()
2630        .map(|s| {
2631            if s.is_empty() {
2632                s.clone()
2633            } else {
2634                let n = (s.len() - s.trim_start().len()).min(min_indent);
2635                s[n..].to_string()
2636            }
2637        })
2638        .collect();
2639    out.join("\n")
2640}
2641
2642fn apply_xmode(stderr: &str, xmode: XMode) -> String {
2643    let lines: Vec<&str> = stderr.lines().collect();
2644    match xmode {
2645        XMode::Plain => lines.first().map(|s| (*s).to_string()).unwrap_or_default(),
2646        XMode::Context => lines.iter().take(5).cloned().collect::<Vec<_>>().join("\n"),
2647        XMode::Verbose => stderr.to_string(),
2648    }
2649}
2650
2651/// Render execution outcome. Stdout is passed through unchanged so engine ANSI/rich output is preserved.
2652fn render_outcome(outcome: &ExecutionOutcome, xmode: XMode) {
2653    if !outcome.stdout.is_empty() {
2654        print!("{}", ensure_trailing_newline(&outcome.stdout));
2655    }
2656    if !outcome.stderr.is_empty() {
2657        let formatted =
2658            output::format_stderr(&outcome.language, &outcome.stderr, outcome.success());
2659        let trimmed = apply_xmode(&formatted, xmode);
2660        if !trimmed.is_empty() {
2661            eprint!("\x1b[31m{}\x1b[0m", ensure_trailing_newline(&trimmed));
2662        }
2663    }
2664
2665    let millis = outcome.duration.as_millis();
2666    if let Some(code) = outcome.exit_code
2667        && code != 0
2668    {
2669        println!("\x1b[2m[exit {code}] {}\x1b[0m", format_duration(millis));
2670        return;
2671    }
2672
2673    // Show execution timing
2674    if millis > 0 {
2675        println!("\x1b[2m{}\x1b[0m", format_duration(millis));
2676    }
2677}
2678
2679fn format_duration(millis: u128) -> String {
2680    if millis >= 60_000 {
2681        let mins = millis / 60_000;
2682        let secs = (millis % 60_000) / 1000;
2683        format!("{mins}m {secs}s")
2684    } else if millis >= 1000 {
2685        let secs = millis as f64 / 1000.0;
2686        format!("{secs:.2}s")
2687    } else {
2688        format!("{millis}ms")
2689    }
2690}
2691
2692fn ensure_trailing_newline(text: &str) -> String {
2693    if text.ends_with('\n') {
2694        text.to_string()
2695    } else {
2696        let mut owned = text.to_string();
2697        owned.push('\n');
2698        owned
2699    }
2700}
2701
2702/// Run $EDITOR (or vi/notepad) on the given path. Blocks until editor exits.
2703fn run_editor(path: &Path) -> Result<()> {
2704    let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
2705        #[cfg(unix)]
2706        let default = "vi";
2707        #[cfg(windows)]
2708        let default = "notepad";
2709        default.to_string()
2710    });
2711    let path_str = path.to_string_lossy();
2712    let status = Command::new(&editor)
2713        .arg(path_str.as_ref())
2714        .stdin(std::process::Stdio::inherit())
2715        .stdout(std::process::Stdio::inherit())
2716        .stderr(std::process::Stdio::inherit())
2717        .status()
2718        .context("run editor")?;
2719    if !status.success() {
2720        bail!("editor exited with {}", status);
2721    }
2722    Ok(())
2723}
2724
2725/// Create a temp file, run the editor on it, return the path (temp file is kept).
2726fn edit_temp_file() -> Result<PathBuf> {
2727    let tmp = tempfile::NamedTempFile::new().context("create temp file for :edit")?;
2728    let path = tmp.path().to_path_buf();
2729    run_editor(&path)?;
2730    std::mem::forget(tmp);
2731    Ok(path)
2732}
2733
2734/// Fetch URL to a temp file and return its path. Caller runs and then temp file is left in /tmp.
2735fn fetch_url_to_temp(url: &str) -> Result<PathBuf> {
2736    let tmp = tempfile::NamedTempFile::new().context("create temp file for :load url")?;
2737    let path = tmp.path().to_path_buf();
2738
2739    #[cfg(unix)]
2740    let ok = Command::new("curl")
2741        .args(["-sSL", "-o", path.to_str().unwrap_or(""), url])
2742        .status()
2743        .map(|s| s.success())
2744        .unwrap_or(false);
2745
2746    #[cfg(windows)]
2747    let ok = Command::new("curl")
2748        .args(["-sSL", "-o", path.to_str().unwrap_or(""), url])
2749        .status()
2750        .map(|s| s.success())
2751        .unwrap_or_else(|_| {
2752            // Fallback: PowerShell
2753            let ps = format!(
2754                "Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing",
2755                url.replace('\'', "''"),
2756                path.to_string_lossy().replace('\'', "''")
2757            );
2758            Command::new("powershell")
2759                .args(["-NoProfile", "-Command", &ps])
2760                .status()
2761                .map(|s| s.success())
2762                .unwrap_or(false)
2763        });
2764
2765    if !ok {
2766        let _ = tmp.close();
2767        bail!("fetch failed (curl or download failed)");
2768    }
2769    std::mem::forget(tmp);
2770    Ok(path)
2771}
2772
2773/// Run a shell command. If `capture` is true, run and print stdout/stderr; otherwise inherit.
2774fn run_shell(cmd: &str, capture: bool) {
2775    #[cfg(unix)]
2776    let mut c = Command::new("sh");
2777    #[cfg(unix)]
2778    c.arg("-c").arg(cmd);
2779
2780    #[cfg(windows)]
2781    let mut c = {
2782        let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
2783        let mut com = Command::new(shell);
2784        com.arg("/c").arg(cmd);
2785        com
2786    };
2787
2788    if capture {
2789        match c
2790            .stdout(std::process::Stdio::piped())
2791            .stderr(std::process::Stdio::piped())
2792            .output()
2793        {
2794            Ok(out) => {
2795                let _ = std::io::stdout().write_all(&out.stdout);
2796                let _ = std::io::stderr().write_all(&out.stderr);
2797                if let Some(code) = out.status.code() {
2798                    if code != 0 {
2799                        println!("\x1b[2m[exit {code}]\x1b[0m");
2800                    }
2801                }
2802            }
2803            Err(e) => {
2804                eprintln!("\x1b[31m[run]\x1b[0m shell: {e}");
2805            }
2806        }
2807    } else {
2808        c.stdin(std::process::Stdio::inherit())
2809            .stdout(std::process::Stdio::inherit())
2810            .stderr(std::process::Stdio::inherit());
2811        if let Err(e) = c.status() {
2812            eprintln!("\x1b[31m[run]\x1b[0m shell: {e}");
2813        }
2814    }
2815}
2816
2817/// Parse :history range: "4-6", "4-", "-6", or "10" (last n). 1-based; returns (start, end) 0-based for entries[start..end].
2818fn parse_history_range(s: &str, len: usize) -> (usize, usize) {
2819    if s.contains('-') {
2820        let (a, b) = s.split_once('-').unwrap_or((s, ""));
2821        let a = a.trim();
2822        let b = b.trim();
2823        if a.is_empty() && b.is_empty() {
2824            return (len.saturating_sub(25), len);
2825        }
2826        if b.is_empty() {
2827            // "4-" = from 4 to end
2828            if let Ok(n) = a.parse::<usize>() {
2829                let start = n.saturating_sub(1).min(len);
2830                return (start, len);
2831            }
2832        } else if a.is_empty() {
2833            // "-6" = last 6
2834            if let Ok(n) = b.parse::<usize>() {
2835                let start = len.saturating_sub(n);
2836                return (start, len);
2837            }
2838        } else if let (Ok(lo), Ok(hi)) = (a.parse::<usize>(), b.parse::<usize>()) {
2839            // "4-6" = 4 to 6 inclusive
2840            let start = lo.saturating_sub(1).min(len);
2841            let end = hi.min(len).max(start);
2842            return (start, end);
2843        }
2844    } else if let Ok(n) = s.parse::<usize>() {
2845        // "10" = last 10
2846        let start = len.saturating_sub(n);
2847        return (start, len);
2848    }
2849    (len.saturating_sub(25), len)
2850}
2851
2852fn history_path() -> Option<PathBuf> {
2853    if let Ok(home) = std::env::var("HOME") {
2854        return Some(Path::new(&home).join(HISTORY_FILE));
2855    }
2856    #[cfg(windows)]
2857    if let Ok(home) = std::env::var("USERPROFILE") {
2858        return Some(Path::new(&home).join(HISTORY_FILE));
2859    }
2860    None
2861}
2862
2863fn bookmarks_path() -> Option<PathBuf> {
2864    if let Ok(home) = std::env::var("HOME") {
2865        return Some(Path::new(&home).join(BOOKMARKS_FILE));
2866    }
2867    #[cfg(windows)]
2868    if let Ok(home) = std::env::var("USERPROFILE") {
2869        return Some(Path::new(&home).join(BOOKMARKS_FILE));
2870    }
2871    None
2872}
2873
2874fn load_bookmarks() -> Result<HashMap<String, PathBuf>> {
2875    let path = bookmarks_path().context("no home dir for bookmarks")?;
2876    let content = match std::fs::read_to_string(&path) {
2877        Ok(c) => c,
2878        Err(_) => return Ok(HashMap::new()),
2879    };
2880    let mut out = HashMap::new();
2881    for line in content.lines() {
2882        let line = line.trim();
2883        if line.is_empty() || line.starts_with('#') {
2884            continue;
2885        }
2886        if let Some((name, rest)) = line.split_once('\t') {
2887            let name = name.trim().to_string();
2888            let p = PathBuf::from(rest.trim());
2889            if !name.is_empty() && p.is_absolute() {
2890                out.insert(name, p);
2891            }
2892        }
2893    }
2894    Ok(out)
2895}
2896
2897fn save_bookmarks(bookmarks: &HashMap<String, PathBuf>) -> Result<()> {
2898    let path = bookmarks_path().context("no home dir for bookmarks")?;
2899    let mut lines: Vec<String> = bookmarks
2900        .iter()
2901        .map(|(k, v)| format!("{}\t{}", k, v.display()))
2902        .collect();
2903    lines.sort();
2904    std::fs::write(path, lines.join("\n") + "\n")?;
2905    Ok(())
2906}
2907
2908fn repl_config_path() -> Option<PathBuf> {
2909    #[cfg(unix)]
2910    if let Ok(home) = std::env::var("HOME") {
2911        return Some(PathBuf::from(&home).join(REPL_CONFIG_FILE));
2912    }
2913    #[cfg(windows)]
2914    if let Ok(home) = std::env::var("USERPROFILE") {
2915        return Some(PathBuf::from(&home).join(REPL_CONFIG_FILE));
2916    }
2917    None
2918}
2919
2920fn load_repl_config() -> Result<HashMap<String, String>> {
2921    let path = repl_config_path().context("no home dir for REPL config")?;
2922    let content = match std::fs::read_to_string(&path) {
2923        Ok(c) => c,
2924        Err(_) => return Ok(HashMap::new()),
2925    };
2926    let mut out = HashMap::new();
2927    for line in content.lines() {
2928        let line = line.trim();
2929        if line.is_empty() || line.starts_with('#') {
2930            continue;
2931        }
2932        if let Some((k, v)) = line.split_once('=') {
2933            let k = k.trim().to_lowercase();
2934            let v = v.trim().trim_matches('"').to_string();
2935            if !k.is_empty() {
2936                out.insert(k, v);
2937            }
2938        }
2939    }
2940    Ok(out)
2941}
2942
2943fn save_repl_config(cfg: &HashMap<String, String>) -> Result<()> {
2944    let path = repl_config_path().context("no home dir for REPL config")?;
2945    let mut keys: Vec<_> = cfg.keys().collect();
2946    keys.sort();
2947    let lines: Vec<String> = keys
2948        .iter()
2949        .map(|k| {
2950            let v = cfg.get(*k).unwrap();
2951            format!("{}={}", k, v)
2952        })
2953        .collect();
2954    std::fs::write(path, lines.join("\n") + "\n")?;
2955    Ok(())
2956}
2957
2958/// Extract variable/function/class names defined in a code snippet for tab completion.
2959fn extract_defined_names(code: &str, language_id: &str) -> Vec<String> {
2960    let mut names = Vec::new();
2961    for line in code.lines() {
2962        let trimmed = line.trim();
2963        match language_id {
2964            "python" | "py" | "python3" | "py3" => {
2965                // x = ..., def foo(...), class Bar:, import x, from x import y
2966                if let Some(rest) = trimmed.strip_prefix("def ") {
2967                    if let Some(name) = rest.split('(').next() {
2968                        let n = name.trim();
2969                        if !n.is_empty() {
2970                            names.push(n.to_string());
2971                        }
2972                    }
2973                } else if let Some(rest) = trimmed.strip_prefix("class ") {
2974                    let name = rest.split(['(', ':']).next().unwrap_or("").trim();
2975                    if !name.is_empty() {
2976                        names.push(name.to_string());
2977                    }
2978                } else if let Some(rest) = trimmed.strip_prefix("import ") {
2979                    for part in rest.split(',') {
2980                        let name = if let Some(alias) = part.split(" as ").nth(1) {
2981                            alias.trim()
2982                        } else {
2983                            part.trim().split('.').next_back().unwrap_or("")
2984                        };
2985                        if !name.is_empty() {
2986                            names.push(name.to_string());
2987                        }
2988                    }
2989                } else if trimmed.starts_with("from ") && trimmed.contains("import ") {
2990                    if let Some(imports) = trimmed.split("import ").nth(1) {
2991                        for part in imports.split(',') {
2992                            let name = if let Some(alias) = part.split(" as ").nth(1) {
2993                                alias.trim()
2994                            } else {
2995                                part.trim()
2996                            };
2997                            if !name.is_empty() {
2998                                names.push(name.to_string());
2999                            }
3000                        }
3001                    }
3002                } else if let Some(eq_pos) = trimmed.find('=') {
3003                    let lhs = &trimmed[..eq_pos];
3004                    if !lhs.contains('(')
3005                        && !lhs.contains('[')
3006                        && !trimmed[eq_pos..].starts_with("==")
3007                    {
3008                        for part in lhs.split(',') {
3009                            let name = part.trim().split(':').next().unwrap_or("").trim();
3010                            if !name.is_empty()
3011                                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
3012                            {
3013                                names.push(name.to_string());
3014                            }
3015                        }
3016                    }
3017                }
3018            }
3019            "javascript" | "js" | "node" | "typescript" | "ts" => {
3020                // let/const/var x = ..., function foo(...), class Bar
3021                for prefix in ["let ", "const ", "var "] {
3022                    if let Some(rest) = trimmed.strip_prefix(prefix) {
3023                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
3024                        if !name.is_empty() {
3025                            names.push(name.to_string());
3026                        }
3027                    }
3028                }
3029                if let Some(rest) = trimmed.strip_prefix("function ") {
3030                    let name = rest.split('(').next().unwrap_or("").trim();
3031                    if !name.is_empty() {
3032                        names.push(name.to_string());
3033                    }
3034                } else if let Some(rest) = trimmed.strip_prefix("class ") {
3035                    let name = rest.split(['{', ' ']).next().unwrap_or("").trim();
3036                    if !name.is_empty() {
3037                        names.push(name.to_string());
3038                    }
3039                }
3040            }
3041            "rust" | "rs" => {
3042                for prefix in ["let ", "let mut "] {
3043                    if let Some(rest) = trimmed.strip_prefix(prefix) {
3044                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
3045                        if !name.is_empty() {
3046                            names.push(name.to_string());
3047                        }
3048                    }
3049                }
3050                if let Some(rest) = trimmed.strip_prefix("fn ") {
3051                    let name = rest.split(['(', '<']).next().unwrap_or("").trim();
3052                    if !name.is_empty() {
3053                        names.push(name.to_string());
3054                    }
3055                } else if let Some(rest) = trimmed.strip_prefix("struct ") {
3056                    let name = rest.split(['{', '(', '<', ' ']).next().unwrap_or("").trim();
3057                    if !name.is_empty() {
3058                        names.push(name.to_string());
3059                    }
3060                }
3061            }
3062            _ => {
3063                // Generic: catch x = ... assignments
3064                if let Some(eq_pos) = trimmed.find('=') {
3065                    let lhs = trimmed[..eq_pos].trim();
3066                    if !lhs.is_empty()
3067                        && !trimmed[eq_pos..].starts_with("==")
3068                        && lhs
3069                            .chars()
3070                            .all(|c| c.is_alphanumeric() || c == '_' || c == ' ')
3071                        && let Some(name) = lhs.split_whitespace().last()
3072                    {
3073                        names.push(name.to_string());
3074                    }
3075                }
3076            }
3077        }
3078    }
3079    names
3080}
3081
3082#[cfg(test)]
3083mod tests {
3084    use super::*;
3085
3086    #[test]
3087    fn language_aliases_resolve_in_registry() {
3088        let registry = LanguageRegistry::bootstrap();
3089        let aliases = [
3090            "python",
3091            "py",
3092            "python3",
3093            "rust",
3094            "rs",
3095            "go",
3096            "golang",
3097            "csharp",
3098            "cs",
3099            "c#",
3100            "typescript",
3101            "ts",
3102            "javascript",
3103            "js",
3104            "node",
3105            "ruby",
3106            "rb",
3107            "lua",
3108            "bash",
3109            "sh",
3110            "zsh",
3111            "java",
3112            "php",
3113            "kotlin",
3114            "kt",
3115            "c",
3116            "cpp",
3117            "c++",
3118            "swift",
3119            "swiftlang",
3120            "perl",
3121            "pl",
3122            "julia",
3123            "jl",
3124        ];
3125
3126        for alias in aliases {
3127            let spec = LanguageSpec::new(alias);
3128            assert!(
3129                registry.resolve(&spec).is_some(),
3130                "alias {alias} should resolve to a registered language"
3131            );
3132        }
3133    }
3134
3135    #[test]
3136    fn python_multiline_def_requires_blank_line_to_execute() {
3137        let mut p = PendingInput::new();
3138        p.push_line("def fib(n):");
3139        assert!(p.needs_more_input("python"));
3140        p.push_line("    return n");
3141        assert!(p.needs_more_input("python"));
3142        p.push_line(""); // blank line ends block
3143        assert!(!p.needs_more_input("python"));
3144    }
3145
3146    #[test]
3147    fn python_dict_literal_colon_does_not_trigger_block() {
3148        let mut p = PendingInput::new();
3149        p.push_line("x = {'key': 'value'}");
3150        assert!(
3151            !p.needs_more_input("python"),
3152            "dict literal should not trigger multi-line"
3153        );
3154    }
3155
3156    #[test]
3157    fn python_class_block_needs_body() {
3158        let mut p = PendingInput::new();
3159        p.push_line("class Foo:");
3160        assert!(p.needs_more_input("python"));
3161        p.push_line("    pass");
3162        assert!(p.needs_more_input("python")); // still indented
3163        p.push_line(""); // blank line ends
3164        assert!(!p.needs_more_input("python"));
3165    }
3166
3167    #[test]
3168    fn python_if_block_with_dedented_body_is_complete() {
3169        let mut p = PendingInput::new();
3170        p.push_line("if True:");
3171        assert!(p.needs_more_input("python"));
3172        p.push_line("    print('yes')");
3173        assert!(p.needs_more_input("python"));
3174        p.push_line(""); // blank line terminates
3175        assert!(!p.needs_more_input("python"));
3176    }
3177
3178    #[test]
3179    fn python_auto_indents_first_line_after_colon_header() {
3180        let mut p = PendingInput::new();
3181        p.push_line("def cool():");
3182        p.push_line_auto("python", r#"print("ok")"#);
3183        let code = p.take();
3184        assert!(
3185            code.contains("    print(\"ok\")\n"),
3186            "expected auto-indented print line, got:\n{code}"
3187        );
3188    }
3189
3190    #[test]
3191    fn python_auto_indents_nested_blocks() {
3192        let mut p = PendingInput::new();
3193        p.push_line("def bubble_sort(arr):");
3194        p.push_line_auto("python", "n = len(arr)");
3195        p.push_line_auto("python", "for i in range(n):");
3196        p.push_line_auto("python", "for j in range(0, n-i-1):");
3197        p.push_line_auto("python", "if arr[j] > arr[j+1]:");
3198        p.push_line_auto("python", "arr[j], arr[j+1] = arr[j+1], arr[j]");
3199        let code = p.take();
3200        assert!(
3201            code.contains("    n = len(arr)\n"),
3202            "missing indent for len"
3203        );
3204        assert!(
3205            code.contains("    for i in range(n):\n"),
3206            "missing indent for outer loop"
3207        );
3208        assert!(
3209            code.contains("        for j in range(0, n-i-1):\n"),
3210            "missing indent for inner loop"
3211        );
3212        assert!(
3213            code.contains("            if arr[j] > arr[j+1]:\n"),
3214            "missing indent for if"
3215        );
3216        assert!(
3217            code.contains("                arr[j], arr[j+1] = arr[j+1], arr[j]\n"),
3218            "missing indent for swap"
3219        );
3220    }
3221
3222    #[test]
3223    fn python_auto_dedents_else_like_blocks() {
3224        let mut p = PendingInput::new();
3225        p.push_line("def f():");
3226        p.push_line_auto("python", "if True:");
3227        p.push_line_auto("python", "print('yes')");
3228        p.push_line_auto("python", "else:");
3229        let code = p.take();
3230        assert!(
3231            code.contains("    else:\n"),
3232            "expected else to align with if block:\n{code}"
3233        );
3234    }
3235
3236    #[test]
3237    fn python_auto_dedents_return_to_def() {
3238        let mut p = PendingInput::new();
3239        p.push_line("def bubble_sort(arr):");
3240        p.push_line_auto("python", "for i in range(3):");
3241        p.push_line_auto("python", "for j in range(2):");
3242        p.push_line_auto("python", "if j:");
3243        p.push_line_auto("python", "pass");
3244        p.push_line_auto("python", "return arr");
3245        let code = p.take();
3246        assert!(
3247            code.contains("    return arr\n"),
3248            "expected return to align with def block:\n{code}"
3249        );
3250    }
3251
3252    #[test]
3253    fn generic_multiline_tracks_unclosed_delimiters() {
3254        let mut p = PendingInput::new();
3255        p.push_line("func(");
3256        assert!(p.needs_more_input("csharp"));
3257        p.push_line(")");
3258        assert!(!p.needs_more_input("csharp"));
3259    }
3260
3261    #[test]
3262    fn generic_multiline_tracks_trailing_equals() {
3263        let mut p = PendingInput::new();
3264        p.push_line("let x =");
3265        assert!(p.needs_more_input("rust"));
3266        p.push_line("10;");
3267        assert!(!p.needs_more_input("rust"));
3268    }
3269
3270    #[test]
3271    fn generic_multiline_tracks_trailing_dot() {
3272        let mut p = PendingInput::new();
3273        p.push_line("foo.");
3274        assert!(p.needs_more_input("csharp"));
3275        p.push_line("Bar()");
3276        assert!(!p.needs_more_input("csharp"));
3277    }
3278
3279    #[test]
3280    fn generic_multiline_accepts_preprocessor_lines() {
3281        let mut p = PendingInput::new();
3282        p.push_line("#include <stdio.h>");
3283        assert!(
3284            !p.needs_more_input("c"),
3285            "preprocessor lines should not force continuation"
3286        );
3287    }
3288}