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::{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}