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