Skip to main content

run/engine/
ruby.rs

1use std::io::{BufRead, BufReader, Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use std::sync::{Arc, Mutex};
8use std::thread;
9
10use super::{
11    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, run_version_command,
12};
13
14pub struct RubyEngine {
15    executable: PathBuf,
16    irb: Option<PathBuf>,
17}
18
19impl Default for RubyEngine {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl RubyEngine {
26    pub fn new() -> Self {
27        let executable = resolve_ruby_binary();
28        let irb = resolve_irb_binary();
29        Self { executable, irb }
30    }
31
32    fn binary(&self) -> &Path {
33        &self.executable
34    }
35
36    fn run_command(&self) -> Command {
37        Command::new(self.binary())
38    }
39
40    fn ensure_irb(&self) -> Result<&Path> {
41        self.irb.as_deref().ok_or_else(|| {
42            anyhow::anyhow!(
43                "Interactive Ruby REPL requires the `irb` executable. Install it with your Ruby distribution and ensure it is on your PATH."
44            )
45        })
46    }
47}
48
49impl LanguageEngine for RubyEngine {
50    fn id(&self) -> &'static str {
51        "ruby"
52    }
53
54    fn display_name(&self) -> &'static str {
55        "Ruby"
56    }
57
58    fn aliases(&self) -> &[&'static str] {
59        &["rb"]
60    }
61
62    fn supports_sessions(&self) -> bool {
63        self.irb.is_some()
64    }
65
66    fn validate(&self) -> Result<()> {
67        let mut cmd = self.run_command();
68        cmd.arg("--version")
69            .stdout(Stdio::null())
70            .stderr(Stdio::null());
71        cmd.status()
72            .with_context(|| format!("failed to invoke {}", self.binary().display()))?
73            .success()
74            .then_some(())
75            .ok_or_else(|| anyhow::anyhow!("{} is not executable", self.binary().display()))
76    }
77
78    fn toolchain_version(&self) -> Result<Option<String>> {
79        let mut cmd = self.run_command();
80        cmd.arg("--version");
81        let context = format!("{}", self.binary().display());
82        run_version_command(cmd, &context)
83    }
84
85    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
86        let start = Instant::now();
87        let args = payload.args();
88        let output = match payload {
89            ExecutionPayload::Inline { code, .. } => {
90                let mut cmd = self.run_command();
91                cmd.arg("-e").arg(code).args(args);
92                cmd.stdin(Stdio::inherit());
93                cmd.output()
94            }
95            ExecutionPayload::File { path, .. } => {
96                let mut cmd = self.run_command();
97                cmd.arg(path).args(args);
98                cmd.stdin(Stdio::inherit());
99                cmd.output()
100            }
101            ExecutionPayload::Stdin { code, .. } => {
102                let mut cmd = self.run_command();
103                cmd.arg("-")
104                    .args(args)
105                    .stdin(Stdio::piped())
106                    .stdout(Stdio::piped())
107                    .stderr(Stdio::piped());
108                let mut child = cmd.spawn().with_context(|| {
109                    format!(
110                        "failed to start {} for stdin execution",
111                        self.binary().display()
112                    )
113                })?;
114                if let Some(mut stdin) = child.stdin.take() {
115                    stdin.write_all(code.as_bytes())?;
116                    if !code.ends_with('\n') {
117                        stdin.write_all(b"\n")?;
118                    }
119                    stdin.flush()?;
120                }
121                child.wait_with_output()
122            }
123        }?;
124
125        Ok(ExecutionOutcome {
126            language: self.id().to_string(),
127            exit_code: output.status.code(),
128            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
129            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
130            duration: start.elapsed(),
131        })
132    }
133
134    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
135        let irb = self.ensure_irb()?;
136        let mut cmd = Command::new(irb);
137        cmd.arg("--simple-prompt")
138            .stdin(Stdio::piped())
139            .stdout(Stdio::piped())
140            .stderr(Stdio::piped());
141
142        let mut child = cmd
143            .spawn()
144            .with_context(|| format!("failed to start {} REPL", irb.display()))?;
145
146        let stdout = child.stdout.take().context("missing stdout handle")?;
147        let stderr = child.stderr.take().context("missing stderr handle")?;
148
149        let stderr_buffer = Arc::new(Mutex::new(String::new()));
150        let stderr_collector = stderr_buffer.clone();
151        thread::spawn(move || {
152            let mut reader = BufReader::new(stderr);
153            let mut buf = String::new();
154            loop {
155                buf.clear();
156                match reader.read_line(&mut buf) {
157                    Ok(0) => break,
158                    Ok(_) => {
159                        let mut lock = match stderr_collector.lock() {
160                            Ok(lock) => lock,
161                            Err(poisoned) => poisoned.into_inner(),
162                        };
163                        lock.push_str(&buf);
164                    }
165                    Err(_) => break,
166                }
167            }
168        });
169
170        let mut session = RubySession {
171            child,
172            stdout: BufReader::new(stdout),
173            stderr: stderr_buffer,
174        };
175
176        session.discard_prompt()?;
177
178        Ok(Box::new(session))
179    }
180}
181
182fn resolve_ruby_binary() -> PathBuf {
183    let candidates = ["ruby"];
184    for name in candidates {
185        if let Ok(path) = which::which(name) {
186            return path;
187        }
188    }
189    PathBuf::from("ruby")
190}
191
192fn resolve_irb_binary() -> Option<PathBuf> {
193    which::which("irb").ok()
194}
195
196struct RubySession {
197    child: std::process::Child,
198    stdout: BufReader<std::process::ChildStdout>,
199    stderr: Arc<Mutex<String>>,
200}
201
202impl RubySession {
203    fn write_code(&mut self, code: &str) -> Result<()> {
204        let stdin = self
205            .child
206            .stdin
207            .as_mut()
208            .context("ruby session stdin closed")?;
209        stdin.write_all(code.as_bytes())?;
210        if !code.ends_with('\n') {
211            stdin.write_all(b"\n")?;
212        }
213        stdin.flush()?;
214        Ok(())
215    }
216
217    fn read_until_prompt(&mut self) -> Result<String> {
218        const PROMPTS: &[&[u8]] = &[b">> ", b"?> ", b"%l> ", b"*> "];
219        let mut buffer = Vec::new();
220        loop {
221            let mut byte = [0u8; 1];
222            let read = self.stdout.read(&mut byte)?;
223            if read == 0 {
224                break;
225            }
226            buffer.extend_from_slice(&byte[..read]);
227            if PROMPTS.iter().any(|prompt| buffer.ends_with(prompt)) {
228                break;
229            }
230        }
231
232        if let Some(prompt) = PROMPTS.iter().find(|prompt| buffer.ends_with(prompt)) {
233            buffer.truncate(buffer.len() - prompt.len());
234        }
235
236        let mut text = String::from_utf8_lossy(&buffer).into_owned();
237        text = text.replace("\r\n", "\n");
238        text = text.replace('\r', "");
239        Ok(strip_ruby_result(text))
240    }
241
242    fn take_stderr(&self) -> String {
243        let mut lock = match self.stderr.lock() {
244            Ok(lock) => lock,
245            Err(poisoned) => poisoned.into_inner(),
246        };
247        if lock.is_empty() {
248            String::new()
249        } else {
250            let mut output = String::new();
251            std::mem::swap(&mut output, &mut *lock);
252            output
253        }
254    }
255
256    fn discard_prompt(&mut self) -> Result<()> {
257        let _ = self.read_until_prompt()?;
258        let _ = self.take_stderr();
259        Ok(())
260    }
261}
262
263impl LanguageSession for RubySession {
264    fn language_id(&self) -> &str {
265        "ruby"
266    }
267
268    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
269        let start = Instant::now();
270        self.write_code(code)?;
271        let stdout = self.read_until_prompt()?;
272        let stderr = self.take_stderr();
273        Ok(ExecutionOutcome {
274            language: self.language_id().to_string(),
275            exit_code: None,
276            stdout,
277            stderr,
278            duration: start.elapsed(),
279        })
280    }
281
282    fn shutdown(&mut self) -> Result<()> {
283        if let Some(mut stdin) = self.child.stdin.take() {
284            let _ = stdin.write_all(b"exit\n");
285            let _ = stdin.flush();
286        }
287        let start = Instant::now();
288        loop {
289            if self.child.try_wait()?.is_some() {
290                break;
291            }
292            if start.elapsed() >= Duration::from_secs(2) {
293                let _ = self.child.kill();
294                let _ = self.child.wait();
295                break;
296            }
297            thread::sleep(Duration::from_millis(25));
298        }
299        Ok(())
300    }
301}
302
303fn strip_ruby_result(text: String) -> String {
304    let mut lines = Vec::new();
305    for line in text.lines() {
306        if let Some(stripped) = line.strip_prefix("=> ") {
307            lines.push(stripped.to_string());
308        } else if !line.trim().is_empty() {
309            lines.push(line.to_string());
310        }
311    }
312    lines.join("\n")
313}