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                let output = self
229                    .run(&cache_dir, "Main", args)
230                    .with_context(|| "failed to execute cached Java artifact")?;
231                return Ok(ExecutionOutcome {
232                    language: self.id().to_string(),
233                    exit_code: output.status.code(),
234                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
235                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
236                    duration: start.elapsed(),
237                });
238            }
239        }
240
241        let temp_dir = Builder::new()
242            .prefix("run-java")
243            .tempdir()
244            .context("failed to create temporary directory for java build")?;
245        let dir_path = temp_dir.path();
246
247        let (source_path, class_name) = match payload {
248            ExecutionPayload::Inline { code, .. } => self.write_inline_source(code, dir_path)?,
249            ExecutionPayload::Stdin { code, .. } => self.write_from_stdin(code, dir_path)?,
250            ExecutionPayload::File { path, .. } => self.copy_source(path, dir_path)?,
251        };
252
253        let start = Instant::now();
254
255        let compile_output = self.compile(&source_path, dir_path)?;
256        if !compile_output.status.success() {
257            return Ok(ExecutionOutcome {
258                language: self.id().to_string(),
259                exit_code: compile_output.status.code(),
260                stdout: String::from_utf8_lossy(&compile_output.stdout).into_owned(),
261                stderr: String::from_utf8_lossy(&compile_output.stderr).into_owned(),
262                duration: start.elapsed(),
263            });
264        }
265
266        // Cache compiled class files for inline/stdin
267        if let Some(code) = match payload {
268            ExecutionPayload::Inline { code, .. } | ExecutionPayload::Stdin { code, .. } => {
269                Some(code.as_str())
270            }
271            _ => None,
272        } {
273            let wrapped = wrap_inline_java(code);
274            let src_hash = hash_source(&wrapped);
275            let cache_dir = std::env::temp_dir()
276                .join("run-compile-cache")
277                .join(format!("java-{:016x}", src_hash));
278            let _ = std::fs::create_dir_all(&cache_dir);
279            // Copy all .class files
280            if let Ok(entries) = std::fs::read_dir(dir_path) {
281                for entry in entries.flatten() {
282                    if entry.path().extension().and_then(|e| e.to_str()) == Some("class") {
283                        let _ = std::fs::copy(entry.path(), cache_dir.join(entry.file_name()));
284                    }
285                }
286            }
287        }
288
289        let run_output = self.run(dir_path, &class_name, args)?;
290        Ok(ExecutionOutcome {
291            language: self.id().to_string(),
292            exit_code: run_output.status.code(),
293            stdout: String::from_utf8_lossy(&run_output.stdout).into_owned(),
294            stderr: String::from_utf8_lossy(&run_output.stderr).into_owned(),
295            duration: start.elapsed(),
296        })
297    }
298
299    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
300        let jshell = self.ensure_jshell()?;
301        let mut cmd = Command::new(jshell);
302        cmd.arg("--execution=local")
303            .arg("--feedback=concise")
304            .arg("--no-startup")
305            .stdin(Stdio::piped())
306            .stdout(Stdio::piped())
307            .stderr(Stdio::piped());
308
309        let mut child = cmd
310            .spawn()
311            .with_context(|| format!("failed to start {} REPL", jshell.display()))?;
312
313        let stdout = child.stdout.take().context("missing stdout handle")?;
314        let stderr = child.stderr.take().context("missing stderr handle")?;
315
316        let stderr_buffer = Arc::new(Mutex::new(String::new()));
317        let stderr_collector = stderr_buffer.clone();
318        thread::spawn(move || {
319            let mut reader = BufReader::new(stderr);
320            let mut buf = String::new();
321            loop {
322                buf.clear();
323                match reader.read_line(&mut buf) {
324                    Ok(0) => break,
325                    Ok(_) => {
326                        let Ok(mut lock) = stderr_collector.lock() else {
327                            break;
328                        };
329                        lock.push_str(&buf);
330                    }
331                    Err(_) => break,
332                }
333            }
334        });
335
336        let mut session = JavaSession {
337            child,
338            stdout: BufReader::new(stdout),
339            stderr: stderr_buffer,
340            closed: false,
341        };
342
343        session.discard_prompt()?;
344
345        Ok(Box::new(session))
346    }
347}
348
349fn resolve_javac_binary() -> Option<PathBuf> {
350    which::which("javac").ok()
351}
352
353fn resolve_java_binary() -> Option<PathBuf> {
354    which::which("java").ok()
355}
356
357fn resolve_jshell_binary() -> Option<PathBuf> {
358    which::which("jshell").ok()
359}
360
361fn wrap_inline_java(body: &str) -> String {
362    if body.contains("class ") {
363        return body.to_string();
364    }
365
366    let mut header_lines = Vec::new();
367    let mut rest_lines = Vec::new();
368    let mut in_header = true;
369
370    for line in body.lines() {
371        let trimmed = line.trim_start();
372        if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
373            header_lines.push(line);
374            continue;
375        }
376        in_header = false;
377        rest_lines.push(line);
378    }
379
380    let mut result = String::new();
381    if !header_lines.is_empty() {
382        for hl in header_lines {
383            result.push_str(hl);
384            if !hl.ends_with('\n') {
385                result.push('\n');
386            }
387        }
388        result.push('\n');
389    }
390
391    result.push_str(
392        "public class Main {\n    public static void main(String[] args) throws Exception {\n",
393    );
394    for line in rest_lines {
395        if line.trim().is_empty() {
396            result.push_str("        \n");
397        } else {
398            result.push_str("        ");
399            result.push_str(line);
400            result.push('\n');
401        }
402    }
403    result.push_str("    }\n}\n");
404    result
405}
406
407struct JavaSession {
408    child: std::process::Child,
409    stdout: BufReader<std::process::ChildStdout>,
410    stderr: Arc<Mutex<String>>,
411    closed: bool,
412}
413
414impl JavaSession {
415    fn write_code(&mut self, code: &str) -> Result<()> {
416        if self.closed {
417            anyhow::bail!("jshell session has already exited; start a new session with :reset");
418        }
419        let stdin = self
420            .child
421            .stdin
422            .as_mut()
423            .context("jshell session stdin closed")?;
424        stdin.write_all(code.as_bytes())?;
425        if !code.ends_with('\n') {
426            stdin.write_all(b"\n")?;
427        }
428        stdin.flush()?;
429        Ok(())
430    }
431
432    fn read_until_prompt(&mut self) -> Result<String> {
433        const PROMPT: &[u8] = b"jshell> ";
434        let mut buffer = Vec::new();
435        loop {
436            let mut byte = [0u8; 1];
437            let read = self.stdout.read(&mut byte)?;
438            if read == 0 {
439                break;
440            }
441            buffer.extend_from_slice(&byte[..read]);
442            if buffer.ends_with(PROMPT) {
443                break;
444            }
445        }
446
447        if buffer.ends_with(PROMPT) {
448            buffer.truncate(buffer.len() - PROMPT.len());
449        }
450
451        let mut text = String::from_utf8_lossy(&buffer).into_owned();
452        text = text.replace("\r\n", "\n");
453        text = text.replace('\r', "");
454        Ok(strip_feedback(text))
455    }
456
457    fn take_stderr(&self) -> String {
458        let Ok(mut lock) = self.stderr.lock() else {
459            return String::new();
460        };
461        if lock.is_empty() {
462            String::new()
463        } else {
464            let mut output = String::new();
465            std::mem::swap(&mut output, &mut *lock);
466            output
467        }
468    }
469
470    fn discard_prompt(&mut self) -> Result<()> {
471        let _ = self.read_until_prompt()?;
472        let _ = self.take_stderr();
473        Ok(())
474    }
475}
476
477impl LanguageSession for JavaSession {
478    fn language_id(&self) -> &str {
479        "java"
480    }
481
482    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
483        if self.closed {
484            return Ok(ExecutionOutcome {
485                language: self.language_id().to_string(),
486                exit_code: None,
487                stdout: String::new(),
488                stderr: "jshell session already exited. Use :reset to start a new session.\n"
489                    .to_string(),
490                duration: Duration::default(),
491            });
492        }
493
494        let trimmed = code.trim();
495        let exit_requested = matches!(trimmed, "/exit" | "/exit;" | ":exit");
496        let start = Instant::now();
497        self.write_code(code)?;
498        let stdout = match self.read_until_prompt() {
499            Ok(output) => output,
500            Err(_) if exit_requested => String::new(),
501            Err(err) => return Err(err),
502        };
503        let stderr = self.take_stderr();
504
505        if exit_requested {
506            self.closed = true;
507            let _ = self.child.stdin.take();
508            let _ = self.child.wait();
509        }
510
511        Ok(ExecutionOutcome {
512            language: self.language_id().to_string(),
513            exit_code: None,
514            stdout,
515            stderr,
516            duration: start.elapsed(),
517        })
518    }
519
520    fn shutdown(&mut self) -> Result<()> {
521        if !self.closed
522            && let Some(mut stdin) = self.child.stdin.take()
523        {
524            let _ = stdin.write_all(b"/exit\n");
525            let _ = stdin.flush();
526        }
527        let _ = self.child.wait();
528        self.closed = true;
529        Ok(())
530    }
531}
532
533fn strip_feedback(text: String) -> String {
534    let mut lines = Vec::new();
535    for line in text.lines() {
536        if let Some(stripped) = line.strip_prefix("|  ") {
537            lines.push(stripped.to_string());
538        } else if let Some(stripped) = line.strip_prefix("| ") {
539            lines.push(stripped.to_string());
540        } else if line.starts_with("|=") {
541            lines.push(line.trim_start_matches('|').trim().to_string());
542        } else if !line.trim().is_empty() {
543            lines.push(line.to_string());
544        }
545    }
546    lines.join("\n")
547}