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}