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