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