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