Skip to main content

run/engine/
java.rs

1use std::io::{BufRead, BufReader, Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::sync::{Arc, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result};
9use tempfile::Builder;
10
11use super::{
12    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, hash_source,
13    run_version_command,
14};
15
16pub struct JavaEngine {
17    compiler: Option<PathBuf>,
18    runtime: Option<PathBuf>,
19    jshell: Option<PathBuf>,
20}
21
22impl Default for JavaEngine {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl JavaEngine {
29    pub fn new() -> Self {
30        Self {
31            compiler: resolve_javac_binary(),
32            runtime: resolve_java_binary(),
33            jshell: resolve_jshell_binary(),
34        }
35    }
36
37    fn ensure_compiler(&self) -> Result<&Path> {
38        self.compiler.as_deref().ok_or_else(|| {
39            anyhow::anyhow!(
40                "Java support requires the `javac` compiler. Install the JDK from https://adoptium.net/ or your vendor of choice and ensure it is on your PATH."
41            )
42        })
43    }
44
45    fn ensure_runtime(&self) -> Result<&Path> {
46        self.runtime.as_deref().ok_or_else(|| {
47            anyhow::anyhow!(
48                "Java support requires the `java` runtime. Install the JDK from https://adoptium.net/ or your vendor of choice and ensure it is on your PATH."
49            )
50        })
51    }
52
53    fn ensure_jshell(&self) -> Result<&Path> {
54        self.jshell.as_deref().ok_or_else(|| {
55            anyhow::anyhow!(
56                "Interactive Java REPL requires `jshell`. Install a full JDK and ensure `jshell` is on your PATH."
57            )
58        })
59    }
60
61    fn write_inline_source(&self, code: &str, dir: &Path) -> Result<(PathBuf, String)> {
62        let source_path = dir.join("Main.java");
63        let wrapped = wrap_inline_java(code);
64        std::fs::write(&source_path, wrapped).with_context(|| {
65            format!(
66                "failed to write temporary Java source to {}",
67                source_path.display()
68            )
69        })?;
70        Ok((source_path, "Main".to_string()))
71    }
72
73    fn write_from_stdin(&self, code: &str, dir: &Path) -> Result<(PathBuf, String)> {
74        self.write_inline_source(code, dir)
75    }
76
77    fn copy_source(&self, original: &Path, dir: &Path) -> Result<(PathBuf, String)> {
78        let file_name = original
79            .file_name()
80            .map(|f| f.to_owned())
81            .ok_or_else(|| anyhow::anyhow!("invalid Java source path"))?;
82        let target = dir.join(&file_name);
83        std::fs::copy(original, &target).with_context(|| {
84            format!(
85                "failed to copy Java source from {} to {}",
86                original.display(),
87                target.display()
88            )
89        })?;
90        let class_name = original
91            .file_stem()
92            .and_then(|stem| stem.to_str())
93            .ok_or_else(|| anyhow::anyhow!("unable to determine Java class name"))?
94            .to_string();
95        Ok((target, class_name))
96    }
97
98    fn compile(&self, source: &Path, output_dir: &Path) -> Result<std::process::Output> {
99        let compiler = self.ensure_compiler()?;
100        let mut cmd = Command::new(compiler);
101        cmd.arg("-d")
102            .arg(output_dir)
103            .arg(source)
104            .stdout(Stdio::piped())
105            .stderr(Stdio::piped());
106        cmd.output().with_context(|| {
107            format!(
108                "failed to invoke {} to compile {}",
109                compiler.display(),
110                source.display()
111            )
112        })
113    }
114
115    fn run(
116        &self,
117        class_dir: &Path,
118        class_name: &str,
119        args: &[String],
120    ) -> Result<std::process::Output> {
121        let runtime = self.ensure_runtime()?;
122        let mut cmd = Command::new(runtime);
123        cmd.arg("-cp")
124            .arg(class_dir)
125            .arg(class_name)
126            .args(args)
127            .stdout(Stdio::piped())
128            .stderr(Stdio::piped());
129        cmd.stdin(Stdio::inherit());
130        cmd.output().with_context(|| {
131            format!(
132                "failed to execute {} for class {} with classpath {}",
133                runtime.display(),
134                class_name,
135                class_dir.display()
136            )
137        })
138    }
139}
140
141impl LanguageEngine for JavaEngine {
142    fn id(&self) -> &'static str {
143        "java"
144    }
145
146    fn display_name(&self) -> &'static str {
147        "Java"
148    }
149
150    fn aliases(&self) -> &[&'static str] {
151        &[]
152    }
153
154    fn supports_sessions(&self) -> bool {
155        self.jshell.is_some()
156    }
157
158    fn validate(&self) -> Result<()> {
159        let compiler = self.ensure_compiler()?;
160        let mut compile_check = Command::new(compiler);
161        compile_check
162            .arg("-version")
163            .stdout(Stdio::null())
164            .stderr(Stdio::null());
165        compile_check
166            .status()
167            .with_context(|| format!("failed to invoke {}", compiler.display()))?
168            .success()
169            .then_some(())
170            .ok_or_else(|| anyhow::anyhow!("{} is not executable", compiler.display()))?;
171
172        let runtime = self.ensure_runtime()?;
173        let mut runtime_check = Command::new(runtime);
174        runtime_check
175            .arg("-version")
176            .stdout(Stdio::null())
177            .stderr(Stdio::null());
178        runtime_check
179            .status()
180            .with_context(|| format!("failed to invoke {}", runtime.display()))?
181            .success()
182            .then_some(())
183            .ok_or_else(|| anyhow::anyhow!("{} is not executable", runtime.display()))?;
184
185        if let Some(jshell) = self.jshell.as_ref() {
186            let mut jshell_check = Command::new(jshell);
187            jshell_check
188                .arg("--version")
189                .stdout(Stdio::null())
190                .stderr(Stdio::null());
191            jshell_check
192                .status()
193                .with_context(|| format!("failed to invoke {}", jshell.display()))?
194                .success()
195                .then_some(())
196                .ok_or_else(|| anyhow::anyhow!("{} is not executable", jshell.display()))?;
197        }
198
199        Ok(())
200    }
201
202    fn toolchain_version(&self) -> Result<Option<String>> {
203        let runtime = self.ensure_runtime()?;
204        let mut cmd = Command::new(runtime);
205        cmd.arg("-version");
206        let context = format!("{}", runtime.display());
207        run_version_command(cmd, &context)
208    }
209
210    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
211        // Check class file cache for inline/stdin payloads
212        let args = payload.args();
213
214        if let Some(code) = match payload {
215            ExecutionPayload::Inline { code, .. } | ExecutionPayload::Stdin { code, .. } => {
216                Some(code.as_str())
217            }
218            _ => None,
219        } {
220            let wrapped = wrap_inline_java(code);
221            let src_hash = hash_source(&wrapped);
222            let cache_dir = std::env::temp_dir()
223                .join("run-compile-cache")
224                .join(format!("java-{:016x}", src_hash));
225            let class_file = cache_dir.join("Main.class");
226            if class_file.exists() {
227                let start = Instant::now();
228                if let Ok(output) = self.run(&cache_dir, "Main", args) {
229                    return Ok(ExecutionOutcome {
230                        language: self.id().to_string(),
231                        exit_code: output.status.code(),
232                        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
233                        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
234                        duration: start.elapsed(),
235                    });
236                }
237            }
238        }
239
240        let temp_dir = Builder::new()
241            .prefix("run-java")
242            .tempdir()
243            .context("failed to create temporary directory for java build")?;
244        let dir_path = temp_dir.path();
245
246        let (source_path, class_name) = match payload {
247            ExecutionPayload::Inline { code, .. } => self.write_inline_source(code, dir_path)?,
248            ExecutionPayload::Stdin { code, .. } => self.write_from_stdin(code, dir_path)?,
249            ExecutionPayload::File { path, .. } => self.copy_source(path, dir_path)?,
250        };
251
252        let start = Instant::now();
253
254        let compile_output = self.compile(&source_path, dir_path)?;
255        if !compile_output.status.success() {
256            return Ok(ExecutionOutcome {
257                language: self.id().to_string(),
258                exit_code: compile_output.status.code(),
259                stdout: String::from_utf8_lossy(&compile_output.stdout).into_owned(),
260                stderr: String::from_utf8_lossy(&compile_output.stderr).into_owned(),
261                duration: start.elapsed(),
262            });
263        }
264
265        // Cache compiled class files for inline/stdin
266        if let Some(code) = match payload {
267            ExecutionPayload::Inline { code, .. } | ExecutionPayload::Stdin { code, .. } => {
268                Some(code.as_str())
269            }
270            _ => None,
271        } {
272            let wrapped = wrap_inline_java(code);
273            let src_hash = hash_source(&wrapped);
274            let cache_dir = std::env::temp_dir()
275                .join("run-compile-cache")
276                .join(format!("java-{:016x}", src_hash));
277            let _ = std::fs::create_dir_all(&cache_dir);
278            // Copy all .class files
279            if let Ok(entries) = std::fs::read_dir(dir_path) {
280                for entry in entries.flatten() {
281                    if entry.path().extension().and_then(|e| e.to_str()) == Some("class") {
282                        let _ = std::fs::copy(entry.path(), cache_dir.join(entry.file_name()));
283                    }
284                }
285            }
286        }
287
288        let run_output = self.run(dir_path, &class_name, args)?;
289        Ok(ExecutionOutcome {
290            language: self.id().to_string(),
291            exit_code: run_output.status.code(),
292            stdout: String::from_utf8_lossy(&run_output.stdout).into_owned(),
293            stderr: String::from_utf8_lossy(&run_output.stderr).into_owned(),
294            duration: start.elapsed(),
295        })
296    }
297
298    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
299        let jshell = self.ensure_jshell()?;
300        let mut cmd = Command::new(jshell);
301        cmd.arg("--execution=local")
302            .arg("--feedback=concise")
303            .arg("--no-startup")
304            .stdin(Stdio::piped())
305            .stdout(Stdio::piped())
306            .stderr(Stdio::piped());
307
308        let mut child = cmd
309            .spawn()
310            .with_context(|| format!("failed to start {} REPL", jshell.display()))?;
311
312        let stdout = child.stdout.take().context("missing stdout handle")?;
313        let stderr = child.stderr.take().context("missing stderr handle")?;
314
315        let stderr_buffer = Arc::new(Mutex::new(String::new()));
316        let stderr_collector = stderr_buffer.clone();
317        thread::spawn(move || {
318            let mut reader = BufReader::new(stderr);
319            let mut buf = String::new();
320            loop {
321                buf.clear();
322                match reader.read_line(&mut buf) {
323                    Ok(0) => break,
324                    Ok(_) => {
325                        let Ok(mut lock) = stderr_collector.lock() else {
326                            break;
327                        };
328                        lock.push_str(&buf);
329                    }
330                    Err(_) => break,
331                }
332            }
333        });
334
335        let mut session = JavaSession {
336            child,
337            stdout: BufReader::new(stdout),
338            stderr: stderr_buffer,
339            closed: false,
340        };
341
342        session.discard_prompt()?;
343
344        Ok(Box::new(session))
345    }
346}
347
348fn resolve_javac_binary() -> Option<PathBuf> {
349    which::which("javac").ok()
350}
351
352fn resolve_java_binary() -> Option<PathBuf> {
353    which::which("java").ok()
354}
355
356fn resolve_jshell_binary() -> Option<PathBuf> {
357    which::which("jshell").ok()
358}
359
360fn wrap_inline_java(body: &str) -> String {
361    if body.contains("class ") {
362        return body.to_string();
363    }
364
365    let mut header_lines = Vec::new();
366    let mut rest_lines = Vec::new();
367    let mut in_header = true;
368
369    for line in body.lines() {
370        let trimmed = line.trim_start();
371        if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
372            header_lines.push(line);
373            continue;
374        }
375        in_header = false;
376        rest_lines.push(line);
377    }
378
379    let mut result = String::new();
380    if !header_lines.is_empty() {
381        for hl in header_lines {
382            result.push_str(hl);
383            if !hl.ends_with('\n') {
384                result.push('\n');
385            }
386        }
387        result.push('\n');
388    }
389
390    result.push_str(
391        "public class Main {\n    public static void main(String[] args) throws Exception {\n",
392    );
393    for line in rest_lines {
394        if line.trim().is_empty() {
395            result.push_str("        \n");
396        } else {
397            result.push_str("        ");
398            result.push_str(line);
399            result.push('\n');
400        }
401    }
402    result.push_str("    }\n}\n");
403    result
404}
405
406struct JavaSession {
407    child: std::process::Child,
408    stdout: BufReader<std::process::ChildStdout>,
409    stderr: Arc<Mutex<String>>,
410    closed: bool,
411}
412
413impl JavaSession {
414    fn write_code(&mut self, code: &str) -> Result<()> {
415        if self.closed {
416            anyhow::bail!("jshell session has already exited; start a new session with :reset");
417        }
418        let stdin = self
419            .child
420            .stdin
421            .as_mut()
422            .context("jshell session stdin closed")?;
423        stdin.write_all(code.as_bytes())?;
424        if !code.ends_with('\n') {
425            stdin.write_all(b"\n")?;
426        }
427        stdin.flush()?;
428        Ok(())
429    }
430
431    fn read_until_prompt(&mut self) -> Result<String> {
432        const PROMPT: &[u8] = b"jshell> ";
433        let mut buffer = Vec::new();
434        loop {
435            let mut byte = [0u8; 1];
436            let read = self.stdout.read(&mut byte)?;
437            if read == 0 {
438                break;
439            }
440            buffer.extend_from_slice(&byte[..read]);
441            if buffer.ends_with(PROMPT) {
442                break;
443            }
444        }
445
446        if buffer.ends_with(PROMPT) {
447            buffer.truncate(buffer.len() - PROMPT.len());
448        }
449
450        let mut text = String::from_utf8_lossy(&buffer).into_owned();
451        text = text.replace("\r\n", "\n");
452        text = text.replace('\r', "");
453        Ok(strip_feedback(text))
454    }
455
456    fn take_stderr(&self) -> String {
457        let Ok(mut lock) = self.stderr.lock() else {
458            return String::new();
459        };
460        if lock.is_empty() {
461            String::new()
462        } else {
463            let mut output = String::new();
464            std::mem::swap(&mut output, &mut *lock);
465            output
466        }
467    }
468
469    fn discard_prompt(&mut self) -> Result<()> {
470        let _ = self.read_until_prompt()?;
471        let _ = self.take_stderr();
472        Ok(())
473    }
474}
475
476impl LanguageSession for JavaSession {
477    fn language_id(&self) -> &str {
478        "java"
479    }
480
481    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
482        if self.closed {
483            return Ok(ExecutionOutcome {
484                language: self.language_id().to_string(),
485                exit_code: None,
486                stdout: String::new(),
487                stderr: "jshell session already exited. Use :reset to start a new session.\n"
488                    .to_string(),
489                duration: Duration::default(),
490            });
491        }
492
493        let trimmed = code.trim();
494        let exit_requested = matches!(trimmed, "/exit" | "/exit;" | ":exit");
495        let start = Instant::now();
496        self.write_code(code)?;
497        let stdout = match self.read_until_prompt() {
498            Ok(output) => output,
499            Err(_) if exit_requested => String::new(),
500            Err(err) => return Err(err),
501        };
502        let stderr = self.take_stderr();
503
504        if exit_requested {
505            self.closed = true;
506            let _ = self.child.stdin.take();
507            let _ = self.child.wait();
508        }
509
510        Ok(ExecutionOutcome {
511            language: self.language_id().to_string(),
512            exit_code: None,
513            stdout,
514            stderr,
515            duration: start.elapsed(),
516        })
517    }
518
519    fn shutdown(&mut self) -> Result<()> {
520        if !self.closed
521            && let Some(mut stdin) = self.child.stdin.take()
522        {
523            let _ = stdin.write_all(b"/exit\n");
524            let _ = stdin.flush();
525        }
526        let _ = self.child.wait();
527        self.closed = true;
528        Ok(())
529    }
530}
531
532fn strip_feedback(text: String) -> String {
533    let mut lines = Vec::new();
534    for line in text.lines() {
535        if let Some(stripped) = line.strip_prefix("|  ") {
536            lines.push(stripped.to_string());
537        } else if let Some(stripped) = line.strip_prefix("| ") {
538            lines.push(stripped.to_string());
539        } else if line.starts_with("|=") {
540            lines.push(line.trim_start_matches('|').trim().to_string());
541        } else if !line.trim().is_empty() {
542            lines.push(line.to_string());
543        }
544    }
545    lines.join("\n")
546}