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