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 let raw = line.trim_end_matches(['\r', '\n']);
92
93 if let Some(p) = pending.as_mut() {
94 if raw.trim() == ":cancel" {
95 pending = None;
96 continue;
97 }
98
99 p.push_line_auto(state.current_language().canonical_id(), raw);
100 if p.needs_more_input(state.current_language().canonical_id()) {
101 continue;
102 }
103
104 let code = p.take();
105 pending = None;
106 let trimmed = code.trim_end();
107 if !trimmed.is_empty() {
108 let _ = editor.add_history_entry(trimmed);
109 state.execute_snippet(trimmed)?;
110 }
111 continue;
112 }
113
114 if raw.trim().is_empty() {
115 continue;
116 }
117
118 if raw.trim_start().starts_with(':') {
119 let trimmed = raw.trim();
120 let _ = editor.add_history_entry(trimmed);
121 if state.handle_meta(trimmed)? {
122 break;
123 }
124 continue;
125 }
126
127 let mut p = PendingInput::new();
128 p.push_line(raw);
129 if p.needs_more_input(state.current_language().canonical_id()) {
130 pending = Some(p);
131 continue;
132 }
133
134 let trimmed = raw.trim_end();
135 let _ = editor.add_history_entry(trimmed);
136 state.execute_snippet(trimmed)?;
137 }
138 Err(ReadlineError::Interrupted) => {
139 println!("^C");
140 pending = None;
141 continue;
142 }
143 Err(ReadlineError::Eof) => {
144 println!("bye");
145 break;
146 }
147 Err(err) => {
148 bail!("readline error: {err}");
149 }
150 }
151 }
152
153 if let Some(path) = history_path() {
154 let _ = editor.save_history(&path);
155 }
156
157 state.shutdown();
158 Ok(0)
159}
160
161struct ReplState {
162 registry: LanguageRegistry,
163 sessions: HashMap<String, Box<dyn LanguageSession>>, current_language: LanguageSpec,
165 detect_enabled: bool,
166}
167
168struct PendingInput {
169 buf: String,
170}
171
172impl PendingInput {
173 fn new() -> Self {
174 Self { buf: String::new() }
175 }
176
177 fn prompt(&self) -> String {
178 "... ".to_string()
179 }
180
181 fn push_line(&mut self, line: &str) {
182 self.buf.push_str(line);
183 self.buf.push('\n');
184 }
185
186 fn push_line_auto(&mut self, language_id: &str, line: &str) {
187 match language_id {
188 "python" | "py" | "python3" | "py3" => {
189 let adjusted = python_auto_indent(line, &self.buf);
190 self.push_line(&adjusted);
191 }
192 _ => self.push_line(line),
193 }
194 }
195
196 fn take(&mut self) -> String {
197 std::mem::take(&mut self.buf)
198 }
199
200 fn needs_more_input(&self, language_id: &str) -> bool {
201 needs_more_input(language_id, &self.buf)
202 }
203}
204
205fn needs_more_input(language_id: &str, code: &str) -> bool {
206 match language_id {
207 "python" | "py" | "python3" | "py3" => needs_more_input_python(code),
208
209 _ => has_unclosed_delimiters(code) || generic_line_looks_incomplete(code),
210 }
211}
212
213fn generic_line_looks_incomplete(code: &str) -> bool {
214 let mut last: Option<&str> = None;
215 for line in code.lines().rev() {
216 let trimmed = line.trim_end();
217 if trimmed.trim().is_empty() {
218 continue;
219 }
220 last = Some(trimmed);
221 break;
222 }
223 let Some(line) = last else { return false };
224 let line = line.trim();
225 if line.is_empty() {
226 return false;
227 }
228
229 if line.ends_with('\\') {
230 return true;
231 }
232
233 const TAILS: [&str; 24] = [
234 "=", "+", "-", "*", "/", "%", "&", "|", "^", "!", "<", ">", "&&", "||", "??", "?:", "?",
235 ":", ".", ",", "=>", "->", "::", "..",
236 ];
237 if TAILS.iter().any(|tok| line.ends_with(tok)) {
238 return true;
239 }
240
241 const PREFIXES: [&str; 9] = [
242 "return", "throw", "yield", "await", "import", "from", "export", "case", "else",
243 ];
244 let lowered = line.to_ascii_lowercase();
245 if PREFIXES
246 .iter()
247 .any(|kw| lowered == *kw || lowered.ends_with(&format!(" {kw}")))
248 {
249 return true;
250 }
251
252 false
253}
254
255fn needs_more_input_python(code: &str) -> bool {
256 if has_unclosed_delimiters(code) {
257 return true;
258 }
259
260 let mut last_nonempty: Option<&str> = None;
261 let mut saw_colon_header = false;
262
263 for line in code.lines() {
264 let trimmed = line.trim_end();
265 if trimmed.trim().is_empty() {
266 continue;
267 }
268 last_nonempty = Some(trimmed);
269 if trimmed.ends_with(':') {
270 saw_colon_header = true;
271 }
272 }
273
274 if !saw_colon_header {
275 return false;
276 }
277
278 if code.ends_with("\n\n") {
279 return false;
280 }
281
282 last_nonempty.is_some()
283}
284
285fn python_auto_indent(line: &str, existing: &str) -> String {
286 let trimmed = line.trim_end_matches(['\r', '\n']);
287 let raw = trimmed;
288 if raw.trim().is_empty() {
289 return raw.to_string();
290 }
291
292 if raw.starts_with(' ') || raw.starts_with('\t') {
293 return raw.to_string();
294 }
295
296 let mut last_nonempty: Option<&str> = None;
297 for l in existing.lines().rev() {
298 if l.trim().is_empty() {
299 continue;
300 }
301 last_nonempty = Some(l);
302 break;
303 }
304
305 let Some(prev) = last_nonempty else {
306 return raw.to_string();
307 };
308 let prev_trimmed = prev.trim_end();
309
310 if !prev_trimmed.ends_with(':') {
311 return raw.to_string();
312 }
313
314 let lowered = raw.trim().to_ascii_lowercase();
315 if lowered.starts_with("else:")
316 || lowered.starts_with("elif ")
317 || lowered.starts_with("except")
318 || lowered.starts_with("finally:")
319 {
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);
494 if self.registry.resolve(&spec).is_some() {
495 self.switch_language(spec)?;
496 return Ok(false);
497 }
498 println!("unknown command: :{alias}. Type :help for help.");
499 }
500 }
501
502 Ok(false)
503 }
504
505 fn switch_language(&mut self, spec: LanguageSpec) -> Result<()> {
506 if self.current_language.canonical_id() == spec.canonical_id() {
507 println!("already using {}", spec.canonical_id());
508 return Ok(());
509 }
510 if self.registry.resolve(&spec).is_none() {
511 let available = self.registry.known_languages().join(", ");
512 bail!(
513 "language '{}' not supported. Available: {available}",
514 spec.canonical_id()
515 );
516 }
517 self.current_language = spec;
518 println!("switched to {}", self.current_language.canonical_id());
519 Ok(())
520 }
521
522 fn reset_current_session(&mut self) {
523 let key = self.current_language.canonical_id().to_string();
524 if let Some(mut session) = self.sessions.remove(&key) {
525 let _ = session.shutdown();
526 }
527 }
528
529 fn execute_snippet(&mut self, code: &str) -> Result<()> {
530 if self.detect_enabled {
531 if let Some(detected) = crate::detect::detect_language_from_snippet(code) {
532 if detected != self.current_language.canonical_id() {
533 let spec = LanguageSpec::new(detected.to_string());
534 if self.registry.resolve(&spec).is_some() {
535 println!(
536 "[auto-detect] switching {} -> {}",
537 self.current_language.canonical_id(),
538 spec.canonical_id()
539 );
540 self.current_language = spec;
541 }
542 }
543 }
544 }
545 let payload = ExecutionPayload::Inline {
546 code: code.to_string(),
547 };
548 self.execute_payload(payload)
549 }
550
551 fn execute_payload(&mut self, payload: ExecutionPayload) -> Result<()> {
552 let language = self.current_language.clone();
553 let outcome = match payload {
554 ExecutionPayload::Inline { code } => {
555 if self.engine_supports_sessions(&language)? {
556 self.eval_in_session(&language, &code)?
557 } else {
558 let engine = self
559 .registry
560 .resolve(&language)
561 .context("language engine not found")?;
562 engine.execute(&ExecutionPayload::Inline { code })?
563 }
564 }
565 ExecutionPayload::File { path } => {
566 let engine = self
567 .registry
568 .resolve(&language)
569 .context("language engine not found")?;
570 engine.execute(&ExecutionPayload::File { path })?
571 }
572 ExecutionPayload::Stdin { code } => {
573 let engine = self
574 .registry
575 .resolve(&language)
576 .context("language engine not found")?;
577 engine.execute(&ExecutionPayload::Stdin { code })?
578 }
579 };
580 render_outcome(&outcome);
581 Ok(())
582 }
583
584 fn engine_supports_sessions(&self, language: &LanguageSpec) -> Result<bool> {
585 Ok(self
586 .registry
587 .resolve(language)
588 .context("language engine not found")?
589 .supports_sessions())
590 }
591
592 fn eval_in_session(&mut self, language: &LanguageSpec, code: &str) -> Result<ExecutionOutcome> {
593 use std::collections::hash_map::Entry;
594 let key = language.canonical_id().to_string();
595 match self.sessions.entry(key) {
596 Entry::Occupied(mut entry) => entry.get_mut().eval(code),
597 Entry::Vacant(entry) => {
598 let engine = self
599 .registry
600 .resolve(language)
601 .context("language engine not found")?;
602 let mut session = engine.start_session().with_context(|| {
603 format!("failed to start {} session", language.canonical_id())
604 })?;
605 let outcome = session.eval(code)?;
606 entry.insert(session);
607 Ok(outcome)
608 }
609 }
610 }
611
612 fn print_languages(&self) {
613 let mut languages = self.registry.known_languages();
614 languages.sort();
615 println!("available languages: {}", languages.join(", "));
616 }
617
618 fn print_help(&self) {
619 println!("Commands:");
620 println!(" :help Show this help message");
621 println!(" :languages List available languages");
622 println!(" :lang <id> Switch to language <id>");
623 println!(" :detect on|off Enable or disable auto language detection");
624 println!(" :reset Reset the current language session");
625 println!(" :load <path> Execute a file in the current language");
626 println!(" :exit, :quit Leave the REPL");
627 println!("Any language id or alias works as a shortcut, e.g. :py, :cpp, :csharp, :php.");
628 }
629
630 fn shutdown(&mut self) {
631 for (_, mut session) in self.sessions.drain() {
632 let _ = session.shutdown();
633 }
634 }
635}
636
637fn render_outcome(outcome: &ExecutionOutcome) {
638 if !outcome.stdout.is_empty() {
639 print!("{}", ensure_trailing_newline(&outcome.stdout));
640 }
641 if !outcome.stderr.is_empty() {
642 eprint!("{}", ensure_trailing_newline(&outcome.stderr));
643 }
644 if let Some(code) = outcome.exit_code {
645 if code != 0 {
646 println!("[exit code {code}] ({}ms)", outcome.duration.as_millis());
647 }
648 }
649}
650
651fn ensure_trailing_newline(text: &str) -> String {
652 if text.ends_with('\n') {
653 text.to_string()
654 } else {
655 let mut owned = text.to_string();
656 owned.push('\n');
657 owned
658 }
659}
660
661fn history_path() -> Option<PathBuf> {
662 if let Ok(home) = std::env::var("HOME") {
663 return Some(Path::new(&home).join(HISTORY_FILE));
664 }
665 None
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[test]
673 fn language_aliases_resolve_in_registry() {
674 let registry = LanguageRegistry::bootstrap();
675 let aliases = [
676 "python",
677 "py",
678 "python3",
679 "rust",
680 "rs",
681 "go",
682 "golang",
683 "csharp",
684 "cs",
685 "c#",
686 "typescript",
687 "ts",
688 "javascript",
689 "js",
690 "node",
691 "ruby",
692 "rb",
693 "lua",
694 "bash",
695 "sh",
696 "zsh",
697 "java",
698 "php",
699 "kotlin",
700 "kt",
701 "c",
702 "cpp",
703 "c++",
704 "swift",
705 "swiftlang",
706 "perl",
707 "pl",
708 "julia",
709 "jl",
710 ];
711
712 for alias in aliases {
713 let spec = LanguageSpec::new(alias);
714 assert!(
715 registry.resolve(&spec).is_some(),
716 "alias {alias} should resolve to a registered language"
717 );
718 }
719 }
720
721 #[test]
722 fn python_multiline_def_requires_blank_line_to_execute() {
723 let mut p = PendingInput::new();
724 p.push_line("def fib(n):");
725 assert!(p.needs_more_input("python"));
726 p.push_line(" return n");
727 assert!(p.needs_more_input("python"));
728 p.push_line(""); assert!(!p.needs_more_input("python"));
730 }
731
732 #[test]
733 fn python_auto_indents_first_line_after_colon_header() {
734 let mut p = PendingInput::new();
735 p.push_line("def cool():");
736 p.push_line_auto("python", r#"print("ok")"#);
737 let code = p.take();
738 assert!(
739 code.contains(" print(\"ok\")\n"),
740 "expected auto-indented print line, got:\n{code}"
741 );
742 }
743
744 #[test]
745 fn generic_multiline_tracks_unclosed_delimiters() {
746 let mut p = PendingInput::new();
747 p.push_line("func(");
748 assert!(p.needs_more_input("csharp"));
749 p.push_line(")");
750 assert!(!p.needs_more_input("csharp"));
751 }
752
753 #[test]
754 fn generic_multiline_tracks_trailing_equals() {
755 let mut p = PendingInput::new();
756 p.push_line("let x =");
757 assert!(p.needs_more_input("rust"));
758 p.push_line("10;");
759 assert!(!p.needs_more_input("rust"));
760 }
761
762 #[test]
763 fn generic_multiline_tracks_trailing_dot() {
764 let mut p = PendingInput::new();
765 p.push_line("foo.");
766 assert!(p.needs_more_input("csharp"));
767 p.push_line("Bar()");
768 assert!(!p.needs_more_input("csharp"));
769 }
770}