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