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