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 Default for GroovyEngine {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl GroovyEngine {
24    pub fn new() -> Self {
25        let executable = resolve_groovy_binary();
26        Self { executable }
27    }
28
29    fn ensure_binary(&self) -> Result<&Path> {
30        self.executable.as_deref().ok_or_else(|| {
31            anyhow::anyhow!(
32                "Groovy support requires the `groovy` executable. Install it from https://groovy-lang.org/download.html and make sure it is available on your PATH."
33            )
34        })
35    }
36}
37
38impl LanguageEngine for GroovyEngine {
39    fn id(&self) -> &'static str {
40        "groovy"
41    }
42
43    fn display_name(&self) -> &'static str {
44        "Groovy"
45    }
46
47    fn aliases(&self) -> &[&'static str] {
48        &["grv"]
49    }
50
51    fn supports_sessions(&self) -> bool {
52        self.executable.is_some()
53    }
54
55    fn validate(&self) -> Result<()> {
56        let binary = self.ensure_binary()?;
57        let mut cmd = Command::new(binary);
58        cmd.arg("--version")
59            .stdout(Stdio::null())
60            .stderr(Stdio::null());
61        cmd.status()
62            .with_context(|| format!("failed to invoke {}", binary.display()))?
63            .success()
64            .then_some(())
65            .ok_or_else(|| anyhow::anyhow!("{} is not executable", binary.display()))
66    }
67
68    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
69        let binary = self.ensure_binary()?;
70        let start = Instant::now();
71        let output = match payload {
72            ExecutionPayload::Inline { code } => {
73                let prepared = prepare_groovy_source(code);
74                let mut cmd = Command::new(binary);
75                cmd.arg("-e").arg(prepared.as_ref());
76                cmd.stdin(Stdio::inherit());
77                cmd.output().with_context(|| {
78                    format!(
79                        "failed to execute {} for inline Groovy snippet",
80                        binary.display()
81                    )
82                })
83            }
84            ExecutionPayload::File { path } => {
85                let mut cmd = Command::new(binary);
86                cmd.arg(path);
87                cmd.stdin(Stdio::inherit());
88                cmd.output().with_context(|| {
89                    format!(
90                        "failed to execute {} for Groovy script {}",
91                        binary.display(),
92                        path.display()
93                    )
94                })
95            }
96            ExecutionPayload::Stdin { code } => {
97                let mut script = Builder::new()
98                    .prefix("run-groovy-stdin")
99                    .suffix(".groovy")
100                    .tempfile()
101                    .context("failed to create temporary Groovy script for stdin input")?;
102                let mut prepared = prepare_groovy_source(code).into_owned();
103                if !prepared.ends_with('\n') {
104                    prepared.push('\n');
105                }
106                script
107                    .write_all(prepared.as_bytes())
108                    .context("failed to write piped Groovy source")?;
109                script.flush()?;
110
111                let script_path = script.path().to_path_buf();
112                let mut cmd = Command::new(binary);
113                cmd.arg(&script_path);
114                cmd.stdin(Stdio::null());
115                let output = cmd.output().with_context(|| {
116                    format!(
117                        "failed to execute {} for Groovy stdin script {}",
118                        binary.display(),
119                        script_path.display()
120                    )
121                })?;
122                drop(script);
123                Ok(output)
124            }
125        }?;
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_binary()?.to_path_buf();
138        Ok(Box::new(GroovySession::new(executable)?))
139    }
140}
141
142fn resolve_groovy_binary() -> Option<PathBuf> {
143    which::which("groovy").ok()
144}
145
146struct GroovySession {
147    executable: PathBuf,
148    dir: TempDir,
149    source_path: PathBuf,
150    statements: Vec<String>,
151    previous_stdout: String,
152    previous_stderr: String,
153}
154
155impl GroovySession {
156    fn new(executable: PathBuf) -> Result<Self> {
157        let dir = Builder::new()
158            .prefix("run-groovy-repl")
159            .tempdir()
160            .context("failed to create temporary directory for groovy repl")?;
161        let source_path = dir.path().join("session.groovy");
162        fs::write(&source_path, "// Groovy REPL session\n").with_context(|| {
163            format!(
164                "failed to initialize generated groovy session source at {}",
165                source_path.display()
166            )
167        })?;
168
169        Ok(Self {
170            executable,
171            dir,
172            source_path,
173            statements: Vec::new(),
174            previous_stdout: String::new(),
175            previous_stderr: String::new(),
176        })
177    }
178
179    fn render_source(&self) -> String {
180        let mut source = String::from("// Generated by run Groovy REPL\n");
181        for snippet in &self.statements {
182            source.push_str(snippet);
183            if !snippet.ends_with('\n') {
184                source.push('\n');
185            }
186        }
187        source
188    }
189
190    fn write_source(&self, contents: &str) -> Result<()> {
191        fs::write(&self.source_path, contents).with_context(|| {
192            format!(
193                "failed to write generated Groovy REPL source to {}",
194                self.source_path.display()
195            )
196        })
197    }
198
199    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
200        let source = self.render_source();
201        self.write_source(&source)?;
202
203        let output = self.run_script()?;
204        let stdout_full = normalize_output(&output.stdout);
205        let stderr_full = normalize_output(&output.stderr);
206
207        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
208        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
209
210        let success = output.status.success();
211        if success {
212            self.previous_stdout = stdout_full;
213            self.previous_stderr = stderr_full;
214        }
215
216        let outcome = ExecutionOutcome {
217            language: "groovy".to_string(),
218            exit_code: output.status.code(),
219            stdout: stdout_delta,
220            stderr: stderr_delta,
221            duration: start.elapsed(),
222        };
223
224        Ok((outcome, success))
225    }
226
227    fn run_script(&self) -> Result<std::process::Output> {
228        let mut cmd = Command::new(&self.executable);
229        cmd.arg(&self.source_path)
230            .stdout(Stdio::piped())
231            .stderr(Stdio::piped())
232            .current_dir(self.dir.path());
233        cmd.output().with_context(|| {
234            format!(
235                "failed to run groovy session script {} with {}",
236                self.source_path.display(),
237                self.executable.display()
238            )
239        })
240    }
241
242    fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
243        self.statements.push(snippet);
244        let start = Instant::now();
245        let (outcome, success) = self.run_current(start)?;
246        if !success {
247            let _ = self.statements.pop();
248            let source = self.render_source();
249            self.write_source(&source)?;
250        }
251        Ok(outcome)
252    }
253
254    fn reset_state(&mut self) -> Result<()> {
255        self.statements.clear();
256        self.previous_stdout.clear();
257        self.previous_stderr.clear();
258        let source = self.render_source();
259        self.write_source(&source)
260    }
261}
262
263impl LanguageSession for GroovySession {
264    fn language_id(&self) -> &str {
265        "groovy"
266    }
267
268    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
269        let trimmed = code.trim();
270        if trimmed.is_empty() {
271            return Ok(ExecutionOutcome {
272                language: self.language_id().to_string(),
273                exit_code: None,
274                stdout: String::new(),
275                stderr: String::new(),
276                duration: Duration::default(),
277            });
278        }
279
280        if trimmed.eq_ignore_ascii_case(":reset") {
281            self.reset_state()?;
282            return Ok(ExecutionOutcome {
283                language: self.language_id().to_string(),
284                exit_code: None,
285                stdout: String::new(),
286                stderr: String::new(),
287                duration: Duration::default(),
288            });
289        }
290
291        if trimmed.eq_ignore_ascii_case(":help") {
292            return Ok(ExecutionOutcome {
293                language: self.language_id().to_string(),
294                exit_code: None,
295                stdout:
296                    "Groovy commands:\n  :reset - clear session state\n  :help  - show this message\n"
297                        .to_string(),
298                stderr: String::new(),
299                duration: Duration::default(),
300            });
301        }
302
303        if let Some(snippet) = rewrite_with_tail_capture(code, self.statements.len()) {
304            let outcome = self.run_snippet(snippet)?;
305            if outcome.exit_code.unwrap_or(0) == 0 {
306                return Ok(outcome);
307            }
308        }
309
310        let snippet = ensure_trailing_newline(code);
311        self.run_snippet(snippet)
312    }
313
314    fn shutdown(&mut self) -> Result<()> {
315        Ok(())
316    }
317}
318
319fn ensure_trailing_newline(code: &str) -> String {
320    let mut owned = code.to_string();
321    if !owned.ends_with('\n') {
322        owned.push('\n');
323    }
324    owned
325}
326
327fn wrap_expression(code: &str, index: usize) -> String {
328    let expr = code.trim().trim_end_matches(';').trim_end();
329    format!("def __run_value_{index} = ({expr});\nprintln(__run_value_{index});\n")
330}
331
332fn should_treat_as_expression(code: &str) -> bool {
333    let trimmed = code.trim();
334    if trimmed.is_empty() {
335        return false;
336    }
337    if trimmed.contains('\n') {
338        return false;
339    }
340
341    let trimmed = trimmed.trim_end();
342    let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
343    if without_trailing_semicolon.is_empty() {
344        return false;
345    }
346    if without_trailing_semicolon.contains(';') {
347        return false;
348    }
349
350    let lowered = without_trailing_semicolon.to_ascii_lowercase();
351    const STATEMENT_PREFIXES: [&str; 15] = [
352        "import ",
353        "package ",
354        "class ",
355        "interface ",
356        "enum ",
357        "trait ",
358        "for ",
359        "while ",
360        "switch ",
361        "case ",
362        "try",
363        "catch",
364        "finally",
365        "return ",
366        "throw ",
367    ];
368    if STATEMENT_PREFIXES
369        .iter()
370        .any(|prefix| lowered.starts_with(prefix))
371    {
372        return false;
373    }
374
375    if lowered.starts_with("def ") {
376        let rest = lowered.trim_start_matches("def ").trim_start();
377        if rest.contains('(') && !rest.contains('=') {
378            return false;
379        }
380    }
381
382    if lowered.starts_with("if ") {
383        return lowered.contains(" else ");
384    }
385
386    if without_trailing_semicolon.starts_with("//") {
387        return false;
388    }
389
390    if lowered.starts_with("println")
391        || lowered.starts_with("print ")
392        || lowered.starts_with("print(")
393    {
394        return false;
395    }
396
397    true
398}
399
400fn rewrite_if_expression(expr: &str) -> Option<String> {
401    let trimmed = expr.trim();
402    let lowered = trimmed.to_ascii_lowercase();
403    if !lowered.starts_with("if ") {
404        return None;
405    }
406    let open = trimmed.find('(')?;
407    let mut depth = 0usize;
408    let mut close: Option<usize> = None;
409    for (i, ch) in trimmed.chars().enumerate().skip(open) {
410        if ch == '(' {
411            depth += 1;
412        } else if ch == ')' {
413            depth = depth.saturating_sub(1);
414            if depth == 0 {
415                close = Some(i);
416                break;
417            }
418        }
419    }
420    let close = close?;
421    let cond = trimmed[open + 1..close].trim();
422    let rest = trimmed[close + 1..].trim();
423    let else_pos = rest.to_ascii_lowercase().rfind(" else ")?;
424    let then_part = rest[..else_pos].trim();
425    let else_part = rest[else_pos + " else ".len()..].trim();
426    if cond.is_empty() || then_part.is_empty() || else_part.is_empty() {
427        return None;
428    }
429    Some(format!("(({cond}) ? ({then_part}) : ({else_part}))"))
430}
431
432fn is_closure_literal_without_params(expr: &str) -> bool {
433    let trimmed = expr.trim();
434    trimmed.starts_with('{') && trimmed.ends_with('}') && !trimmed.contains("->")
435}
436
437fn split_semicolons_outside_quotes(line: &str) -> Vec<&str> {
438    let bytes = line.as_bytes();
439    let mut parts: Vec<&str> = Vec::new();
440    let mut start = 0usize;
441    let mut in_single = false;
442    let mut in_double = false;
443    let mut escape = false;
444    for (i, &b) in bytes.iter().enumerate() {
445        if escape {
446            escape = false;
447            continue;
448        }
449        match b {
450            b'\\' if in_single || in_double => escape = true,
451            b'\'' if !in_double => in_single = !in_single,
452            b'"' if !in_single => in_double = !in_double,
453            b';' if !in_single && !in_double => {
454                parts.push(&line[start..i]);
455                start = i + 1;
456            }
457            _ => {}
458        }
459    }
460    parts.push(&line[start..]);
461    parts
462}
463
464fn rewrite_with_tail_capture(code: &str, index: usize) -> Option<String> {
465    let source = code.trim_end_matches(['\r', '\n']);
466    if source.trim().is_empty() {
467        return None;
468    }
469
470    let trimmed = source.trim();
471    if trimmed.starts_with('{') && trimmed.ends_with('}') && !trimmed.contains("->") {
472        let expr = trimmed.trim_end_matches(';').trim_end();
473        let invoke = format!("({expr})()");
474        return Some(wrap_expression(&invoke, index));
475    }
476
477    if !source.contains('\n') && source.contains(';') {
478        let parts = split_semicolons_outside_quotes(source);
479        if parts.len() >= 2 {
480            let tail = parts.last().unwrap_or(&"").trim();
481            if !tail.is_empty() {
482                let without_comment = strip_inline_comment(tail).trim();
483                if should_treat_as_expression(without_comment) {
484                    let mut expr = without_comment.trim_end_matches(';').trim_end().to_string();
485                    if let Some(rewritten) = rewrite_if_expression(&expr) {
486                        expr = rewritten;
487                    } else if is_closure_literal_without_params(&expr) {
488                        expr = format!("({expr})()");
489                    }
490
491                    let mut snippet = String::new();
492                    let prefix = parts[..parts.len() - 1]
493                        .iter()
494                        .map(|s| s.trim())
495                        .filter(|s| !s.is_empty())
496                        .collect::<Vec<_>>()
497                        .join(";\n");
498                    if !prefix.is_empty() {
499                        snippet.push_str(&prefix);
500                        snippet.push_str(";\n");
501                    }
502                    snippet.push_str(&wrap_expression(&expr, index));
503                    return Some(snippet);
504                }
505            }
506        }
507    }
508
509    let lines: Vec<&str> = source.lines().collect();
510    for i in (0..lines.len()).rev() {
511        let raw_line = lines[i];
512        let trimmed_line = raw_line.trim();
513        if trimmed_line.is_empty() {
514            continue;
515        }
516        if trimmed_line.starts_with("//") {
517            continue;
518        }
519        let without_comment = strip_inline_comment(trimmed_line).trim();
520        if without_comment.is_empty() {
521            continue;
522        }
523
524        if !should_treat_as_expression(without_comment) {
525            break;
526        }
527
528        let mut expr = without_comment.trim_end_matches(';').trim_end().to_string();
529        if let Some(rewritten) = rewrite_if_expression(&expr) {
530            expr = rewritten;
531        } else if is_closure_literal_without_params(&expr) {
532            expr = format!("({expr})()");
533        }
534
535        let mut snippet = String::new();
536        if i > 0 {
537            snippet.push_str(&lines[..i].join("\n"));
538            snippet.push('\n');
539        }
540        snippet.push_str(&wrap_expression(&expr, index));
541        return Some(snippet);
542    }
543
544    None
545}
546
547fn diff_output(previous: &str, current: &str) -> String {
548    if let Some(stripped) = current.strip_prefix(previous) {
549        stripped.to_string()
550    } else {
551        current.to_string()
552    }
553}
554
555fn normalize_output(bytes: &[u8]) -> String {
556    String::from_utf8_lossy(bytes)
557        .replace("\r\n", "\n")
558        .replace('\r', "")
559}
560
561fn prepare_groovy_source(code: &str) -> Cow<'_, str> {
562    if let Some(expr) = extract_tail_expression(code) {
563        let mut script = code.to_string();
564        if !script.ends_with('\n') {
565            script.push('\n');
566        }
567        script.push_str(&format!("println({expr});\n"));
568        Cow::Owned(script)
569    } else {
570        Cow::Borrowed(code)
571    }
572}
573
574fn extract_tail_expression(source: &str) -> Option<String> {
575    for line in source.lines().rev() {
576        let trimmed = line.trim();
577        if trimmed.is_empty() {
578            continue;
579        }
580        if trimmed.starts_with("//") {
581            continue;
582        }
583        let without_comment = strip_inline_comment(trimmed).trim();
584        if without_comment.is_empty() {
585            continue;
586        }
587        if should_treat_as_expression(without_comment) {
588            return Some(without_comment.to_string());
589        }
590        break;
591    }
592    None
593}
594
595fn strip_inline_comment(line: &str) -> &str {
596    let bytes = line.as_bytes();
597    let mut in_single = false;
598    let mut in_double = false;
599    let mut escape = false;
600    let mut i = 0;
601    while i < bytes.len() {
602        let b = bytes[i];
603        if escape {
604            escape = false;
605            i += 1;
606            continue;
607        }
608        match b {
609            b'\\' => {
610                escape = true;
611            }
612            b'\'' if !in_double => {
613                in_single = !in_single;
614            }
615            b'"' if !in_single => {
616                in_double = !in_double;
617            }
618            b'/' if !in_single && !in_double => {
619                if i + 1 < bytes.len() && bytes[i + 1] == b'/' {
620                    return &line[..i];
621                }
622            }
623            _ => {}
624        }
625        i += 1;
626    }
627    line
628}