run/engine/
groovy.rs

1use std::borrow::Cow;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result};
9use tempfile::{Builder, TempDir};
10
11use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
12
13pub struct GroovyEngine {
14    executable: Option<PathBuf>,
15}
16
17impl GroovyEngine {
18    pub fn new() -> Self {
19        let executable = resolve_groovy_binary();
20        Self { executable }
21    }
22
23    fn ensure_binary(&self) -> Result<&Path> {
24        self.executable.as_deref().ok_or_else(|| {
25            anyhow::anyhow!(
26                "Groovy support requires the `groovy` executable. Install it from https://groovy-lang.org/download.html and make sure it is available on your PATH."
27            )
28        })
29    }
30}
31
32impl LanguageEngine for GroovyEngine {
33    fn id(&self) -> &'static str {
34        "groovy"
35    }
36
37    fn display_name(&self) -> &'static str {
38        "Groovy"
39    }
40
41    fn aliases(&self) -> &[&'static str] {
42        &["grv"]
43    }
44
45    fn supports_sessions(&self) -> bool {
46        self.executable.is_some()
47    }
48
49    fn validate(&self) -> Result<()> {
50        let binary = self.ensure_binary()?;
51        let mut cmd = Command::new(binary);
52        cmd.arg("--version")
53            .stdout(Stdio::null())
54            .stderr(Stdio::null());
55        cmd.status()
56            .with_context(|| format!("failed to invoke {}", binary.display()))?
57            .success()
58            .then_some(())
59            .ok_or_else(|| anyhow::anyhow!("{} is not executable", binary.display()))
60    }
61
62    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
63        let binary = self.ensure_binary()?;
64        let start = Instant::now();
65        let output = match payload {
66            ExecutionPayload::Inline { code } => {
67                let prepared = prepare_groovy_source(code);
68                let mut cmd = Command::new(binary);
69                cmd.arg("-e").arg(prepared.as_ref());
70                cmd.stdin(Stdio::inherit());
71                cmd.output().with_context(|| {
72                    format!(
73                        "failed to execute {} for inline Groovy snippet",
74                        binary.display()
75                    )
76                })
77            }
78            ExecutionPayload::File { path } => {
79                let mut cmd = Command::new(binary);
80                cmd.arg(path);
81                cmd.stdin(Stdio::inherit());
82                cmd.output().with_context(|| {
83                    format!(
84                        "failed to execute {} for Groovy script {}",
85                        binary.display(),
86                        path.display()
87                    )
88                })
89            }
90            ExecutionPayload::Stdin { code } => {
91                let mut script = Builder::new()
92                    .prefix("run-groovy-stdin")
93                    .suffix(".groovy")
94                    .tempfile()
95                    .context("failed to create temporary Groovy script for stdin input")?;
96                let mut prepared = prepare_groovy_source(code).into_owned();
97                if !prepared.ends_with('\n') {
98                    prepared.push('\n');
99                }
100                script
101                    .write_all(prepared.as_bytes())
102                    .context("failed to write piped Groovy source")?;
103                script.flush()?;
104
105                let script_path = script.path().to_path_buf();
106                let mut cmd = Command::new(binary);
107                cmd.arg(&script_path);
108                cmd.stdin(Stdio::null());
109                let output = cmd.output().with_context(|| {
110                    format!(
111                        "failed to execute {} for Groovy stdin script {}",
112                        binary.display(),
113                        script_path.display()
114                    )
115                })?;
116                drop(script);
117                Ok(output)
118            }
119        }?;
120
121        Ok(ExecutionOutcome {
122            language: self.id().to_string(),
123            exit_code: output.status.code(),
124            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
125            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
126            duration: start.elapsed(),
127        })
128    }
129
130    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
131        let executable = self.ensure_binary()?.to_path_buf();
132        Ok(Box::new(GroovySession::new(executable)?))
133    }
134}
135
136fn resolve_groovy_binary() -> Option<PathBuf> {
137    which::which("groovy").ok()
138}
139
140struct GroovySession {
141    executable: PathBuf,
142    dir: TempDir,
143    source_path: PathBuf,
144    statements: Vec<String>,
145    previous_stdout: String,
146    previous_stderr: String,
147}
148
149impl GroovySession {
150    fn new(executable: PathBuf) -> Result<Self> {
151        let dir = Builder::new()
152            .prefix("run-groovy-repl")
153            .tempdir()
154            .context("failed to create temporary directory for groovy repl")?;
155        let source_path = dir.path().join("session.groovy");
156        fs::write(&source_path, "// Groovy REPL session\n").with_context(|| {
157            format!(
158                "failed to initialize generated groovy session source at {}",
159                source_path.display()
160            )
161        })?;
162
163        Ok(Self {
164            executable,
165            dir,
166            source_path,
167            statements: Vec::new(),
168            previous_stdout: String::new(),
169            previous_stderr: String::new(),
170        })
171    }
172
173    fn render_source(&self) -> String {
174        let mut source = String::from("// Generated by run Groovy REPL\n");
175        for snippet in &self.statements {
176            source.push_str(snippet);
177            if !snippet.ends_with('\n') {
178                source.push('\n');
179            }
180        }
181        source
182    }
183
184    fn write_source(&self, contents: &str) -> Result<()> {
185        fs::write(&self.source_path, contents).with_context(|| {
186            format!(
187                "failed to write generated Groovy REPL source to {}",
188                self.source_path.display()
189            )
190        })
191    }
192
193    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
194        let source = self.render_source();
195        self.write_source(&source)?;
196
197        let output = self.run_script()?;
198        let stdout_full = normalize_output(&output.stdout);
199        let stderr_full = normalize_output(&output.stderr);
200
201        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
202        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
203
204        let success = output.status.success();
205        if success {
206            self.previous_stdout = stdout_full;
207            self.previous_stderr = stderr_full;
208        }
209
210        let outcome = ExecutionOutcome {
211            language: "groovy".to_string(),
212            exit_code: output.status.code(),
213            stdout: stdout_delta,
214            stderr: stderr_delta,
215            duration: start.elapsed(),
216        };
217
218        Ok((outcome, success))
219    }
220
221    fn run_script(&self) -> Result<std::process::Output> {
222        let mut cmd = Command::new(&self.executable);
223        cmd.arg(&self.source_path)
224            .stdout(Stdio::piped())
225            .stderr(Stdio::piped())
226            .current_dir(self.dir.path());
227        cmd.output().with_context(|| {
228            format!(
229                "failed to run groovy session script {} with {}",
230                self.source_path.display(),
231                self.executable.display()
232            )
233        })
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 source = self.render_source();
243            self.write_source(&source)?;
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 source = self.render_source();
253        self.write_source(&source)
254    }
255}
256
257impl LanguageSession for GroovySession {
258    fn language_id(&self) -> &str {
259        "groovy"
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                    "Groovy 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        if should_treat_as_expression(trimmed) {
298            let snippet = wrap_expression(trimmed, self.statements.len());
299            let outcome = self.run_snippet(snippet)?;
300            if outcome.exit_code.unwrap_or(0) == 0 {
301                return Ok(outcome);
302            }
303        }
304
305        let snippet = ensure_trailing_newline(code);
306        self.run_snippet(snippet)
307    }
308
309    fn shutdown(&mut self) -> Result<()> {
310        // TempDir cleanup handled automatically.
311        Ok(())
312    }
313}
314
315fn ensure_trailing_newline(code: &str) -> String {
316    let mut owned = code.to_string();
317    if !owned.ends_with('\n') {
318        owned.push('\n');
319    }
320    owned
321}
322
323fn wrap_expression(code: &str, index: usize) -> String {
324    format!("def __run_value_{index} = ({code});\nprintln(__run_value_{index});\n")
325}
326
327fn should_treat_as_expression(code: &str) -> bool {
328    let trimmed = code.trim();
329    if trimmed.is_empty() {
330        return false;
331    }
332    if trimmed.contains('\n') {
333        return false;
334    }
335    if trimmed.ends_with('{') || trimmed.ends_with('}') {
336        return false;
337    }
338
339    let lowered = trimmed.to_ascii_lowercase();
340    const STATEMENT_PREFIXES: [&str; 17] = [
341        "import ",
342        "package ",
343        "class ",
344        "interface ",
345        "enum ",
346        "trait ",
347        "def ",
348        "if ",
349        "for ",
350        "while ",
351        "switch ",
352        "case ",
353        "try",
354        "catch",
355        "finally",
356        "return ",
357        "throw ",
358    ];
359    if STATEMENT_PREFIXES
360        .iter()
361        .any(|prefix| lowered.starts_with(prefix))
362    {
363        return false;
364    }
365
366    if trimmed.starts_with("//") {
367        return false;
368    }
369
370    if lowered.starts_with("println")
371        || lowered.starts_with("print ")
372        || lowered.starts_with("print(")
373    {
374        return false;
375    }
376
377    if trimmed.contains('=')
378        && !trimmed.contains("==")
379        && !trimmed.contains("!=")
380        && !trimmed.contains(">=")
381        && !trimmed.contains("<=")
382        && !trimmed.contains("=>")
383    {
384        return false;
385    }
386
387    true
388}
389
390fn diff_output(previous: &str, current: &str) -> String {
391    if let Some(stripped) = current.strip_prefix(previous) {
392        stripped.to_string()
393    } else {
394        current.to_string()
395    }
396}
397
398fn normalize_output(bytes: &[u8]) -> String {
399    String::from_utf8_lossy(bytes)
400        .replace("\r\n", "\n")
401        .replace('\r', "")
402}
403
404fn prepare_groovy_source(code: &str) -> Cow<'_, str> {
405    if let Some(expr) = extract_tail_expression(code) {
406        let mut script = code.to_string();
407        if !script.ends_with('\n') {
408            script.push('\n');
409        }
410        script.push_str(&format!("println({expr});\n"));
411        Cow::Owned(script)
412    } else {
413        Cow::Borrowed(code)
414    }
415}
416
417fn extract_tail_expression(source: &str) -> Option<String> {
418    for line in source.lines().rev() {
419        let trimmed = line.trim();
420        if trimmed.is_empty() {
421            continue;
422        }
423        if trimmed.starts_with("//") {
424            continue;
425        }
426        let without_comment = strip_inline_comment(trimmed).trim();
427        if without_comment.is_empty() {
428            continue;
429        }
430        if should_treat_as_expression(without_comment) {
431            return Some(without_comment.to_string());
432        }
433        break;
434    }
435    None
436}
437
438fn strip_inline_comment(line: &str) -> &str {
439    let bytes = line.as_bytes();
440    let mut in_single = false;
441    let mut in_double = false;
442    let mut escape = false;
443    let mut i = 0;
444    while i < bytes.len() {
445        let b = bytes[i];
446        if escape {
447            escape = false;
448            i += 1;
449            continue;
450        }
451        match b {
452            b'\\' => {
453                escape = true;
454            }
455            b'\'' if !in_double => {
456                in_single = !in_single;
457            }
458            b'"' if !in_single => {
459                in_double = !in_double;
460            }
461            b'/' if !in_single && !in_double => {
462                if i + 1 < bytes.len() && bytes[i + 1] == b'/' {
463                    return &line[..i];
464                }
465            }
466            _ => {}
467        }
468        i += 1;
469    }
470    line
471}