Skip to main content

run/engine/
r.rs

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}