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}