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