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 should_treat_as_expression(trimmed) {
298 let snippet = wrap_expression(trimmed, 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 format!("def __run_value_{index} = ({code});\nprintln(__run_value_{index});\n")
325}
326
327fn should_treat_as_expression(code: &str) -> bool {
328 let trimmed = code.trim();
329 if trimmed.is_empty() {
330 return false;
331 }
332 if trimmed.contains('\n') {
333 return false;
334 }
335 if trimmed.ends_with('{') || trimmed.ends_with('}') {
336 return false;
337 }
338
339 let lowered = trimmed.to_ascii_lowercase();
340 const STATEMENT_PREFIXES: [&str; 17] = [
341 "import ",
342 "package ",
343 "class ",
344 "interface ",
345 "enum ",
346 "trait ",
347 "def ",
348 "if ",
349 "for ",
350 "while ",
351 "switch ",
352 "case ",
353 "try",
354 "catch",
355 "finally",
356 "return ",
357 "throw ",
358 ];
359 if STATEMENT_PREFIXES
360 .iter()
361 .any(|prefix| lowered.starts_with(prefix))
362 {
363 return false;
364 }
365
366 if trimmed.starts_with("//") {
367 return false;
368 }
369
370 if lowered.starts_with("println")
371 || lowered.starts_with("print ")
372 || lowered.starts_with("print(")
373 {
374 return false;
375 }
376
377 if trimmed.contains('=')
378 && !trimmed.contains("==")
379 && !trimmed.contains("!=")
380 && !trimmed.contains(">=")
381 && !trimmed.contains("<=")
382 && !trimmed.contains("=>")
383 {
384 return false;
385 }
386
387 true
388}
389
390fn diff_output(previous: &str, current: &str) -> String {
391 if let Some(stripped) = current.strip_prefix(previous) {
392 stripped.to_string()
393 } else {
394 current.to_string()
395 }
396}
397
398fn normalize_output(bytes: &[u8]) -> String {
399 String::from_utf8_lossy(bytes)
400 .replace("\r\n", "\n")
401 .replace('\r', "")
402}
403
404fn prepare_groovy_source(code: &str) -> Cow<'_, str> {
405 if let Some(expr) = extract_tail_expression(code) {
406 let mut script = code.to_string();
407 if !script.ends_with('\n') {
408 script.push('\n');
409 }
410 script.push_str(&format!("println({expr});\n"));
411 Cow::Owned(script)
412 } else {
413 Cow::Borrowed(code)
414 }
415}
416
417fn extract_tail_expression(source: &str) -> Option<String> {
418 for line in source.lines().rev() {
419 let trimmed = line.trim();
420 if trimmed.is_empty() {
421 continue;
422 }
423 if trimmed.starts_with("//") {
424 continue;
425 }
426 let without_comment = strip_inline_comment(trimmed).trim();
427 if without_comment.is_empty() {
428 continue;
429 }
430 if should_treat_as_expression(without_comment) {
431 return Some(without_comment.to_string());
432 }
433 break;
434 }
435 None
436}
437
438fn strip_inline_comment(line: &str) -> &str {
439 let bytes = line.as_bytes();
440 let mut in_single = false;
441 let mut in_double = false;
442 let mut escape = false;
443 let mut i = 0;
444 while i < bytes.len() {
445 let b = bytes[i];
446 if escape {
447 escape = false;
448 i += 1;
449 continue;
450 }
451 match b {
452 b'\\' => {
453 escape = true;
454 }
455 b'\'' if !in_double => {
456 in_single = !in_single;
457 }
458 b'"' if !in_single => {
459 in_double = !in_double;
460 }
461 b'/' if !in_single && !in_double => {
462 if i + 1 < bytes.len() && bytes[i + 1] == b'/' {
463 return &line[..i];
464 }
465 }
466 _ => {}
467 }
468 i += 1;
469 }
470 line
471}