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 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}