Skip to main content

run/
repl.rs

1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use rustyline::completion::{Completer, Pair};
7use rustyline::error::ReadlineError;
8use rustyline::highlight::Highlighter;
9use rustyline::hint::Hinter;
10use rustyline::history::DefaultHistory;
11use rustyline::validate::Validator;
12use rustyline::{Editor, Helper};
13
14use crate::engine::{
15    ExecutionOutcome, ExecutionPayload, LanguageRegistry, LanguageSession, build_install_command,
16    package_install_command,
17};
18use crate::highlight;
19use crate::language::LanguageSpec;
20
21const HISTORY_FILE: &str = ".run_history";
22
23struct ReplHelper {
24    language_id: String,
25    session_vars: Vec<String>,
26}
27
28impl ReplHelper {
29    fn new(language_id: String) -> Self {
30        Self {
31            language_id,
32            session_vars: Vec::new(),
33        }
34    }
35
36    fn update_language(&mut self, language_id: String) {
37        self.language_id = language_id;
38    }
39
40    fn update_session_vars(&mut self, vars: Vec<String>) {
41        self.session_vars = vars;
42    }
43}
44
45const META_COMMANDS: &[&str] = &[
46    ":help",
47    ":exit",
48    ":quit",
49    ":languages",
50    ":lang ",
51    ":detect ",
52    ":reset",
53    ":load ",
54    ":run ",
55    ":save ",
56    ":history",
57    ":install ",
58    ":bench ",
59    ":type",
60];
61
62fn language_keywords(lang: &str) -> &'static [&'static str] {
63    match lang {
64        "python" | "py" | "python3" | "py3" => &[
65            "False",
66            "None",
67            "True",
68            "and",
69            "as",
70            "assert",
71            "async",
72            "await",
73            "break",
74            "class",
75            "continue",
76            "def",
77            "del",
78            "elif",
79            "else",
80            "except",
81            "finally",
82            "for",
83            "from",
84            "global",
85            "if",
86            "import",
87            "in",
88            "is",
89            "lambda",
90            "nonlocal",
91            "not",
92            "or",
93            "pass",
94            "raise",
95            "return",
96            "try",
97            "while",
98            "with",
99            "yield",
100            "print",
101            "len",
102            "range",
103            "enumerate",
104            "zip",
105            "map",
106            "filter",
107            "sorted",
108            "list",
109            "dict",
110            "set",
111            "tuple",
112            "str",
113            "int",
114            "float",
115            "bool",
116            "type",
117            "isinstance",
118            "hasattr",
119            "getattr",
120            "setattr",
121            "open",
122            "input",
123        ],
124        "javascript" | "js" | "node" => &[
125            "async",
126            "await",
127            "break",
128            "case",
129            "catch",
130            "class",
131            "const",
132            "continue",
133            "debugger",
134            "default",
135            "delete",
136            "do",
137            "else",
138            "export",
139            "extends",
140            "false",
141            "finally",
142            "for",
143            "function",
144            "if",
145            "import",
146            "in",
147            "instanceof",
148            "let",
149            "new",
150            "null",
151            "of",
152            "return",
153            "static",
154            "super",
155            "switch",
156            "this",
157            "throw",
158            "true",
159            "try",
160            "typeof",
161            "undefined",
162            "var",
163            "void",
164            "while",
165            "with",
166            "yield",
167            "console",
168            "require",
169            "module",
170            "process",
171            "Promise",
172            "Array",
173            "Object",
174            "String",
175            "Number",
176            "Boolean",
177            "Math",
178            "JSON",
179            "Date",
180            "RegExp",
181            "Map",
182            "Set",
183        ],
184        "typescript" | "ts" => &[
185            "abstract",
186            "any",
187            "as",
188            "async",
189            "await",
190            "boolean",
191            "break",
192            "case",
193            "catch",
194            "class",
195            "const",
196            "continue",
197            "debugger",
198            "declare",
199            "default",
200            "delete",
201            "do",
202            "else",
203            "enum",
204            "export",
205            "extends",
206            "false",
207            "finally",
208            "for",
209            "from",
210            "function",
211            "get",
212            "if",
213            "implements",
214            "import",
215            "in",
216            "infer",
217            "instanceof",
218            "interface",
219            "is",
220            "keyof",
221            "let",
222            "module",
223            "namespace",
224            "never",
225            "new",
226            "null",
227            "number",
228            "object",
229            "of",
230            "private",
231            "protected",
232            "public",
233            "readonly",
234            "return",
235            "set",
236            "static",
237            "string",
238            "super",
239            "switch",
240            "symbol",
241            "this",
242            "throw",
243            "true",
244            "try",
245            "type",
246            "typeof",
247            "undefined",
248            "unique",
249            "unknown",
250            "var",
251            "void",
252            "while",
253            "with",
254            "yield",
255        ],
256        "rust" | "rs" => &[
257            "as",
258            "async",
259            "await",
260            "break",
261            "const",
262            "continue",
263            "crate",
264            "dyn",
265            "else",
266            "enum",
267            "extern",
268            "false",
269            "fn",
270            "for",
271            "if",
272            "impl",
273            "in",
274            "let",
275            "loop",
276            "match",
277            "mod",
278            "move",
279            "mut",
280            "pub",
281            "ref",
282            "return",
283            "self",
284            "Self",
285            "static",
286            "struct",
287            "super",
288            "trait",
289            "true",
290            "type",
291            "unsafe",
292            "use",
293            "where",
294            "while",
295            "println!",
296            "eprintln!",
297            "format!",
298            "vec!",
299            "String",
300            "Vec",
301            "Option",
302            "Result",
303            "Some",
304            "None",
305            "Ok",
306            "Err",
307        ],
308        "go" | "golang" => &[
309            "break",
310            "case",
311            "chan",
312            "const",
313            "continue",
314            "default",
315            "defer",
316            "else",
317            "fallthrough",
318            "for",
319            "func",
320            "go",
321            "goto",
322            "if",
323            "import",
324            "interface",
325            "map",
326            "package",
327            "range",
328            "return",
329            "select",
330            "struct",
331            "switch",
332            "type",
333            "var",
334            "fmt",
335            "Println",
336            "Printf",
337            "Sprintf",
338            "errors",
339            "strings",
340            "strconv",
341        ],
342        "ruby" | "rb" => &[
343            "alias",
344            "and",
345            "begin",
346            "break",
347            "case",
348            "class",
349            "def",
350            "defined?",
351            "do",
352            "else",
353            "elsif",
354            "end",
355            "ensure",
356            "false",
357            "for",
358            "if",
359            "in",
360            "module",
361            "next",
362            "nil",
363            "not",
364            "or",
365            "redo",
366            "rescue",
367            "retry",
368            "return",
369            "self",
370            "super",
371            "then",
372            "true",
373            "undef",
374            "unless",
375            "until",
376            "when",
377            "while",
378            "yield",
379            "puts",
380            "print",
381            "require",
382            "require_relative",
383        ],
384        "java" => &[
385            "abstract",
386            "assert",
387            "boolean",
388            "break",
389            "byte",
390            "case",
391            "catch",
392            "char",
393            "class",
394            "const",
395            "continue",
396            "default",
397            "do",
398            "double",
399            "else",
400            "enum",
401            "extends",
402            "final",
403            "finally",
404            "float",
405            "for",
406            "goto",
407            "if",
408            "implements",
409            "import",
410            "instanceof",
411            "int",
412            "interface",
413            "long",
414            "native",
415            "new",
416            "package",
417            "private",
418            "protected",
419            "public",
420            "return",
421            "short",
422            "static",
423            "strictfp",
424            "super",
425            "switch",
426            "synchronized",
427            "this",
428            "throw",
429            "throws",
430            "transient",
431            "try",
432            "void",
433            "volatile",
434            "while",
435            "System",
436            "String",
437        ],
438        _ => &[],
439    }
440}
441
442fn complete_file_path(partial: &str) -> Vec<Pair> {
443    let (dir_part, file_prefix) = if let Some(sep_pos) = partial.rfind('/') {
444        (&partial[..=sep_pos], &partial[sep_pos + 1..])
445    } else {
446        ("", partial)
447    };
448
449    let search_dir = if dir_part.is_empty() { "." } else { dir_part };
450
451    let mut results = Vec::new();
452    if let Ok(entries) = std::fs::read_dir(search_dir) {
453        for entry in entries.flatten() {
454            let name = entry.file_name().to_string_lossy().to_string();
455            if name.starts_with('.') {
456                continue; // skip dotfiles
457            }
458            if name.starts_with(file_prefix) {
459                let full = format!("{dir_part}{name}");
460                let display = if entry.path().is_dir() {
461                    format!("{name}/")
462                } else {
463                    name.clone()
464                };
465                results.push(Pair {
466                    display,
467                    replacement: full,
468                });
469            }
470        }
471    }
472    results
473}
474
475impl Completer for ReplHelper {
476    type Candidate = Pair;
477
478    fn complete(
479        &self,
480        line: &str,
481        pos: usize,
482        _ctx: &rustyline::Context<'_>,
483    ) -> rustyline::Result<(usize, Vec<Pair>)> {
484        let line_up_to = &line[..pos];
485
486        // Meta command completion
487        if line_up_to.starts_with(':') {
488            // File path completion for :load and :run
489            if let Some(rest) = line_up_to
490                .strip_prefix(":load ")
491                .or_else(|| line_up_to.strip_prefix(":run "))
492                .or_else(|| line_up_to.strip_prefix(":save "))
493            {
494                let start = pos - rest.len();
495                return Ok((start, complete_file_path(rest)));
496            }
497
498            let candidates: Vec<Pair> = META_COMMANDS
499                .iter()
500                .filter(|cmd| cmd.starts_with(line_up_to))
501                .map(|cmd| Pair {
502                    display: cmd.to_string(),
503                    replacement: cmd.to_string(),
504                })
505                .collect();
506            return Ok((0, candidates));
507        }
508
509        // Find the word being typed
510        let word_start = line_up_to
511            .rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '!')
512            .map(|i| i + 1)
513            .unwrap_or(0);
514        let prefix = &line_up_to[word_start..];
515
516        if prefix.is_empty() {
517            return Ok((pos, Vec::new()));
518        }
519
520        let mut candidates: Vec<Pair> = Vec::new();
521
522        // Language keywords
523        for kw in language_keywords(&self.language_id) {
524            if kw.starts_with(prefix) {
525                candidates.push(Pair {
526                    display: kw.to_string(),
527                    replacement: kw.to_string(),
528                });
529            }
530        }
531
532        // Session variables
533        for var in &self.session_vars {
534            if var.starts_with(prefix) && !candidates.iter().any(|c| c.replacement == *var) {
535                candidates.push(Pair {
536                    display: var.clone(),
537                    replacement: var.clone(),
538                });
539            }
540        }
541
542        Ok((word_start, candidates))
543    }
544}
545
546impl Hinter for ReplHelper {
547    type Hint = String;
548}
549
550impl Validator for ReplHelper {}
551
552impl Highlighter for ReplHelper {
553    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
554        if line.trim_start().starts_with(':') {
555            return Cow::Borrowed(line);
556        }
557
558        let highlighted = highlight::highlight_repl_input(line, &self.language_id);
559        Cow::Owned(highlighted)
560    }
561
562    fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
563        true
564    }
565}
566
567impl Helper for ReplHelper {}
568
569pub fn run_repl(
570    initial_language: LanguageSpec,
571    registry: LanguageRegistry,
572    detect_enabled: bool,
573) -> Result<i32> {
574    let helper = ReplHelper::new(initial_language.canonical_id().to_string());
575    let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?;
576    editor.set_helper(Some(helper));
577
578    if let Some(path) = history_path() {
579        let _ = editor.load_history(&path);
580    }
581
582    let lang_count = registry.known_languages().len();
583    let mut state = ReplState::new(initial_language, registry, detect_enabled)?;
584
585    println!(
586        "\x1b[1mrun\x1b[0m \x1b[2mv{} — {}+ languages. Type :help for commands.\x1b[0m",
587        env!("CARGO_PKG_VERSION"),
588        lang_count
589    );
590    let mut pending: Option<PendingInput> = None;
591
592    loop {
593        let prompt = match &pending {
594            Some(p) => p.prompt(),
595            None => state.prompt(),
596        };
597
598        if let Some(helper) = editor.helper_mut() {
599            helper.update_language(state.current_language().canonical_id().to_string());
600        }
601
602        match editor.readline(&prompt) {
603            Ok(line) => {
604                let raw = line.trim_end_matches(['\r', '\n']);
605
606                if let Some(p) = pending.as_mut() {
607                    if raw.trim() == ":cancel" {
608                        pending = None;
609                        continue;
610                    }
611
612                    p.push_line_auto(state.current_language().canonical_id(), raw);
613                    if p.needs_more_input(state.current_language().canonical_id()) {
614                        continue;
615                    }
616
617                    let code = p.take();
618                    pending = None;
619                    let trimmed = code.trim_end();
620                    if !trimmed.is_empty() {
621                        let _ = editor.add_history_entry(trimmed);
622                        state.history_entries.push(trimmed.to_string());
623                        state.execute_snippet(trimmed)?;
624                        if let Some(helper) = editor.helper_mut() {
625                            helper.update_session_vars(state.session_var_names());
626                        }
627                    }
628                    continue;
629                }
630
631                if raw.trim().is_empty() {
632                    continue;
633                }
634
635                if raw.trim_start().starts_with(':') {
636                    let trimmed = raw.trim();
637                    let _ = editor.add_history_entry(trimmed);
638                    if state.handle_meta(trimmed)? {
639                        break;
640                    }
641                    continue;
642                }
643
644                let mut p = PendingInput::new();
645                p.push_line(raw);
646                if p.needs_more_input(state.current_language().canonical_id()) {
647                    pending = Some(p);
648                    continue;
649                }
650
651                let trimmed = raw.trim_end();
652                let _ = editor.add_history_entry(trimmed);
653                state.history_entries.push(trimmed.to_string());
654                state.execute_snippet(trimmed)?;
655                if let Some(helper) = editor.helper_mut() {
656                    helper.update_session_vars(state.session_var_names());
657                }
658            }
659            Err(ReadlineError::Interrupted) => {
660                println!("^C");
661                pending = None;
662                continue;
663            }
664            Err(ReadlineError::Eof) => {
665                println!("bye");
666                break;
667            }
668            Err(err) => {
669                bail!("readline error: {err}");
670            }
671        }
672    }
673
674    if let Some(path) = history_path() {
675        let _ = editor.save_history(&path);
676    }
677
678    state.shutdown();
679    Ok(0)
680}
681
682struct ReplState {
683    registry: LanguageRegistry,
684    sessions: HashMap<String, Box<dyn LanguageSession>>, // keyed by canonical id
685    current_language: LanguageSpec,
686    detect_enabled: bool,
687    defined_names: HashSet<String>,
688    history_entries: Vec<String>,
689}
690
691struct PendingInput {
692    buf: String,
693}
694
695impl PendingInput {
696    fn new() -> Self {
697        Self { buf: String::new() }
698    }
699
700    fn prompt(&self) -> String {
701        "... ".to_string()
702    }
703
704    fn push_line(&mut self, line: &str) {
705        self.buf.push_str(line);
706        self.buf.push('\n');
707    }
708
709    fn push_line_auto(&mut self, language_id: &str, line: &str) {
710        match language_id {
711            "python" | "py" | "python3" | "py3" => {
712                let adjusted = python_auto_indent(line, &self.buf);
713                self.push_line(&adjusted);
714            }
715            _ => self.push_line(line),
716        }
717    }
718
719    fn take(&mut self) -> String {
720        std::mem::take(&mut self.buf)
721    }
722
723    fn needs_more_input(&self, language_id: &str) -> bool {
724        needs_more_input(language_id, &self.buf)
725    }
726}
727
728fn needs_more_input(language_id: &str, code: &str) -> bool {
729    match language_id {
730        "python" | "py" | "python3" | "py3" => needs_more_input_python(code),
731
732        _ => has_unclosed_delimiters(code) || generic_line_looks_incomplete(code),
733    }
734}
735
736fn generic_line_looks_incomplete(code: &str) -> bool {
737    let mut last: Option<&str> = None;
738    for line in code.lines().rev() {
739        let trimmed = line.trim_end();
740        if trimmed.trim().is_empty() {
741            continue;
742        }
743        last = Some(trimmed);
744        break;
745    }
746    let Some(line) = last else { return false };
747    let line = line.trim();
748    if line.is_empty() {
749        return false;
750    }
751
752    if line.ends_with('\\') {
753        return true;
754    }
755
756    const TAILS: [&str; 24] = [
757        "=", "+", "-", "*", "/", "%", "&", "|", "^", "!", "<", ">", "&&", "||", "??", "?:", "?",
758        ":", ".", ",", "=>", "->", "::", "..",
759    ];
760    if TAILS.iter().any(|tok| line.ends_with(tok)) {
761        return true;
762    }
763
764    const PREFIXES: [&str; 9] = [
765        "return", "throw", "yield", "await", "import", "from", "export", "case", "else",
766    ];
767    let lowered = line.to_ascii_lowercase();
768    if PREFIXES
769        .iter()
770        .any(|kw| lowered == *kw || lowered.ends_with(&format!(" {kw}")))
771    {
772        return true;
773    }
774
775    false
776}
777
778fn needs_more_input_python(code: &str) -> bool {
779    if has_unclosed_delimiters(code) {
780        return true;
781    }
782
783    let mut last_nonempty: Option<&str> = None;
784    let mut saw_block_header = false;
785    let mut has_body_after_header = false;
786
787    for line in code.lines() {
788        let trimmed = line.trim_end();
789        if trimmed.trim().is_empty() {
790            continue;
791        }
792        last_nonempty = Some(trimmed);
793        if is_python_block_header(trimmed.trim()) {
794            saw_block_header = true;
795            has_body_after_header = false;
796        } else if saw_block_header {
797            has_body_after_header = true;
798        }
799    }
800
801    if !saw_block_header {
802        return false;
803    }
804
805    // A blank line terminates a block
806    if code.ends_with("\n\n") {
807        return false;
808    }
809
810    // If we have a header but no body yet, we need more input
811    if !has_body_after_header {
812        return true;
813    }
814
815    // If the last line is still indented, we're still inside the block
816    if let Some(last) = last_nonempty
817        && (last.starts_with(' ') || last.starts_with('\t'))
818    {
819        return true;
820    }
821
822    false
823}
824
825/// Check if a trimmed Python line is a block header (def, class, if, for, etc.)
826/// rather than a line that just happens to end with `:` (dict literal, slice, etc.)
827fn is_python_block_header(line: &str) -> bool {
828    if !line.ends_with(':') {
829        return false;
830    }
831    let lowered = line.to_ascii_lowercase();
832    const BLOCK_KEYWORDS: &[&str] = &[
833        "def ",
834        "class ",
835        "if ",
836        "elif ",
837        "else:",
838        "for ",
839        "while ",
840        "try:",
841        "except",
842        "finally:",
843        "with ",
844        "async def ",
845        "async for ",
846        "async with ",
847    ];
848    BLOCK_KEYWORDS.iter().any(|kw| lowered.starts_with(kw))
849}
850
851fn python_auto_indent(line: &str, existing: &str) -> String {
852    let trimmed = line.trim_end_matches(['\r', '\n']);
853    let raw = trimmed;
854    if raw.trim().is_empty() {
855        return raw.to_string();
856    }
857
858    if raw.starts_with(' ') || raw.starts_with('\t') {
859        return raw.to_string();
860    }
861
862    let mut last_nonempty: Option<&str> = None;
863    for l in existing.lines().rev() {
864        if l.trim().is_empty() {
865            continue;
866        }
867        last_nonempty = Some(l);
868        break;
869    }
870
871    let Some(prev) = last_nonempty else {
872        return raw.to_string();
873    };
874    let prev_trimmed = prev.trim_end();
875
876    if !prev_trimmed.ends_with(':') {
877        return raw.to_string();
878    }
879
880    let lowered = raw.trim().to_ascii_lowercase();
881    if lowered.starts_with("else:")
882        || lowered.starts_with("elif ")
883        || lowered.starts_with("except")
884        || lowered.starts_with("finally:")
885    {
886        return raw.to_string();
887    }
888
889    let base_indent = prev
890        .chars()
891        .take_while(|c| *c == ' ' || *c == '\t')
892        .collect::<String>();
893
894    format!("{base_indent}    {raw}")
895}
896
897fn has_unclosed_delimiters(code: &str) -> bool {
898    let mut paren = 0i32;
899    let mut bracket = 0i32;
900    let mut brace = 0i32;
901
902    let mut in_single = false;
903    let mut in_double = false;
904    let mut in_backtick = false;
905    let mut in_block_comment = false;
906    let mut escape = false;
907
908    let chars: Vec<char> = code.chars().collect();
909    let len = chars.len();
910    let mut i = 0;
911
912    while i < len {
913        let ch = chars[i];
914
915        if escape {
916            escape = false;
917            i += 1;
918            continue;
919        }
920
921        // Inside block comment /* ... */
922        if in_block_comment {
923            if ch == '*' && i + 1 < len && chars[i + 1] == '/' {
924                in_block_comment = false;
925                i += 2;
926                continue;
927            }
928            i += 1;
929            continue;
930        }
931
932        if in_single {
933            if ch == '\\' {
934                escape = true;
935            } else if ch == '\'' {
936                in_single = false;
937            }
938            i += 1;
939            continue;
940        }
941        if in_double {
942            if ch == '\\' {
943                escape = true;
944            } else if ch == '"' {
945                in_double = false;
946            }
947            i += 1;
948            continue;
949        }
950        if in_backtick {
951            if ch == '\\' {
952                escape = true;
953            } else if ch == '`' {
954                in_backtick = false;
955            }
956            i += 1;
957            continue;
958        }
959
960        // Check for line comments (// and #)
961        if ch == '/' && i + 1 < len && chars[i + 1] == '/' {
962            // Skip rest of line
963            while i < len && chars[i] != '\n' {
964                i += 1;
965            }
966            continue;
967        }
968        if ch == '#' {
969            // Python/Ruby/etc. line comment - skip rest of line
970            while i < len && chars[i] != '\n' {
971                i += 1;
972            }
973            continue;
974        }
975        // Check for block comments /* ... */
976        if ch == '/' && i + 1 < len && chars[i + 1] == '*' {
977            in_block_comment = true;
978            i += 2;
979            continue;
980        }
981
982        match ch {
983            '\'' => in_single = true,
984            '"' => in_double = true,
985            '`' => in_backtick = true,
986            '(' => paren += 1,
987            ')' => paren -= 1,
988            '[' => bracket += 1,
989            ']' => bracket -= 1,
990            '{' => brace += 1,
991            '}' => brace -= 1,
992            _ => {}
993        }
994
995        i += 1;
996    }
997
998    paren > 0 || bracket > 0 || brace > 0 || in_block_comment
999}
1000
1001impl ReplState {
1002    fn new(
1003        initial_language: LanguageSpec,
1004        registry: LanguageRegistry,
1005        detect_enabled: bool,
1006    ) -> Result<Self> {
1007        let mut state = Self {
1008            registry,
1009            sessions: HashMap::new(),
1010            current_language: initial_language,
1011            detect_enabled,
1012            defined_names: HashSet::new(),
1013            history_entries: Vec::new(),
1014        };
1015        state.ensure_current_language()?;
1016        Ok(state)
1017    }
1018
1019    fn current_language(&self) -> &LanguageSpec {
1020        &self.current_language
1021    }
1022
1023    fn prompt(&self) -> String {
1024        format!("{}>>> ", self.current_language.canonical_id())
1025    }
1026
1027    fn ensure_current_language(&mut self) -> Result<()> {
1028        if self.registry.resolve(&self.current_language).is_none() {
1029            bail!(
1030                "language '{}' is not available",
1031                self.current_language.canonical_id()
1032            );
1033        }
1034        Ok(())
1035    }
1036
1037    fn handle_meta(&mut self, line: &str) -> Result<bool> {
1038        let command = line.trim_start_matches(':').trim();
1039        if command.is_empty() {
1040            return Ok(false);
1041        }
1042
1043        let mut parts = command.split_whitespace();
1044        let Some(head) = parts.next() else {
1045            return Ok(false);
1046        };
1047        match head {
1048            "exit" | "quit" => return Ok(true),
1049            "help" => {
1050                self.print_help();
1051                return Ok(false);
1052            }
1053            "languages" => {
1054                self.print_languages();
1055                return Ok(false);
1056            }
1057            "detect" => {
1058                if let Some(arg) = parts.next() {
1059                    match arg {
1060                        "on" | "true" | "1" => {
1061                            self.detect_enabled = true;
1062                            println!("auto-detect enabled");
1063                        }
1064                        "off" | "false" | "0" => {
1065                            self.detect_enabled = false;
1066                            println!("auto-detect disabled");
1067                        }
1068                        "toggle" => {
1069                            self.detect_enabled = !self.detect_enabled;
1070                            println!(
1071                                "auto-detect {}",
1072                                if self.detect_enabled {
1073                                    "enabled"
1074                                } else {
1075                                    "disabled"
1076                                }
1077                            );
1078                        }
1079                        _ => println!("usage: :detect <on|off|toggle>"),
1080                    }
1081                } else {
1082                    println!(
1083                        "auto-detect is {}",
1084                        if self.detect_enabled {
1085                            "enabled"
1086                        } else {
1087                            "disabled"
1088                        }
1089                    );
1090                }
1091                return Ok(false);
1092            }
1093            "lang" => {
1094                if let Some(lang) = parts.next() {
1095                    self.switch_language(LanguageSpec::new(lang.to_string()))?;
1096                } else {
1097                    println!("usage: :lang <language>");
1098                }
1099                return Ok(false);
1100            }
1101            "reset" => {
1102                self.reset_current_session();
1103                println!(
1104                    "session for '{}' reset",
1105                    self.current_language.canonical_id()
1106                );
1107                return Ok(false);
1108            }
1109            "load" | "run" => {
1110                if let Some(token) = parts.next() {
1111                    let path = PathBuf::from(token);
1112                    self.execute_payload(ExecutionPayload::File { path })?;
1113                } else {
1114                    println!("usage: :load <path>");
1115                }
1116                return Ok(false);
1117            }
1118            "save" => {
1119                if let Some(token) = parts.next() {
1120                    let path = Path::new(token);
1121                    match self.save_session(path) {
1122                        Ok(count) => println!(
1123                            "\x1b[2m[saved {count} entries to {}]\x1b[0m",
1124                            path.display()
1125                        ),
1126                        Err(e) => println!("error saving session: {e}"),
1127                    }
1128                } else {
1129                    println!("usage: :save <path>");
1130                }
1131                return Ok(false);
1132            }
1133            "history" => {
1134                let limit: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(25);
1135                self.show_history(limit);
1136                return Ok(false);
1137            }
1138            "install" => {
1139                if let Some(pkg) = parts.next() {
1140                    self.install_package(pkg);
1141                } else {
1142                    println!("usage: :install <package>");
1143                }
1144                return Ok(false);
1145            }
1146            "bench" => {
1147                let n: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(10);
1148                let code = parts.collect::<Vec<_>>().join(" ");
1149                if code.is_empty() {
1150                    println!("usage: :bench [N] <code>");
1151                    println!("  Runs <code> N times (default: 10) and reports timing stats.");
1152                } else {
1153                    self.bench_code(&code, n)?;
1154                }
1155                return Ok(false);
1156            }
1157            "type" | "which" => {
1158                let lang = &self.current_language;
1159                println!(
1160                    "\x1b[1m{}\x1b[0m \x1b[2m({})\x1b[0m",
1161                    lang.canonical_id(),
1162                    if self.sessions.contains_key(lang.canonical_id()) {
1163                        "session active"
1164                    } else {
1165                        "no session"
1166                    }
1167                );
1168                return Ok(false);
1169            }
1170            alias => {
1171                let spec = LanguageSpec::new(alias);
1172                if self.registry.resolve(&spec).is_some() {
1173                    self.switch_language(spec)?;
1174                    return Ok(false);
1175                }
1176                println!("unknown command: :{alias}. Type :help for help.");
1177            }
1178        }
1179
1180        Ok(false)
1181    }
1182
1183    fn switch_language(&mut self, spec: LanguageSpec) -> Result<()> {
1184        if self.current_language.canonical_id() == spec.canonical_id() {
1185            println!("already using {}", spec.canonical_id());
1186            return Ok(());
1187        }
1188        if self.registry.resolve(&spec).is_none() {
1189            let available = self.registry.known_languages().join(", ");
1190            bail!(
1191                "language '{}' not supported. Available: {available}",
1192                spec.canonical_id()
1193            );
1194        }
1195        self.current_language = spec;
1196        println!("switched to {}", self.current_language.canonical_id());
1197        Ok(())
1198    }
1199
1200    fn reset_current_session(&mut self) {
1201        let key = self.current_language.canonical_id().to_string();
1202        if let Some(mut session) = self.sessions.remove(&key) {
1203            let _ = session.shutdown();
1204        }
1205    }
1206
1207    fn execute_snippet(&mut self, code: &str) -> Result<()> {
1208        if self.detect_enabled
1209            && let Some(detected) = crate::detect::detect_language_from_snippet(code)
1210            && detected != self.current_language.canonical_id()
1211        {
1212            let spec = LanguageSpec::new(detected.to_string());
1213            if self.registry.resolve(&spec).is_some() {
1214                println!(
1215                    "[auto-detect] switching {} -> {}",
1216                    self.current_language.canonical_id(),
1217                    spec.canonical_id()
1218                );
1219                self.current_language = spec;
1220            }
1221        }
1222
1223        // Track defined variable names for tab completion
1224        self.defined_names.extend(extract_defined_names(
1225            code,
1226            self.current_language.canonical_id(),
1227        ));
1228
1229        let payload = ExecutionPayload::Inline {
1230            code: code.to_string(),
1231        };
1232        self.execute_payload(payload)
1233    }
1234
1235    fn session_var_names(&self) -> Vec<String> {
1236        self.defined_names.iter().cloned().collect()
1237    }
1238
1239    fn execute_payload(&mut self, payload: ExecutionPayload) -> Result<()> {
1240        let language = self.current_language.clone();
1241        let outcome = match payload {
1242            ExecutionPayload::Inline { code } => {
1243                if self.engine_supports_sessions(&language)? {
1244                    self.eval_in_session(&language, &code)?
1245                } else {
1246                    let engine = self
1247                        .registry
1248                        .resolve(&language)
1249                        .context("language engine not found")?;
1250                    engine.execute(&ExecutionPayload::Inline { code })?
1251                }
1252            }
1253            ExecutionPayload::File { ref path } => {
1254                // Read the file and feed it through the session so variables persist
1255                if self.engine_supports_sessions(&language)? {
1256                    let code = std::fs::read_to_string(path)
1257                        .with_context(|| format!("failed to read file: {}", path.display()))?;
1258                    println!("\x1b[2m[loaded {}]\x1b[0m", path.display());
1259                    self.eval_in_session(&language, &code)?
1260                } else {
1261                    let engine = self
1262                        .registry
1263                        .resolve(&language)
1264                        .context("language engine not found")?;
1265                    engine.execute(&payload)?
1266                }
1267            }
1268            ExecutionPayload::Stdin { code } => {
1269                if self.engine_supports_sessions(&language)? {
1270                    self.eval_in_session(&language, &code)?
1271                } else {
1272                    let engine = self
1273                        .registry
1274                        .resolve(&language)
1275                        .context("language engine not found")?;
1276                    engine.execute(&ExecutionPayload::Stdin { code })?
1277                }
1278            }
1279        };
1280        render_outcome(&outcome);
1281        Ok(())
1282    }
1283
1284    fn engine_supports_sessions(&self, language: &LanguageSpec) -> Result<bool> {
1285        Ok(self
1286            .registry
1287            .resolve(language)
1288            .context("language engine not found")?
1289            .supports_sessions())
1290    }
1291
1292    fn eval_in_session(&mut self, language: &LanguageSpec, code: &str) -> Result<ExecutionOutcome> {
1293        use std::collections::hash_map::Entry;
1294        let key = language.canonical_id().to_string();
1295        match self.sessions.entry(key) {
1296            Entry::Occupied(mut entry) => entry.get_mut().eval(code),
1297            Entry::Vacant(entry) => {
1298                let engine = self
1299                    .registry
1300                    .resolve(language)
1301                    .context("language engine not found")?;
1302                let mut session = engine.start_session().with_context(|| {
1303                    format!("failed to start {} session", language.canonical_id())
1304                })?;
1305                let outcome = session.eval(code)?;
1306                entry.insert(session);
1307                Ok(outcome)
1308            }
1309        }
1310    }
1311
1312    fn print_languages(&self) {
1313        let mut languages = self.registry.known_languages();
1314        languages.sort();
1315        println!("available languages: {}", languages.join(", "));
1316    }
1317
1318    fn install_package(&self, package: &str) {
1319        let lang_id = self.current_language.canonical_id();
1320        if package_install_command(lang_id).is_none() {
1321            println!("No package manager available for '{lang_id}'.");
1322            return;
1323        }
1324
1325        let Some(mut cmd) = build_install_command(lang_id, package) else {
1326            println!("Failed to build install command for '{lang_id}'.");
1327            return;
1328        };
1329
1330        println!("\x1b[36m[run]\x1b[0m Installing '{package}' for {lang_id}...");
1331
1332        match cmd
1333            .stdin(std::process::Stdio::inherit())
1334            .stdout(std::process::Stdio::inherit())
1335            .stderr(std::process::Stdio::inherit())
1336            .status()
1337        {
1338            Ok(status) if status.success() => {
1339                println!("\x1b[32m[run]\x1b[0m Successfully installed '{package}'");
1340            }
1341            Ok(_) => {
1342                println!("\x1b[31m[run]\x1b[0m Failed to install '{package}'");
1343            }
1344            Err(e) => {
1345                println!("\x1b[31m[run]\x1b[0m Error running package manager: {e}");
1346            }
1347        }
1348    }
1349
1350    fn bench_code(&mut self, code: &str, iterations: u32) -> Result<()> {
1351        let language = self.current_language.clone();
1352
1353        // Warmup
1354        let warmup = self.eval_in_session(&language, code)?;
1355        if !warmup.success() {
1356            println!("\x1b[31mError:\x1b[0m Code failed during warmup");
1357            if !warmup.stderr.is_empty() {
1358                print!("{}", warmup.stderr);
1359            }
1360            return Ok(());
1361        }
1362        println!("\x1b[2m  warmup: {}ms\x1b[0m", warmup.duration.as_millis());
1363
1364        let mut times: Vec<f64> = Vec::with_capacity(iterations as usize);
1365        for i in 0..iterations {
1366            let outcome = self.eval_in_session(&language, code)?;
1367            let ms = outcome.duration.as_secs_f64() * 1000.0;
1368            times.push(ms);
1369            if i < 3 || i == iterations - 1 {
1370                println!("\x1b[2m  run {}: {:.2}ms\x1b[0m", i + 1, ms);
1371            }
1372        }
1373
1374        times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1375        let total: f64 = times.iter().sum();
1376        let avg = total / times.len() as f64;
1377        let min = times.first().copied().unwrap_or(0.0);
1378        let max = times.last().copied().unwrap_or(0.0);
1379        let median = if times.len().is_multiple_of(2) && times.len() >= 2 {
1380            (times[times.len() / 2 - 1] + times[times.len() / 2]) / 2.0
1381        } else {
1382            times[times.len() / 2]
1383        };
1384        let variance: f64 =
1385            times.iter().map(|t| (t - avg).powi(2)).sum::<f64>() / times.len() as f64;
1386        let stddev = variance.sqrt();
1387
1388        println!();
1389        println!("\x1b[1mResults ({iterations} runs):\x1b[0m");
1390        println!("  min:    \x1b[32m{min:.2}ms\x1b[0m");
1391        println!("  max:    \x1b[33m{max:.2}ms\x1b[0m");
1392        println!("  avg:    \x1b[36m{avg:.2}ms\x1b[0m");
1393        println!("  median: \x1b[36m{median:.2}ms\x1b[0m");
1394        println!("  stddev: {stddev:.2}ms");
1395        Ok(())
1396    }
1397
1398    fn save_session(&self, path: &Path) -> Result<usize> {
1399        use std::io::Write;
1400        let mut file = std::fs::File::create(path)
1401            .with_context(|| format!("failed to create {}", path.display()))?;
1402        let count = self.history_entries.len();
1403        for entry in &self.history_entries {
1404            writeln!(file, "{entry}")?;
1405        }
1406        Ok(count)
1407    }
1408
1409    fn show_history(&self, limit: usize) {
1410        let entries = &self.history_entries;
1411        let start = entries.len().saturating_sub(limit);
1412        if entries.is_empty() {
1413            println!("\x1b[2m(no history)\x1b[0m");
1414            return;
1415        }
1416        for (i, entry) in entries[start..].iter().enumerate() {
1417            let num = start + i + 1;
1418            // Show multi-line entries with continuation indicator
1419            let first_line = entry.lines().next().unwrap_or(entry);
1420            let is_multiline = entry.contains('\n');
1421            if is_multiline {
1422                println!("\x1b[2m[{num:>4}]\x1b[0m {first_line} \x1b[2m(...)\x1b[0m");
1423            } else {
1424                println!("\x1b[2m[{num:>4}]\x1b[0m {entry}");
1425            }
1426        }
1427    }
1428
1429    fn print_help(&self) {
1430        println!("\x1b[1mCommands\x1b[0m");
1431        println!("  \x1b[36m:help\x1b[0m                 \x1b[2mShow this help\x1b[0m");
1432        println!("  \x1b[36m:lang\x1b[0m <id>            \x1b[2mSwitch language\x1b[0m");
1433        println!("  \x1b[36m:languages\x1b[0m            \x1b[2mList available languages\x1b[0m");
1434        println!(
1435            "  \x1b[36m:detect\x1b[0m on|off        \x1b[2mToggle auto language detection\x1b[0m"
1436        );
1437        println!(
1438            "  \x1b[36m:reset\x1b[0m                \x1b[2mClear current session state\x1b[0m"
1439        );
1440        println!("  \x1b[36m:load\x1b[0m <path>          \x1b[2mLoad and execute a file\x1b[0m");
1441        println!(
1442            "  \x1b[36m:save\x1b[0m <path>          \x1b[2mSave session history to file\x1b[0m"
1443        );
1444        println!(
1445            "  \x1b[36m:history\x1b[0m [n]          \x1b[2mShow last n entries (default: 25)\x1b[0m"
1446        );
1447        println!(
1448            "  \x1b[36m:install\x1b[0m <pkg>        \x1b[2mInstall a package for current language\x1b[0m"
1449        );
1450        println!(
1451            "  \x1b[36m:bench\x1b[0m [N] <code>     \x1b[2mBenchmark code N times (default: 10)\x1b[0m"
1452        );
1453        println!(
1454            "  \x1b[36m:type\x1b[0m                 \x1b[2mShow current language and session status\x1b[0m"
1455        );
1456        println!("  \x1b[36m:exit\x1b[0m                 \x1b[2mLeave the REPL\x1b[0m");
1457        println!("\x1b[2mLanguage shortcuts: :py, :js, :rs, :go, :cpp, :java, ...\x1b[0m");
1458    }
1459
1460    fn shutdown(&mut self) {
1461        for (_, mut session) in self.sessions.drain() {
1462            let _ = session.shutdown();
1463        }
1464    }
1465}
1466
1467fn render_outcome(outcome: &ExecutionOutcome) {
1468    if !outcome.stdout.is_empty() {
1469        print!("{}", ensure_trailing_newline(&outcome.stdout));
1470    }
1471    if !outcome.stderr.is_empty() {
1472        eprint!(
1473            "\x1b[31m{}\x1b[0m",
1474            ensure_trailing_newline(&outcome.stderr)
1475        );
1476    }
1477
1478    let millis = outcome.duration.as_millis();
1479    if let Some(code) = outcome.exit_code
1480        && code != 0
1481    {
1482        println!("\x1b[2m[exit {code}] {}\x1b[0m", format_duration(millis));
1483        return;
1484    }
1485
1486    // Show execution timing
1487    if millis > 0 {
1488        println!("\x1b[2m{}\x1b[0m", format_duration(millis));
1489    }
1490}
1491
1492fn format_duration(millis: u128) -> String {
1493    if millis >= 60_000 {
1494        let mins = millis / 60_000;
1495        let secs = (millis % 60_000) / 1000;
1496        format!("{mins}m {secs}s")
1497    } else if millis >= 1000 {
1498        let secs = millis as f64 / 1000.0;
1499        format!("{secs:.2}s")
1500    } else {
1501        format!("{millis}ms")
1502    }
1503}
1504
1505fn ensure_trailing_newline(text: &str) -> String {
1506    if text.ends_with('\n') {
1507        text.to_string()
1508    } else {
1509        let mut owned = text.to_string();
1510        owned.push('\n');
1511        owned
1512    }
1513}
1514
1515fn history_path() -> Option<PathBuf> {
1516    if let Ok(home) = std::env::var("HOME") {
1517        return Some(Path::new(&home).join(HISTORY_FILE));
1518    }
1519    None
1520}
1521
1522/// Extract variable/function/class names defined in a code snippet for tab completion.
1523fn extract_defined_names(code: &str, language_id: &str) -> Vec<String> {
1524    let mut names = Vec::new();
1525    for line in code.lines() {
1526        let trimmed = line.trim();
1527        match language_id {
1528            "python" | "py" | "python3" | "py3" => {
1529                // x = ..., def foo(...), class Bar:, import x, from x import y
1530                if let Some(rest) = trimmed.strip_prefix("def ") {
1531                    if let Some(name) = rest.split('(').next() {
1532                        let n = name.trim();
1533                        if !n.is_empty() {
1534                            names.push(n.to_string());
1535                        }
1536                    }
1537                } else if let Some(rest) = trimmed.strip_prefix("class ") {
1538                    let name = rest.split(['(', ':']).next().unwrap_or("").trim();
1539                    if !name.is_empty() {
1540                        names.push(name.to_string());
1541                    }
1542                } else if let Some(rest) = trimmed.strip_prefix("import ") {
1543                    for part in rest.split(',') {
1544                        let name = if let Some(alias) = part.split(" as ").nth(1) {
1545                            alias.trim()
1546                        } else {
1547                            part.trim().split('.').next_back().unwrap_or("")
1548                        };
1549                        if !name.is_empty() {
1550                            names.push(name.to_string());
1551                        }
1552                    }
1553                } else if trimmed.starts_with("from ") && trimmed.contains("import ") {
1554                    if let Some(imports) = trimmed.split("import ").nth(1) {
1555                        for part in imports.split(',') {
1556                            let name = if let Some(alias) = part.split(" as ").nth(1) {
1557                                alias.trim()
1558                            } else {
1559                                part.trim()
1560                            };
1561                            if !name.is_empty() {
1562                                names.push(name.to_string());
1563                            }
1564                        }
1565                    }
1566                } else if let Some(eq_pos) = trimmed.find('=') {
1567                    let lhs = &trimmed[..eq_pos];
1568                    if !lhs.contains('(')
1569                        && !lhs.contains('[')
1570                        && !trimmed[eq_pos..].starts_with("==")
1571                    {
1572                        for part in lhs.split(',') {
1573                            let name = part.trim().split(':').next().unwrap_or("").trim();
1574                            if !name.is_empty()
1575                                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1576                            {
1577                                names.push(name.to_string());
1578                            }
1579                        }
1580                    }
1581                }
1582            }
1583            "javascript" | "js" | "node" | "typescript" | "ts" => {
1584                // let/const/var x = ..., function foo(...), class Bar
1585                for prefix in ["let ", "const ", "var "] {
1586                    if let Some(rest) = trimmed.strip_prefix(prefix) {
1587                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
1588                        if !name.is_empty() {
1589                            names.push(name.to_string());
1590                        }
1591                    }
1592                }
1593                if let Some(rest) = trimmed.strip_prefix("function ") {
1594                    let name = rest.split('(').next().unwrap_or("").trim();
1595                    if !name.is_empty() {
1596                        names.push(name.to_string());
1597                    }
1598                } else if let Some(rest) = trimmed.strip_prefix("class ") {
1599                    let name = rest.split(['{', ' ']).next().unwrap_or("").trim();
1600                    if !name.is_empty() {
1601                        names.push(name.to_string());
1602                    }
1603                }
1604            }
1605            "rust" | "rs" => {
1606                for prefix in ["let ", "let mut "] {
1607                    if let Some(rest) = trimmed.strip_prefix(prefix) {
1608                        let name = rest.split(['=', ':', ';', ' ']).next().unwrap_or("").trim();
1609                        if !name.is_empty() {
1610                            names.push(name.to_string());
1611                        }
1612                    }
1613                }
1614                if let Some(rest) = trimmed.strip_prefix("fn ") {
1615                    let name = rest.split(['(', '<']).next().unwrap_or("").trim();
1616                    if !name.is_empty() {
1617                        names.push(name.to_string());
1618                    }
1619                } else if let Some(rest) = trimmed.strip_prefix("struct ") {
1620                    let name = rest.split(['{', '(', '<', ' ']).next().unwrap_or("").trim();
1621                    if !name.is_empty() {
1622                        names.push(name.to_string());
1623                    }
1624                }
1625            }
1626            _ => {
1627                // Generic: catch x = ... assignments
1628                if let Some(eq_pos) = trimmed.find('=') {
1629                    let lhs = trimmed[..eq_pos].trim();
1630                    if !lhs.is_empty()
1631                        && !trimmed[eq_pos..].starts_with("==")
1632                        && lhs
1633                            .chars()
1634                            .all(|c| c.is_alphanumeric() || c == '_' || c == ' ')
1635                        && let Some(name) = lhs.split_whitespace().last()
1636                    {
1637                        names.push(name.to_string());
1638                    }
1639                }
1640            }
1641        }
1642    }
1643    names
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648    use super::*;
1649
1650    #[test]
1651    fn language_aliases_resolve_in_registry() {
1652        let registry = LanguageRegistry::bootstrap();
1653        let aliases = [
1654            "python",
1655            "py",
1656            "python3",
1657            "rust",
1658            "rs",
1659            "go",
1660            "golang",
1661            "csharp",
1662            "cs",
1663            "c#",
1664            "typescript",
1665            "ts",
1666            "javascript",
1667            "js",
1668            "node",
1669            "ruby",
1670            "rb",
1671            "lua",
1672            "bash",
1673            "sh",
1674            "zsh",
1675            "java",
1676            "php",
1677            "kotlin",
1678            "kt",
1679            "c",
1680            "cpp",
1681            "c++",
1682            "swift",
1683            "swiftlang",
1684            "perl",
1685            "pl",
1686            "julia",
1687            "jl",
1688        ];
1689
1690        for alias in aliases {
1691            let spec = LanguageSpec::new(alias);
1692            assert!(
1693                registry.resolve(&spec).is_some(),
1694                "alias {alias} should resolve to a registered language"
1695            );
1696        }
1697    }
1698
1699    #[test]
1700    fn python_multiline_def_requires_blank_line_to_execute() {
1701        let mut p = PendingInput::new();
1702        p.push_line("def fib(n):");
1703        assert!(p.needs_more_input("python"));
1704        p.push_line("    return n");
1705        assert!(p.needs_more_input("python"));
1706        p.push_line(""); // blank line ends block
1707        assert!(!p.needs_more_input("python"));
1708    }
1709
1710    #[test]
1711    fn python_dict_literal_colon_does_not_trigger_block() {
1712        let mut p = PendingInput::new();
1713        p.push_line("x = {'key': 'value'}");
1714        assert!(
1715            !p.needs_more_input("python"),
1716            "dict literal should not trigger multi-line"
1717        );
1718    }
1719
1720    #[test]
1721    fn python_class_block_needs_body() {
1722        let mut p = PendingInput::new();
1723        p.push_line("class Foo:");
1724        assert!(p.needs_more_input("python"));
1725        p.push_line("    pass");
1726        assert!(p.needs_more_input("python")); // still indented
1727        p.push_line(""); // blank line ends
1728        assert!(!p.needs_more_input("python"));
1729    }
1730
1731    #[test]
1732    fn python_if_block_with_dedented_body_is_complete() {
1733        let mut p = PendingInput::new();
1734        p.push_line("if True:");
1735        assert!(p.needs_more_input("python"));
1736        p.push_line("    print('yes')");
1737        assert!(p.needs_more_input("python"));
1738        p.push_line(""); // blank line terminates
1739        assert!(!p.needs_more_input("python"));
1740    }
1741
1742    #[test]
1743    fn python_auto_indents_first_line_after_colon_header() {
1744        let mut p = PendingInput::new();
1745        p.push_line("def cool():");
1746        p.push_line_auto("python", r#"print("ok")"#);
1747        let code = p.take();
1748        assert!(
1749            code.contains("    print(\"ok\")\n"),
1750            "expected auto-indented print line, got:\n{code}"
1751        );
1752    }
1753
1754    #[test]
1755    fn generic_multiline_tracks_unclosed_delimiters() {
1756        let mut p = PendingInput::new();
1757        p.push_line("func(");
1758        assert!(p.needs_more_input("csharp"));
1759        p.push_line(")");
1760        assert!(!p.needs_more_input("csharp"));
1761    }
1762
1763    #[test]
1764    fn generic_multiline_tracks_trailing_equals() {
1765        let mut p = PendingInput::new();
1766        p.push_line("let x =");
1767        assert!(p.needs_more_input("rust"));
1768        p.push_line("10;");
1769        assert!(!p.needs_more_input("rust"));
1770    }
1771
1772    #[test]
1773    fn generic_multiline_tracks_trailing_dot() {
1774        let mut p = PendingInput::new();
1775        p.push_line("foo.");
1776        assert!(p.needs_more_input("csharp"));
1777        p.push_line("Bar()");
1778        assert!(!p.needs_more_input("csharp"));
1779    }
1780}