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::{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        // TempDir cleanup handled automatically.
291        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}