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::{
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 = stderr_collector.lock().expect("stderr collector poisoned");
160 lock.push_str(&buf);
161 }
162 Err(_) => break,
163 }
164 }
165 });
166
167 let mut session = RubySession {
168 child,
169 stdout: BufReader::new(stdout),
170 stderr: stderr_buffer,
171 };
172
173 session.discard_prompt()?;
174
175 Ok(Box::new(session))
176 }
177}
178
179fn resolve_ruby_binary() -> PathBuf {
180 let candidates = ["ruby"];
181 for name in candidates {
182 if let Ok(path) = which::which(name) {
183 return path;
184 }
185 }
186 PathBuf::from("ruby")
187}
188
189fn resolve_irb_binary() -> Option<PathBuf> {
190 which::which("irb").ok()
191}
192
193struct RubySession {
194 child: std::process::Child,
195 stdout: BufReader<std::process::ChildStdout>,
196 stderr: Arc<Mutex<String>>,
197}
198
199impl RubySession {
200 fn write_code(&mut self, code: &str) -> Result<()> {
201 let stdin = self
202 .child
203 .stdin
204 .as_mut()
205 .context("ruby session stdin closed")?;
206 stdin.write_all(code.as_bytes())?;
207 if !code.ends_with('\n') {
208 stdin.write_all(b"\n")?;
209 }
210 stdin.flush()?;
211 Ok(())
212 }
213
214 fn read_until_prompt(&mut self) -> Result<String> {
215 const PROMPTS: &[&[u8]] = &[b">> ", b"?> ", b"%l> ", b"*> "];
216 let mut buffer = Vec::new();
217 loop {
218 let mut byte = [0u8; 1];
219 let read = self.stdout.read(&mut byte)?;
220 if read == 0 {
221 break;
222 }
223 buffer.extend_from_slice(&byte[..read]);
224 if PROMPTS.iter().any(|prompt| buffer.ends_with(prompt)) {
225 break;
226 }
227 }
228
229 if let Some(prompt) = PROMPTS.iter().find(|prompt| buffer.ends_with(prompt)) {
230 buffer.truncate(buffer.len() - prompt.len());
231 }
232
233 let mut text = String::from_utf8_lossy(&buffer).into_owned();
234 text = text.replace("\r\n", "\n");
235 text = text.replace('\r', "");
236 Ok(strip_ruby_result(text))
237 }
238
239 fn take_stderr(&self) -> String {
240 let mut lock = self.stderr.lock().expect("stderr lock poisoned");
241 if lock.is_empty() {
242 String::new()
243 } else {
244 let mut output = String::new();
245 std::mem::swap(&mut output, &mut *lock);
246 output
247 }
248 }
249
250 fn discard_prompt(&mut self) -> Result<()> {
251 let _ = self.read_until_prompt()?;
252 let _ = self.take_stderr();
253 Ok(())
254 }
255}
256
257impl LanguageSession for RubySession {
258 fn language_id(&self) -> &str {
259 "ruby"
260 }
261
262 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
263 let start = Instant::now();
264 self.write_code(code)?;
265 let stdout = self.read_until_prompt()?;
266 let stderr = self.take_stderr();
267 Ok(ExecutionOutcome {
268 language: self.language_id().to_string(),
269 exit_code: None,
270 stdout,
271 stderr,
272 duration: start.elapsed(),
273 })
274 }
275
276 fn shutdown(&mut self) -> Result<()> {
277 if let Some(mut stdin) = self.child.stdin.take() {
278 let _ = stdin.write_all(b"exit\n");
279 let _ = stdin.flush();
280 }
281 let _ = self.child.wait();
282 Ok(())
283 }
284}
285
286fn strip_ruby_result(text: String) -> String {
287 let mut lines = Vec::new();
288 for line in text.lines() {
289 if let Some(stripped) = line.strip_prefix("=> ") {
290 lines.push(stripped.to_string());
291 } else if !line.trim().is_empty() {
292 lines.push(line.to_string());
293 }
294 }
295 lines.join("\n")
296}