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}