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