Skip to main content

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                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>>, // keyed by canonical id
164    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(""); // blank line ends block
729        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}