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};
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    let mut header_lines = Vec::new();
289    let mut rest_lines = Vec::new();
290    let mut in_header = true;
291
292    for line in body.lines() {
293        let trimmed = line.trim_start();
294        if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
295            header_lines.push(line);
296            continue;
297        }
298        in_header = false;
299        rest_lines.push(line);
300    }
301
302    let mut result = String::new();
303    if !header_lines.is_empty() {
304        for hl in header_lines {
305            result.push_str(hl);
306            if !hl.ends_with('\n') {
307                result.push('\n');
308            }
309        }
310        result.push('\n');
311    }
312
313    result.push_str(
314        "public class Main {\n    public static void main(String[] args) throws Exception {\n",
315    );
316    for line in rest_lines {
317        if line.trim().is_empty() {
318            result.push_str("        \n");
319        } else {
320            result.push_str("        ");
321            result.push_str(line);
322            result.push('\n');
323        }
324    }
325    result.push_str("    }\n}\n");
326    result
327}
328
329struct JavaSession {
330    child: std::process::Child,
331    stdout: BufReader<std::process::ChildStdout>,
332    stderr: Arc<Mutex<String>>,
333    closed: bool,
334}
335
336impl JavaSession {
337    fn write_code(&mut self, code: &str) -> Result<()> {
338        if self.closed {
339            anyhow::bail!("jshell session has already exited; start a new session with :reset");
340        }
341        let stdin = self
342            .child
343            .stdin
344            .as_mut()
345            .context("jshell session stdin closed")?;
346        stdin.write_all(code.as_bytes())?;
347        if !code.ends_with('\n') {
348            stdin.write_all(b"\n")?;
349        }
350        stdin.flush()?;
351        Ok(())
352    }
353
354    fn read_until_prompt(&mut self) -> Result<String> {
355        const PROMPT: &[u8] = b"jshell> ";
356        let mut buffer = Vec::new();
357        loop {
358            let mut byte = [0u8; 1];
359            let read = self.stdout.read(&mut byte)?;
360            if read == 0 {
361                break;
362            }
363            buffer.extend_from_slice(&byte[..read]);
364            if buffer.ends_with(PROMPT) {
365                break;
366            }
367        }
368
369        if buffer.ends_with(PROMPT) {
370            buffer.truncate(buffer.len() - PROMPT.len());
371        }
372
373        let mut text = String::from_utf8_lossy(&buffer).into_owned();
374        text = text.replace("\r\n", "\n");
375        text = text.replace('\r', "");
376        Ok(strip_feedback(text))
377    }
378
379    fn take_stderr(&self) -> String {
380        let mut lock = self.stderr.lock().expect("stderr lock poisoned");
381        if lock.is_empty() {
382            String::new()
383        } else {
384            let mut output = String::new();
385            std::mem::swap(&mut output, &mut *lock);
386            output
387        }
388    }
389
390    fn discard_prompt(&mut self) -> Result<()> {
391        let _ = self.read_until_prompt()?;
392        let _ = self.take_stderr();
393        Ok(())
394    }
395}
396
397impl LanguageSession for JavaSession {
398    fn language_id(&self) -> &str {
399        "java"
400    }
401
402    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
403        if self.closed {
404            return Ok(ExecutionOutcome {
405                language: self.language_id().to_string(),
406                exit_code: None,
407                stdout: String::new(),
408                stderr: "jshell session already exited. Use :reset to start a new session.\n"
409                    .to_string(),
410                duration: Duration::default(),
411            });
412        }
413
414        let trimmed = code.trim();
415        let exit_requested = matches!(trimmed, "/exit" | "/exit;" | ":exit");
416        let start = Instant::now();
417        self.write_code(code)?;
418        let stdout = match self.read_until_prompt() {
419            Ok(output) => output,
420            Err(_) if exit_requested => String::new(),
421            Err(err) => return Err(err),
422        };
423        let stderr = self.take_stderr();
424
425        if exit_requested {
426            self.closed = true;
427            let _ = self.child.stdin.take();
428            let _ = self.child.wait();
429        }
430
431        Ok(ExecutionOutcome {
432            language: self.language_id().to_string(),
433            exit_code: None,
434            stdout,
435            stderr,
436            duration: start.elapsed(),
437        })
438    }
439
440    fn shutdown(&mut self) -> Result<()> {
441        if !self.closed {
442            if let Some(mut stdin) = self.child.stdin.take() {
443                let _ = stdin.write_all(b"/exit\n");
444                let _ = stdin.flush();
445            }
446        }
447        let _ = self.child.wait();
448        self.closed = true;
449        Ok(())
450    }
451}
452
453fn strip_feedback(text: String) -> String {
454    let mut lines = Vec::new();
455    for line in text.lines() {
456        if let Some(stripped) = line.strip_prefix("|  ") {
457            lines.push(stripped.to_string());
458        } else if let Some(stripped) = line.strip_prefix("| ") {
459            lines.push(stripped.to_string());
460        } else if line.starts_with("|=") {
461            lines.push(line.trim_start_matches('|').trim().to_string());
462        } else if !line.trim().is_empty() {
463            lines.push(line.to_string());
464        }
465    }
466    lines.join("\n")
467}