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