Skip to main content

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 let Some(snippet) = rewrite_with_tail_capture(code, self.statements.len()) {
298            let outcome = self.run_snippet(snippet)?;
299            if outcome.exit_code.unwrap_or(0) == 0 {
300                return Ok(outcome);
301            }
302        }
303
304        let snippet = ensure_trailing_newline(code);
305        self.run_snippet(snippet)
306    }
307
308    fn shutdown(&mut self) -> Result<()> {
309        Ok(())
310    }
311}
312
313fn ensure_trailing_newline(code: &str) -> String {
314    let mut owned = code.to_string();
315    if !owned.ends_with('\n') {
316        owned.push('\n');
317    }
318    owned
319}
320
321fn wrap_expression(code: &str, index: usize) -> String {
322    let expr = code.trim().trim_end_matches(';').trim_end();
323    format!("def __run_value_{index} = ({expr});\nprintln(__run_value_{index});\n")
324}
325
326fn should_treat_as_expression(code: &str) -> bool {
327    let trimmed = code.trim();
328    if trimmed.is_empty() {
329        return false;
330    }
331    if trimmed.contains('\n') {
332        return false;
333    }
334
335    let trimmed = trimmed.trim_end();
336    let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
337    if without_trailing_semicolon.is_empty() {
338        return false;
339    }
340    if without_trailing_semicolon.contains(';') {
341        return false;
342    }
343
344    let lowered = without_trailing_semicolon.to_ascii_lowercase();
345    const STATEMENT_PREFIXES: [&str; 15] = [
346        "import ",
347        "package ",
348        "class ",
349        "interface ",
350        "enum ",
351        "trait ",
352        "for ",
353        "while ",
354        "switch ",
355        "case ",
356        "try",
357        "catch",
358        "finally",
359        "return ",
360        "throw ",
361    ];
362    if STATEMENT_PREFIXES
363        .iter()
364        .any(|prefix| lowered.starts_with(prefix))
365    {
366        return false;
367    }
368
369    if lowered.starts_with("def ") {
370        let rest = lowered.trim_start_matches("def ").trim_start();
371        if rest.contains('(') && !rest.contains('=') {
372            return false;
373        }
374    }
375
376    if lowered.starts_with("if ") {
377        return lowered.contains(" else ");
378    }
379
380    if without_trailing_semicolon.starts_with("//") {
381        return false;
382    }
383
384    if lowered.starts_with("println")
385        || lowered.starts_with("print ")
386        || lowered.starts_with("print(")
387    {
388        return false;
389    }
390
391    true
392}
393
394fn rewrite_if_expression(expr: &str) -> Option<String> {
395    let trimmed = expr.trim();
396    let lowered = trimmed.to_ascii_lowercase();
397    if !lowered.starts_with("if ") {
398        return None;
399    }
400    let open = trimmed.find('(')?;
401    let mut depth = 0usize;
402    let mut close: Option<usize> = None;
403    for (i, ch) in trimmed.chars().enumerate().skip(open) {
404        if ch == '(' {
405            depth += 1;
406        } else if ch == ')' {
407            depth = depth.saturating_sub(1);
408            if depth == 0 {
409                close = Some(i);
410                break;
411            }
412        }
413    }
414    let close = close?;
415    let cond = trimmed[open + 1..close].trim();
416    let rest = trimmed[close + 1..].trim();
417    let else_pos = rest.to_ascii_lowercase().rfind(" else ")?;
418    let then_part = rest[..else_pos].trim();
419    let else_part = rest[else_pos + " else ".len()..].trim();
420    if cond.is_empty() || then_part.is_empty() || else_part.is_empty() {
421        return None;
422    }
423    Some(format!("(({cond}) ? ({then_part}) : ({else_part}))"))
424}
425
426fn is_closure_literal_without_params(expr: &str) -> bool {
427    let trimmed = expr.trim();
428    trimmed.starts_with('{') && trimmed.ends_with('}') && !trimmed.contains("->")
429}
430
431fn split_semicolons_outside_quotes(line: &str) -> Vec<&str> {
432    let bytes = line.as_bytes();
433    let mut parts: Vec<&str> = Vec::new();
434    let mut start = 0usize;
435    let mut in_single = false;
436    let mut in_double = false;
437    let mut escape = false;
438    for (i, &b) in bytes.iter().enumerate() {
439        if escape {
440            escape = false;
441            continue;
442        }
443        match b {
444            b'\\' if in_single || in_double => escape = true,
445            b'\'' if !in_double => in_single = !in_single,
446            b'"' if !in_single => in_double = !in_double,
447            b';' if !in_single && !in_double => {
448                parts.push(&line[start..i]);
449                start = i + 1;
450            }
451            _ => {}
452        }
453    }
454    parts.push(&line[start..]);
455    parts
456}
457
458fn rewrite_with_tail_capture(code: &str, index: usize) -> Option<String> {
459    let source = code.trim_end_matches(['\r', '\n']);
460    if source.trim().is_empty() {
461        return None;
462    }
463
464    let trimmed = source.trim();
465    if trimmed.starts_with('{') && trimmed.ends_with('}') && !trimmed.contains("->") {
466        let expr = trimmed.trim_end_matches(';').trim_end();
467        let invoke = format!("({expr})()");
468        return Some(wrap_expression(&invoke, index));
469    }
470
471    if !source.contains('\n') && source.contains(';') {
472        let parts = split_semicolons_outside_quotes(source);
473        if parts.len() >= 2 {
474            let tail = parts.last().unwrap_or(&"").trim();
475            if !tail.is_empty() {
476                let without_comment = strip_inline_comment(tail).trim();
477                if should_treat_as_expression(without_comment) {
478                    let mut expr = without_comment.trim_end_matches(';').trim_end().to_string();
479                    if let Some(rewritten) = rewrite_if_expression(&expr) {
480                        expr = rewritten;
481                    } else if is_closure_literal_without_params(&expr) {
482                        expr = format!("({expr})()");
483                    }
484
485                    let mut snippet = String::new();
486                    let prefix = parts[..parts.len() - 1]
487                        .iter()
488                        .map(|s| s.trim())
489                        .filter(|s| !s.is_empty())
490                        .collect::<Vec<_>>()
491                        .join(";\n");
492                    if !prefix.is_empty() {
493                        snippet.push_str(&prefix);
494                        snippet.push_str(";\n");
495                    }
496                    snippet.push_str(&wrap_expression(&expr, index));
497                    return Some(snippet);
498                }
499            }
500        }
501    }
502
503    let lines: Vec<&str> = source.lines().collect();
504    for i in (0..lines.len()).rev() {
505        let raw_line = lines[i];
506        let trimmed_line = raw_line.trim();
507        if trimmed_line.is_empty() {
508            continue;
509        }
510        if trimmed_line.starts_with("//") {
511            continue;
512        }
513        let without_comment = strip_inline_comment(trimmed_line).trim();
514        if without_comment.is_empty() {
515            continue;
516        }
517
518        if !should_treat_as_expression(without_comment) {
519            break;
520        }
521
522        let mut expr = without_comment.trim_end_matches(';').trim_end().to_string();
523        if let Some(rewritten) = rewrite_if_expression(&expr) {
524            expr = rewritten;
525        } else if is_closure_literal_without_params(&expr) {
526            expr = format!("({expr})()");
527        }
528
529        let mut snippet = String::new();
530        if i > 0 {
531            snippet.push_str(&lines[..i].join("\n"));
532            snippet.push('\n');
533        }
534        snippet.push_str(&wrap_expression(&expr, index));
535        return Some(snippet);
536    }
537
538    None
539}
540
541fn diff_output(previous: &str, current: &str) -> String {
542    if let Some(stripped) = current.strip_prefix(previous) {
543        stripped.to_string()
544    } else {
545        current.to_string()
546    }
547}
548
549fn normalize_output(bytes: &[u8]) -> String {
550    String::from_utf8_lossy(bytes)
551        .replace("\r\n", "\n")
552        .replace('\r', "")
553}
554
555fn prepare_groovy_source(code: &str) -> Cow<'_, str> {
556    if let Some(expr) = extract_tail_expression(code) {
557        let mut script = code.to_string();
558        if !script.ends_with('\n') {
559            script.push('\n');
560        }
561        script.push_str(&format!("println({expr});\n"));
562        Cow::Owned(script)
563    } else {
564        Cow::Borrowed(code)
565    }
566}
567
568fn extract_tail_expression(source: &str) -> Option<String> {
569    for line in source.lines().rev() {
570        let trimmed = line.trim();
571        if trimmed.is_empty() {
572            continue;
573        }
574        if trimmed.starts_with("//") {
575            continue;
576        }
577        let without_comment = strip_inline_comment(trimmed).trim();
578        if without_comment.is_empty() {
579            continue;
580        }
581        if should_treat_as_expression(without_comment) {
582            return Some(without_comment.to_string());
583        }
584        break;
585    }
586    None
587}
588
589fn strip_inline_comment(line: &str) -> &str {
590    let bytes = line.as_bytes();
591    let mut in_single = false;
592    let mut in_double = false;
593    let mut escape = false;
594    let mut i = 0;
595    while i < bytes.len() {
596        let b = bytes[i];
597        if escape {
598            escape = false;
599            i += 1;
600            continue;
601        }
602        match b {
603            b'\\' => {
604                escape = true;
605            }
606            b'\'' if !in_double => {
607                in_single = !in_single;
608            }
609            b'"' if !in_single => {
610                in_double = !in_double;
611            }
612            b'/' if !in_single && !in_double => {
613                if i + 1 < bytes.len() && bytes[i + 1] == b'/' {
614                    return &line[..i];
615                }
616            }
617            _ => {}
618        }
619        i += 1;
620    }
621    line
622}