1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use tempfile::{Builder, TempDir};
8
9use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
10
11pub struct REngine {
12 executable: Option<PathBuf>,
13}
14
15impl REngine {
16 pub fn new() -> Self {
17 Self {
18 executable: resolve_r_binary(),
19 }
20 }
21
22 fn ensure_executable(&self) -> Result<&Path> {
23 self.executable.as_deref().ok_or_else(|| {
24 anyhow::anyhow!(
25 "R support requires the `Rscript` executable. Install R from https://cran.r-project.org/ and ensure `Rscript` is on your PATH."
26 )
27 })
28 }
29
30 fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
31 let dir = Builder::new()
32 .prefix("run-r")
33 .tempdir()
34 .context("failed to create temporary directory for R source")?;
35 let path = dir.path().join("snippet.R");
36 let mut contents = code.to_string();
37 if !contents.ends_with('\n') {
38 contents.push('\n');
39 }
40 fs::write(&path, contents)
41 .with_context(|| format!("failed to write temporary R source to {}", path.display()))?;
42 Ok((dir, path))
43 }
44
45 fn execute_with_path(&self, source: &Path) -> Result<std::process::Output> {
46 let executable = self.ensure_executable()?;
47 let mut cmd = Command::new(executable);
48 cmd.arg("--vanilla")
49 .arg(source)
50 .stdout(Stdio::piped())
51 .stderr(Stdio::piped());
52 cmd.stdin(Stdio::inherit());
53 cmd.output().with_context(|| {
54 format!(
55 "failed to invoke {} to run {}",
56 executable.display(),
57 source.display()
58 )
59 })
60 }
61}
62
63impl LanguageEngine for REngine {
64 fn id(&self) -> &'static str {
65 "r"
66 }
67
68 fn display_name(&self) -> &'static str {
69 "R"
70 }
71
72 fn aliases(&self) -> &[&'static str] {
73 &["rscript"]
74 }
75
76 fn supports_sessions(&self) -> bool {
77 self.executable.is_some()
78 }
79
80 fn validate(&self) -> Result<()> {
81 let executable = self.ensure_executable()?;
82 let mut cmd = Command::new(executable);
83 cmd.arg("--version")
84 .stdout(Stdio::null())
85 .stderr(Stdio::null());
86 cmd.status()
87 .with_context(|| format!("failed to invoke {}", executable.display()))?
88 .success()
89 .then_some(())
90 .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
91 }
92
93 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
94 let start = Instant::now();
95 let (temp_dir, path) = match payload {
96 ExecutionPayload::Inline { code } => {
97 let (dir, path) = self.write_temp_source(code)?;
98 (Some(dir), path)
99 }
100 ExecutionPayload::Stdin { code } => {
101 let (dir, path) = self.write_temp_source(code)?;
102 (Some(dir), path)
103 }
104 ExecutionPayload::File { path } => (None, path.clone()),
105 };
106
107 let output = self.execute_with_path(&path)?;
108 drop(temp_dir);
109
110 Ok(ExecutionOutcome {
111 language: self.id().to_string(),
112 exit_code: output.status.code(),
113 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
114 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
115 duration: start.elapsed(),
116 })
117 }
118
119 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
120 let executable = self.ensure_executable()?.to_path_buf();
121 Ok(Box::new(RSession::new(executable)?))
122 }
123}
124
125fn resolve_r_binary() -> Option<PathBuf> {
126 which::which("Rscript").ok()
127}
128
129struct RSession {
130 executable: PathBuf,
131 dir: TempDir,
132 script_path: PathBuf,
133 statements: Vec<String>,
134 previous_stdout: String,
135 previous_stderr: String,
136}
137
138impl RSession {
139 fn new(executable: PathBuf) -> Result<Self> {
140 let dir = Builder::new()
141 .prefix("run-r-repl")
142 .tempdir()
143 .context("failed to create temporary directory for R repl")?;
144 let script_path = dir.path().join("session.R");
145 fs::write(&script_path, "options(warn=1)\n")
146 .with_context(|| format!("failed to initialize {}", script_path.display()))?;
147
148 Ok(Self {
149 executable,
150 dir,
151 script_path,
152 statements: Vec::new(),
153 previous_stdout: String::new(),
154 previous_stderr: String::new(),
155 })
156 }
157
158 fn render_script(&self) -> String {
159 let mut script = String::from("options(warn=1)\n");
160 for stmt in &self.statements {
161 script.push_str(stmt);
162 if !stmt.ends_with('\n') {
163 script.push('\n');
164 }
165 }
166 script
167 }
168
169 fn write_script(&self, contents: &str) -> Result<()> {
170 fs::write(&self.script_path, contents).with_context(|| {
171 format!(
172 "failed to write generated R REPL script to {}",
173 self.script_path.display()
174 )
175 })
176 }
177
178 fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
179 let script = self.render_script();
180 self.write_script(&script)?;
181
182 let mut cmd = Command::new(&self.executable);
183 cmd.arg("--vanilla")
184 .arg(&self.script_path)
185 .stdout(Stdio::piped())
186 .stderr(Stdio::piped())
187 .current_dir(self.dir.path());
188 let output = cmd.output().with_context(|| {
189 format!(
190 "failed to execute R session script {} with {}",
191 self.script_path.display(),
192 self.executable.display()
193 )
194 })?;
195
196 let stdout_full = normalize_output(&output.stdout);
197 let stderr_full = normalize_output(&output.stderr);
198
199 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
200 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
201
202 let success = output.status.success();
203 if success {
204 self.previous_stdout = stdout_full;
205 self.previous_stderr = stderr_full;
206 }
207
208 let outcome = ExecutionOutcome {
209 language: "r".to_string(),
210 exit_code: output.status.code(),
211 stdout: stdout_delta,
212 stderr: stderr_delta,
213 duration: start.elapsed(),
214 };
215
216 Ok((outcome, success))
217 }
218
219 fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
220 self.statements.push(snippet);
221 let start = Instant::now();
222 let (outcome, success) = self.run_current(start)?;
223 if !success {
224 let _ = self.statements.pop();
225 let script = self.render_script();
226 self.write_script(&script)?;
227 }
228 Ok(outcome)
229 }
230
231 fn reset_state(&mut self) -> Result<()> {
232 self.statements.clear();
233 self.previous_stdout.clear();
234 self.previous_stderr.clear();
235 let script = self.render_script();
236 self.write_script(&script)
237 }
238}
239
240impl LanguageSession for RSession {
241 fn language_id(&self) -> &str {
242 "r"
243 }
244
245 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
246 let trimmed = code.trim();
247 if trimmed.is_empty() {
248 return Ok(ExecutionOutcome {
249 language: self.language_id().to_string(),
250 exit_code: None,
251 stdout: String::new(),
252 stderr: String::new(),
253 duration: Duration::default(),
254 });
255 }
256
257 if trimmed.eq_ignore_ascii_case(":reset") {
258 self.reset_state()?;
259 return Ok(ExecutionOutcome {
260 language: self.language_id().to_string(),
261 exit_code: None,
262 stdout: String::new(),
263 stderr: String::new(),
264 duration: Duration::default(),
265 });
266 }
267
268 if trimmed.eq_ignore_ascii_case(":help") {
269 return Ok(ExecutionOutcome {
270 language: self.language_id().to_string(),
271 exit_code: None,
272 stdout:
273 "R commands:\n :reset — clear session state\n :help — show this message\n"
274 .to_string(),
275 stderr: String::new(),
276 duration: Duration::default(),
277 });
278 }
279
280 let snippet = if should_wrap_expression(trimmed) {
281 wrap_expression(trimmed)
282 } else {
283 ensure_trailing_newline(code)
284 };
285
286 self.run_snippet(snippet)
287 }
288
289 fn shutdown(&mut self) -> Result<()> {
290 Ok(())
292 }
293}
294
295fn should_wrap_expression(code: &str) -> bool {
296 if code.contains('\n') {
297 return false;
298 }
299
300 let lowered = code.trim_start().to_ascii_lowercase();
301 const STATEMENT_PREFIXES: [&str; 12] = [
302 "if ", "for ", "while ", "repeat", "function", "library", "require", "print", "cat",
303 "source", "options", "setwd",
304 ];
305 if STATEMENT_PREFIXES
306 .iter()
307 .any(|prefix| lowered.starts_with(prefix))
308 {
309 return false;
310 }
311
312 if code.contains("<-") || code.contains("=") {
313 return false;
314 }
315
316 true
317}
318
319fn wrap_expression(code: &str) -> String {
320 format!("print(({}))\n", code)
321}
322
323fn ensure_trailing_newline(code: &str) -> String {
324 let mut owned = code.to_string();
325 if !owned.ends_with('\n') {
326 owned.push('\n');
327 }
328 owned
329}
330
331fn diff_output(previous: &str, current: &str) -> String {
332 if let Some(stripped) = current.strip_prefix(previous) {
333 stripped.to_string()
334 } else {
335 current.to_string()
336 }
337}
338
339fn normalize_output(bytes: &[u8]) -> String {
340 String::from_utf8_lossy(bytes)
341 .replace("\r\n", "\n")
342 .replace('\r', "")
343}