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}