1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use rustyline::completion::Completer;
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::{ExecutionOutcome, ExecutionPayload, LanguageRegistry, LanguageSession};
15use crate::highlight;
16use crate::language::LanguageSpec;
17
18const HISTORY_FILE: &str = ".run_history";
19
20struct ReplHelper {
21 language_id: String,
22}
23
24impl ReplHelper {
25 fn new(language_id: String) -> Self {
26 Self { language_id }
27 }
28
29 fn update_language(&mut self, language_id: String) {
30 self.language_id = language_id;
31 }
32}
33
34impl Completer for ReplHelper {
35 type Candidate = String;
36}
37
38impl Hinter for ReplHelper {
39 type Hint = String;
40}
41
42impl Validator for ReplHelper {}
43
44impl Highlighter for ReplHelper {
45 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
46 if line.trim_start().starts_with(':') {
47 return Cow::Borrowed(line);
48 }
49
50 let highlighted = highlight::highlight_repl_input(line, &self.language_id);
51 Cow::Owned(highlighted)
52 }
53
54 fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
55 true
56 }
57}
58
59impl Helper for ReplHelper {}
60
61pub fn run_repl(
62 initial_language: LanguageSpec,
63 registry: LanguageRegistry,
64 detect_enabled: bool,
65) -> Result<i32> {
66 let helper = ReplHelper::new(initial_language.canonical_id().to_string());
67 let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?;
68 editor.set_helper(Some(helper));
69
70 if let Some(path) = history_path() {
71 let _ = editor.load_history(&path);
72 }
73
74 println!("run universal REPL. Type :help for commands.");
75
76 let mut state = ReplState::new(initial_language, registry, detect_enabled)?;
77 let mut pending: Option<PendingInput> = None;
78
79 loop {
80 let prompt = match &pending {
81 Some(p) => p.prompt(),
82 None => state.prompt(),
83 };
84
85 if let Some(helper) = editor.helper_mut() {
86 helper.update_language(state.current_language().canonical_id().to_string());
87 }
88
89 match editor.readline(&prompt) {
90 Ok(line) => {
91
92 let raw = line.trim_end_matches(['\r', '\n']);
93
94 if let Some(p) = pending.as_mut() {
95 if raw.trim() == ":cancel" {
96 pending = None;
97 continue;
98 }
99
100 p.push_line_auto(state.current_language().canonical_id(), raw);
101 if p.needs_more_input(state.current_language().canonical_id()) {
102 continue;
103 }
104
105 let code = p.take();
106 pending = None;
107 let trimmed = code.trim_end();
108 if !trimmed.is_empty() {
109 let _ = editor.add_history_entry(trimmed);
110 state.execute_snippet(trimmed)?;
111 }
112 continue;
113 }
114
115 if raw.trim().is_empty() {
117 continue;
118 }
119
120 if raw.trim_start().starts_with(':') {
122 let trimmed = raw.trim();
123 let _ = editor.add_history_entry(trimmed);
124 if state.handle_meta(trimmed)? {
125 break;
126 }
127 continue;
128 }
129
130 let mut p = PendingInput::new();
132 p.push_line(raw);
133 if p.needs_more_input(state.current_language().canonical_id()) {
134 pending = Some(p);
135 continue;
136 }
137
138 let trimmed = raw.trim_end();
139 let _ = editor.add_history_entry(trimmed);
140 state.execute_snippet(trimmed)?;
141 }
142 Err(ReadlineError::Interrupted) => {
143 println!("^C");
144 pending = None;
145 continue;
146 }
147 Err(ReadlineError::Eof) => {
148 println!("bye");
149 break;
150 }
151 Err(err) => {
152 bail!("readline error: {err}");
153 }
154 }
155 }
156
157 if let Some(path) = history_path() {
158 let _ = editor.save_history(&path);
159 }
160
161 state.shutdown();
162 Ok(0)
163}
164
165struct ReplState {
166 registry: LanguageRegistry,
167 sessions: HashMap<String, Box<dyn LanguageSession>>, current_language: LanguageSpec,
169 detect_enabled: bool,
170}
171
172struct PendingInput {
173 buf: String,
174}
175
176impl PendingInput {
177 fn new() -> Self {
178 Self { buf: String::new() }
179 }
180
181 fn prompt(&self) -> String {
182 "... ".to_string()
183 }
184
185 fn push_line(&mut self, line: &str) {
186 self.buf.push_str(line);
187 self.buf.push('\n');
188 }
189
190 fn push_line_auto(&mut self, language_id: &str, line: &str) {
191 match language_id {
192 "python" | "py" | "python3" | "py3" => {
193 let adjusted = python_auto_indent(line, &self.buf);
194 self.push_line(&adjusted);
195 }
196 _ => self.push_line(line),
197 }
198 }
199
200 fn take(&mut self) -> String {
201 std::mem::take(&mut self.buf)
202 }
203
204 fn needs_more_input(&self, language_id: &str) -> bool {
205 needs_more_input(language_id, &self.buf)
206 }
207}
208
209fn needs_more_input(language_id: &str, code: &str) -> bool {
210
211 match language_id {
212 "python" | "py" | "python3" | "py3" => needs_more_input_python(code),
213
214 _ => has_unclosed_delimiters(code) || generic_line_looks_incomplete(code),
215 }
216}
217
218fn generic_line_looks_incomplete(code: &str) -> bool {
219 let mut last: Option<&str> = None;
220 for line in code.lines().rev() {
221 let trimmed = line.trim_end();
222 if trimmed.trim().is_empty() {
223 continue;
224 }
225 last = Some(trimmed);
226 break;
227 }
228 let Some(line) = last else { return false };
229 let line = line.trim();
230 if line.is_empty() {
231 return false;
232 }
233
234 if line.ends_with('\\') {
235 return true;
236 }
237
238 const TAILS: [&str; 24] = [
239 "=", "+", "-", "*", "/", "%", "&", "|", "^", "!", "<", ">",
240 "&&", "||", "??", "?:", "?", ":", ".", ",",
241 "=>", "->", "::", "..",
242 ];
243 if TAILS.iter().any(|tok| line.ends_with(tok)) {
244 return true;
245 }
246
247 const PREFIXES: [&str; 9] = [
248 "return", "throw", "yield", "await", "import", "from", "export", "case", "else",
249 ];
250 let lowered = line.to_ascii_lowercase();
251 if PREFIXES.iter().any(|kw| lowered == *kw || lowered.ends_with(&format!(" {kw}"))) {
252 return true;
253 }
254
255 false
256}
257
258fn needs_more_input_python(code: &str) -> bool {
259 if has_unclosed_delimiters(code) {
260 return true;
261 }
262
263
264 let mut last_nonempty: Option<&str> = None;
265 let mut saw_colon_header = false;
266
267 for line in code.lines() {
268 let trimmed = line.trim_end();
269 if trimmed.trim().is_empty() {
270 continue;
271 }
272 last_nonempty = Some(trimmed);
273 if trimmed.ends_with(':') {
274 saw_colon_header = true;
275 }
276 }
277
278 if !saw_colon_header {
279 return false;
280 }
281
282 if code.ends_with("\n\n") {
283 return false;
284 }
285
286 last_nonempty.is_some()
287}
288
289fn python_auto_indent(line: &str, existing: &str) -> String {
290 let trimmed = line.trim_end_matches(['\r', '\n']);
291 let raw = trimmed;
292 if raw.trim().is_empty() {
293 return raw.to_string();
294 }
295
296 if raw.starts_with(' ') || raw.starts_with('\t') {
297 return raw.to_string();
298 }
299
300 let mut last_nonempty: Option<&str> = None;
301 for l in existing.lines().rev() {
302 if l.trim().is_empty() {
303 continue;
304 }
305 last_nonempty = Some(l);
306 break;
307 }
308
309 let Some(prev) = last_nonempty else {
310 return raw.to_string();
311 };
312 let prev_trimmed = prev.trim_end();
313
314 if !prev_trimmed.ends_with(':') {
315 return raw.to_string();
316 }
317
318 let lowered = raw.trim().to_ascii_lowercase();
319 if lowered.starts_with("else:") || lowered.starts_with("elif ") || lowered.starts_with("except") || lowered.starts_with("finally:") {
320 return raw.to_string();
321 }
322
323 let base_indent = prev
324 .chars()
325 .take_while(|c| *c == ' ' || *c == '\t')
326 .collect::<String>();
327
328 format!("{base_indent} {raw}")
329}
330
331fn has_unclosed_delimiters(code: &str) -> bool {
332 let mut paren = 0i32;
333 let mut bracket = 0i32;
334 let mut brace = 0i32;
335
336 let mut in_single = false;
337 let mut in_double = false;
338 let mut escape = false;
339
340 for ch in code.chars() {
341 if escape {
342 escape = false;
343 continue;
344 }
345
346 if in_single {
347 if ch == '\\' {
348 escape = true;
349 } else if ch == '\'' {
350 in_single = false;
351 }
352 continue;
353 }
354 if in_double {
355 if ch == '\\' {
356 escape = true;
357 } else if ch == '"' {
358 in_double = false;
359 }
360 continue;
361 }
362
363 match ch {
364 '\'' => in_single = true,
365 '"' => in_double = true,
366 '(' => paren += 1,
367 ')' => paren -= 1,
368 '[' => bracket += 1,
369 ']' => bracket -= 1,
370 '{' => brace += 1,
371 '}' => brace -= 1,
372 _ => {}
373 }
374 }
375
376 paren > 0 || bracket > 0 || brace > 0
377}
378
379impl ReplState {
380 fn new(
381 initial_language: LanguageSpec,
382 registry: LanguageRegistry,
383 detect_enabled: bool,
384 ) -> Result<Self> {
385 let mut state = Self {
386 registry,
387 sessions: HashMap::new(),
388 current_language: initial_language,
389 detect_enabled,
390 };
391 state.ensure_current_language()?;
392 Ok(state)
393 }
394
395 fn current_language(&self) -> &LanguageSpec {
396 &self.current_language
397 }
398
399 fn prompt(&self) -> String {
400 format!("{}>>> ", self.current_language.canonical_id())
401 }
402
403 fn ensure_current_language(&mut self) -> Result<()> {
404 if self.registry.resolve(&self.current_language).is_none() {
405 bail!(
406 "language '{}' is not available",
407 self.current_language.canonical_id()
408 );
409 }
410 Ok(())
411 }
412
413 fn handle_meta(&mut self, line: &str) -> Result<bool> {
414 let command = line.trim_start_matches(':').trim();
415 if command.is_empty() {
416 return Ok(false);
417 }
418
419 let mut parts = command.split_whitespace();
420 let head = parts.next().unwrap();
421 match head {
422 "exit" | "quit" => return Ok(true),
423 "help" => {
424 self.print_help();
425 return Ok(false);
426 }
427 "languages" => {
428 self.print_languages();
429 return Ok(false);
430 }
431 "detect" => {
432 if let Some(arg) = parts.next() {
433 match arg {
434 "on" | "true" | "1" => {
435 self.detect_enabled = true;
436 println!("auto-detect enabled");
437 }
438 "off" | "false" | "0" => {
439 self.detect_enabled = false;
440 println!("auto-detect disabled");
441 }
442 "toggle" => {
443 self.detect_enabled = !self.detect_enabled;
444 println!(
445 "auto-detect {}",
446 if self.detect_enabled {
447 "enabled"
448 } else {
449 "disabled"
450 }
451 );
452 }
453 _ => println!("usage: :detect <on|off|toggle>"),
454 }
455 } else {
456 println!(
457 "auto-detect is {}",
458 if self.detect_enabled {
459 "enabled"
460 } else {
461 "disabled"
462 }
463 );
464 }
465 return Ok(false);
466 }
467 "lang" => {
468 if let Some(lang) = parts.next() {
469 self.switch_language(LanguageSpec::new(lang.to_string()))?;
470 } else {
471 println!("usage: :lang <language>");
472 }
473 return Ok(false);
474 }
475 "reset" => {
476 self.reset_current_session();
477 println!(
478 "session for '{}' reset",
479 self.current_language.canonical_id()
480 );
481 return Ok(false);
482 }
483 "load" | "run" => {
484 if let Some(token) = parts.next() {
485 let path = PathBuf::from(token);
486 self.execute_payload(ExecutionPayload::File { path })?;
487 } else {
488 println!("usage: :load <path>");
489 }
490 return Ok(false);
491 }
492 alias => {
493 let spec = LanguageSpec::new(alias);
495 if self.registry.resolve(&spec).is_some() {
496 self.switch_language(spec)?;
497 return Ok(false);
498 }
499 println!("unknown command: :{alias}. Type :help for help.");
500 }
501 }
502
503 Ok(false)
504 }
505
506 fn switch_language(&mut self, spec: LanguageSpec) -> Result<()> {
507 if self.current_language.canonical_id() == spec.canonical_id() {
508 println!("already using {}", spec.canonical_id());
509 return Ok(());
510 }
511 if self.registry.resolve(&spec).is_none() {
512 let available = self.registry.known_languages().join(", ");
513 bail!(
514 "language '{}' not supported. Available: {available}",
515 spec.canonical_id()
516 );
517 }
518 self.current_language = spec;
519 println!("switched to {}", self.current_language.canonical_id());
520 Ok(())
521 }
522
523 fn reset_current_session(&mut self) {
524 let key = self.current_language.canonical_id().to_string();
525 if let Some(mut session) = self.sessions.remove(&key) {
526 let _ = session.shutdown();
527 }
528 }
529
530 fn execute_snippet(&mut self, code: &str) -> Result<()> {
531 if self.detect_enabled {
532 if let Some(detected) = crate::detect::detect_language_from_snippet(code) {
533 if detected != self.current_language.canonical_id() {
534 let spec = LanguageSpec::new(detected.to_string());
535 if self.registry.resolve(&spec).is_some() {
536 println!(
537 "[auto-detect] switching {} → {}",
538 self.current_language.canonical_id(),
539 spec.canonical_id()
540 );
541 self.current_language = spec;
542 }
543 }
544 }
545 }
546 let payload = ExecutionPayload::Inline {
547 code: code.to_string(),
548 };
549 self.execute_payload(payload)
550 }
551
552 fn execute_payload(&mut self, payload: ExecutionPayload) -> Result<()> {
553 let language = self.current_language.clone();
554 let outcome = match payload {
555 ExecutionPayload::Inline { code } => {
556 if self.engine_supports_sessions(&language)? {
557 self.eval_in_session(&language, &code)?
558 } else {
559 let engine = self
560 .registry
561 .resolve(&language)
562 .context("language engine not found")?;
563 engine.execute(&ExecutionPayload::Inline { code })?
564 }
565 }
566 ExecutionPayload::File { path } => {
567 let engine = self
568 .registry
569 .resolve(&language)
570 .context("language engine not found")?;
571 engine.execute(&ExecutionPayload::File { path })?
572 }
573 ExecutionPayload::Stdin { code } => {
574 let engine = self
575 .registry
576 .resolve(&language)
577 .context("language engine not found")?;
578 engine.execute(&ExecutionPayload::Stdin { code })?
579 }
580 };
581 render_outcome(&outcome);
582 Ok(())
583 }
584
585 fn engine_supports_sessions(&self, language: &LanguageSpec) -> Result<bool> {
586 Ok(self
587 .registry
588 .resolve(language)
589 .context("language engine not found")?
590 .supports_sessions())
591 }
592
593 fn eval_in_session(&mut self, language: &LanguageSpec, code: &str) -> Result<ExecutionOutcome> {
594 use std::collections::hash_map::Entry;
595 let key = language.canonical_id().to_string();
596 match self.sessions.entry(key) {
597 Entry::Occupied(mut entry) => entry.get_mut().eval(code),
598 Entry::Vacant(entry) => {
599 let engine = self
600 .registry
601 .resolve(language)
602 .context("language engine not found")?;
603 let mut session = engine.start_session().with_context(|| {
604 format!("failed to start {} session", language.canonical_id())
605 })?;
606 let outcome = session.eval(code)?;
607 entry.insert(session);
608 Ok(outcome)
609 }
610 }
611 }
612
613 fn print_languages(&self) {
614 let mut languages = self.registry.known_languages();
615 languages.sort();
616 println!("available languages: {}", languages.join(", "));
617 }
618
619 fn print_help(&self) {
620 println!("Commands:");
621 println!(" :help Show this help message");
622 println!(" :languages List available languages");
623 println!(" :lang <id> Switch to language <id>");
624 println!(" :detect on|off Enable or disable auto language detection");
625 println!(" :reset Reset the current language session");
626 println!(" :load <path> Execute a file in the current language");
627 println!(" :exit, :quit Leave the REPL");
628 println!("Any language id or alias works as a shortcut, e.g. :py, :cpp, :csharp, :php.");
629 }
630
631 fn shutdown(&mut self) {
632 for (_, mut session) in self.sessions.drain() {
633 let _ = session.shutdown();
634 }
635 }
636}
637
638fn render_outcome(outcome: &ExecutionOutcome) {
639 if !outcome.stdout.is_empty() {
640 print!("{}", ensure_trailing_newline(&outcome.stdout));
641 }
642 if !outcome.stderr.is_empty() {
643 eprint!("{}", ensure_trailing_newline(&outcome.stderr));
644 }
645 if let Some(code) = outcome.exit_code {
646 if code != 0 {
647 println!("[exit code {code}] ({}ms)", outcome.duration.as_millis());
648 }
649 }
650}
651
652fn ensure_trailing_newline(text: &str) -> String {
653 if text.ends_with('\n') {
654 text.to_string()
655 } else {
656 let mut owned = text.to_string();
657 owned.push('\n');
658 owned
659 }
660}
661
662fn history_path() -> Option<PathBuf> {
663 if let Ok(home) = std::env::var("HOME") {
664 return Some(Path::new(&home).join(HISTORY_FILE));
665 }
666 None
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn language_aliases_resolve_in_registry() {
675 let registry = LanguageRegistry::bootstrap();
676 let aliases = [
677 "python",
678 "py",
679 "python3",
680 "rust",
681 "rs",
682 "go",
683 "golang",
684 "csharp",
685 "cs",
686 "c#",
687 "typescript",
688 "ts",
689 "javascript",
690 "js",
691 "node",
692 "ruby",
693 "rb",
694 "lua",
695 "bash",
696 "sh",
697 "zsh",
698 "java",
699 "php",
700 "kotlin",
701 "kt",
702 "c",
703 "cpp",
704 "c++",
705 "swift",
706 "swiftlang",
707 "perl",
708 "pl",
709 "julia",
710 "jl",
711 ];
712
713 for alias in aliases {
714 let spec = LanguageSpec::new(alias);
715 assert!(
716 registry.resolve(&spec).is_some(),
717 "alias {alias} should resolve to a registered language"
718 );
719 }
720 }
721
722 #[test]
723 fn python_multiline_def_requires_blank_line_to_execute() {
724 let mut p = PendingInput::new();
725 p.push_line("def fib(n):");
726 assert!(p.needs_more_input("python"));
727 p.push_line(" return n");
728 assert!(p.needs_more_input("python"));
729 p.push_line(""); assert!(!p.needs_more_input("python"));
731 }
732
733 #[test]
734 fn python_auto_indents_first_line_after_colon_header() {
735 let mut p = PendingInput::new();
736 p.push_line("def cool():");
737 p.push_line_auto("python", r#"print("ok")"#);
738 let code = p.take();
739 assert!(
740 code.contains(" print(\"ok\")\n"),
741 "expected auto-indented print line, got:\n{code}"
742 );
743 }
744
745 #[test]
746 fn generic_multiline_tracks_unclosed_delimiters() {
747 let mut p = PendingInput::new();
748 p.push_line("func(");
749 assert!(p.needs_more_input("csharp"));
750 p.push_line(")");
751 assert!(!p.needs_more_input("csharp"));
752 }
753
754 #[test]
755 fn generic_multiline_tracks_trailing_equals() {
756 let mut p = PendingInput::new();
757 p.push_line("let x =");
758 assert!(p.needs_more_input("rust"));
759 p.push_line("10;");
760 assert!(!p.needs_more_input("rust"));
761 }
762
763 #[test]
764 fn generic_multiline_tracks_trailing_dot() {
765 let mut p = PendingInput::new();
766 p.push_line("foo.");
767 assert!(p.needs_more_input("csharp"));
768 p.push_line("Bar()");
769 assert!(!p.needs_more_input("csharp"));
770 }
771}