run/
repl.rs

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                // No pending multiline input.
116                if raw.trim().is_empty() {
117                    continue;
118                }
119
120                // Meta commands are only recognized in "single line" mode.
121                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                // Start multiline mode if this line looks incomplete for the current language.
131                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>>, // keyed by canonical id
168    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                // allow :py style switching for any registered alias
494                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(""); // blank line ends block
730        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}