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