Skip to main content

inline_java_core/
lib.rs

1//! Core runtime support for `inline_java`.
2//!
3//! This crate is an implementation detail of `inline_java_macros`.  End users
4//! should depend on `inline_java` instead of this crate directly.
5//!
6//! Public items:
7//!
8//! - [`JavaError`] — error type returned by [`run_java`] and by the `java!` /
9//!   `java_fn!` macros at program runtime.
10//! - [`run_java`] — compile (if needed) and run a generated Java class.
11//! - [`expand_java_args`] — shell-expand an option string into individual args.
12//! - [`cache_dir`] — compute the deterministic temp-dir path for a Java class.
13//! - [`detect_java_version`] — probe `javac -version` and return the major version.
14
15use shellexpand::full_with_context_no_errors;
16
17/// All errors that `java!` and `java_fn!` can return at runtime (and that
18/// `ct_java!` maps to `compile_error!` diagnostics at build time).
19#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
20pub enum JavaError {
21	/// An I/O error while creating the temp directory, writing the source
22	/// file, or spawning `javac`/`java` (e.g. the binary is not on `PATH`).
23	#[error("inline_java: I/O error: {0}")]
24	Io(String),
25
26	/// `javac` exited with a non-zero status.  The `0` field contains the
27	/// compiler diagnostic output (stderr).
28	#[error("inline_java: javac compilation failed:\n{0}")]
29	CompilationFailed(String),
30
31	/// The JVM exited with a non-zero status (e.g. an unhandled exception).
32	/// The `0` field contains the exception message and stack trace (stderr).
33	#[error("inline_java: java runtime failed:\n{0}")]
34	RuntimeFailed(String),
35
36	/// The Java `run()` method returned a `String` whose bytes are not valid
37	/// UTF-8.
38	#[error("inline_java: Java String output is not valid UTF-8: {0}")]
39	InvalidUtf8(#[from] std::string::FromUtf8Error),
40
41	/// The Java `run()` method returned a `char` value that is not a valid
42	/// Unicode scalar (i.e. a lone surrogate half).
43	#[error("inline_java: Java char is not a valid Unicode scalar value")]
44	InvalidChar,
45}
46
47/// Shell-expand `raw` (expanding env vars and `~`), then split into individual
48/// arguments (respecting quotes).
49/// Returns an empty vec if `raw` is empty.
50///
51/// # Examples
52///
53/// ```rust
54/// use inline_java_core::expand_java_args;
55///
56/// let args = expand_java_args("-verbose:class -Xmx512m");
57/// assert_eq!(args, vec!["-verbose:class", "-Xmx512m"]);
58///
59/// let empty = expand_java_args("");
60/// assert!(empty.is_empty());
61/// ```
62#[must_use]
63pub fn expand_java_args(raw: &str) -> Vec<String> {
64	if raw.is_empty() {
65		return Vec::new();
66	}
67	let expanded = full_with_context_no_errors(
68		raw,
69		|| std::env::var("HOME").ok(),
70		|var| std::env::var(var).ok(),
71	);
72	split_args(&expanded)
73}
74
75/// Inject `extra_cp` into `args` by appending it to any existing `-cp`,
76/// `-classpath`, or `--class-path` value, or by appending `-cp extra_cp`
77/// if no classpath flag is present.
78fn inject_classpath(args: &mut Vec<String>, extra_cp: &str) {
79	const SPACE_FLAGS: &[&str] = &["-cp", "-classpath", "--class-path"];
80	for i in 0..args.len() {
81		if SPACE_FLAGS.contains(&args[i].as_str()) && i + 1 < args.len() {
82			args[i + 1].push(CP_SEP);
83			args[i + 1].push_str(extra_cp);
84			return;
85		}
86		if let Some(val) = args[i].strip_prefix("--class-path=") {
87			args[i] = format!("--class-path={val}{CP_SEP}{extra_cp}");
88			return;
89		}
90	}
91	args.push("-cp".to_owned());
92	args.push(extra_cp.to_owned());
93}
94
95/// Split a shell-style argument string into individual arguments, respecting
96/// single- and double-quoted spans.
97fn split_args(s: &str) -> Vec<String> {
98	let mut args: Vec<String> = Vec::new();
99	let mut cur = String::new();
100	let mut in_single = false;
101	let mut in_double = false;
102
103	for ch in s.chars() {
104		match ch {
105			'\'' if !in_double => in_single = !in_single,
106			'"' if !in_single => in_double = !in_double,
107			' ' | '\t' if !in_single && !in_double => {
108				if !cur.is_empty() {
109					args.push(std::mem::take(&mut cur));
110				}
111			}
112			_ => cur.push(ch),
113		}
114	}
115	if !cur.is_empty() {
116		args.push(cur);
117	}
118	args
119}
120
121/// Classpath entry separator: `:` on Unix, `;` on Windows.
122#[cfg(not(windows))]
123const CP_SEP: char = ':';
124#[cfg(windows)]
125const CP_SEP: char = ';';
126
127/// Detect the installed Java major version by running `javac -version`.
128///
129/// `javac` (not `java`) is used because the compiler determines what class-file
130/// format is produced; the runtime is not guaranteed to be present.  Output is
131/// read from stdout (most JDKs) with stderr as fallback.
132///
133/// # Errors
134///
135/// Returns [`JavaError::Io`] if `javac` cannot be spawned or its output cannot
136/// be parsed as a version string.
137pub fn detect_java_version() -> Result<String, JavaError> {
138	let output = std::process::Command::new("javac")
139		.arg("-version")
140		.output()
141		.map_err(|e| JavaError::Io(format!("failed to run `javac -version`: {e}")))?;
142	// `javac -version` writes to stdout on most JDKs, e.g. "javac 21.0.10\n"
143	// (some older JDKs write to stderr; check both, prefer stdout)
144	let stdout = String::from_utf8_lossy(&output.stdout);
145	let stderr = String::from_utf8_lossy(&output.stderr);
146	let raw = if stdout.trim().is_empty() {
147		&*stderr
148	} else {
149		&*stdout
150	};
151	let version_str = raw.trim().strip_prefix("javac ").unwrap_or(raw.trim());
152	let major = version_str
153		.split('.')
154		.next()
155		.and_then(|s| s.parse::<u32>().ok())
156		.ok_or_else(|| {
157			JavaError::Io(format!(
158				"could not parse major version from `javac -version` output: {raw:?}"
159			))
160		})?;
161	Ok(major.to_string())
162}
163
164/// Resolve the root directory used to cache compiled `.class` files.
165///
166/// Resolution order:
167/// 1. `INLINE_JAVA_CACHE_DIR` environment variable, if set and non-empty.
168/// 2. The XDG / platform cache directory (`~/.cache/inline_java` on Linux,
169///    `~/Library/Caches/inline_java` on macOS, `%LOCALAPPDATA%\inline_java`
170///    on Windows) via the [`dirs`] crate.
171/// 3. `<system_temp>/inline_java` as a final fallback.
172#[must_use]
173pub fn base_cache_dir() -> std::path::PathBuf {
174	if let Ok(v) = std::env::var("INLINE_JAVA_CACHE_DIR")
175		&& !v.is_empty()
176	{
177		return std::path::PathBuf::from(v);
178	}
179	if let Some(cache) = dirs::cache_dir() {
180		return cache.join("inline_java");
181	}
182	std::env::temp_dir().join("inline_java")
183}
184
185/// Compute the deterministic cache-dir path used to store compiled `.class` files.
186///
187/// The path is `<base_cache_dir>/<class_name>_<hex_hash>/` where `hex_hash` is a
188/// 64-bit hash of:
189/// - `java_class` — the complete Java source text
190/// - `expand_java_args(javac_raw)` — shell-expanded javac args (env vars and
191///   `~` substituted); relative paths in these args are anchored by the next
192///   component below
193/// - `std::env::current_dir()` — the process working directory at call time;
194///   including it ensures that two invocations with the same `javac_raw`
195///   containing relative paths (e.g. `-cp .`) but from different working
196///   directories hash to different cache entries
197/// - `java_raw` — hashed as a raw string (no expansion needed for cache
198///   differentiation; the `java` step always re-runs fresh)
199/// - [`detect_java_version()`] — the installed `javac` major version; ensures
200///   that upgrading the JDK produces a fresh cache entry whose `.class` files
201///   are compiled by the new compiler
202///
203/// The base directory is resolved by [`base_cache_dir`].
204///
205/// # Errors
206///
207/// Returns [`JavaError::Io`] if `javac -version` cannot be run or its output
208/// cannot be parsed (see [`detect_java_version`]).
209#[allow(clippy::similar_names)]
210pub fn cache_dir(
211	class_name: &str,
212	java_class: &str,
213	javac_raw: &str,
214	java_raw: &str,
215) -> Result<std::path::PathBuf, JavaError> {
216	use std::collections::hash_map::DefaultHasher;
217	use std::hash::{Hash, Hasher};
218
219	let mut h = DefaultHasher::new();
220	java_class.hash(&mut h);
221	expand_java_args(javac_raw).hash(&mut h); // shell-expanded; CWD handles relative paths
222	std::env::current_dir().ok().hash(&mut h); // anchors relative paths in javac_raw
223	java_raw.hash(&mut h);
224	detect_java_version()?.hash(&mut h); // avoids .class collisions across JDK major versions
225
226	let hex = format!("{:016x}", h.finish());
227	Ok(base_cache_dir().join(format!("{class_name}_{hex}")))
228}
229
230/// Compile (if needed) and run a generated Java class, returning raw stdout bytes.
231///
232/// Both the compile step (javac) and the run step (java) are guarded by a
233/// per-class-name file lock so that concurrent invocations cooperate correctly.
234/// A `.done` sentinel and an optimistic pre-check skip recompilation on
235/// subsequent calls without acquiring the lock.
236///
237/// - `class_name`      — bare class name; used as the temp-dir name.
238/// - `filename`        — `"<class_name>.java"`, written inside the temp dir.
239/// - `java_class`      — complete `.java` source to write.
240/// - `full_class_name` — package-qualified class name passed to `java`.
241/// - `javac_raw`       — raw `javac = "..."` option string (shell-expanded).
242/// - `java_raw`        — raw `java  = "..."` option string (shell-expanded).
243/// - `stdin_bytes`     — bytes to pipe to the child process's stdin (may be empty).
244///
245/// # Errors
246///
247/// Returns [`JavaError::Io`] if the temp directory, source file, or lock file
248/// cannot be created, or if `javac`/`java` cannot be spawned.
249/// Returns [`JavaError::CompilationFailed`] if `javac` exits with a non-zero status.
250/// Returns [`JavaError::RuntimeFailed`] if `java` exits with a non-zero status.
251///
252/// # Examples
253///
254/// ```rust,no_run
255/// use inline_java_core::run_java;
256///
257/// let src = "public class Greet {
258///     public static void main(String[] args) {
259///         System.out.print(\"hi\");
260///     }
261/// }";
262/// let output = run_java("Greet", "Greet.java", src, "Greet", "", "", &[]).unwrap();
263/// assert_eq!(output, b"hi");
264/// ```
265#[allow(clippy::similar_names)]
266pub fn run_java(
267	class_name: &str,
268	filename: &str,
269	java_class: &str,
270	full_class_name: &str,
271	javac_raw: &str,
272	java_raw: &str,
273	stdin_bytes: &[u8],
274) -> Result<Vec<u8>, JavaError> {
275	use std::io::Write;
276	use std::process::Stdio;
277
278	let tmp_dir = cache_dir(class_name, java_class, javac_raw, java_raw)?;
279	let javac_extra = expand_java_args(javac_raw);
280	let mut java_extra = expand_java_args(java_raw);
281	inject_classpath(&mut java_extra, &tmp_dir.to_string_lossy());
282
283	if !tmp_dir.join(".done").exists() {
284		std::fs::create_dir_all(&tmp_dir).map_err(|e| JavaError::Io(e.to_string()))?;
285
286		let lock_file = std::fs::OpenOptions::new()
287			.create(true)
288			.truncate(false)
289			.write(true)
290			.open(tmp_dir.join(".lock"))
291			.map_err(|e| JavaError::Io(e.to_string()))?;
292		let mut lock = fd_lock::RwLock::new(lock_file);
293		let _guard = lock.write().map_err(|e| JavaError::Io(e.to_string()))?;
294
295		if !tmp_dir.join(".done").exists() {
296			let src = tmp_dir.join(filename);
297			std::fs::write(&src, java_class).map_err(|e| JavaError::Io(e.to_string()))?;
298
299			let mut cmd = std::process::Command::new("javac");
300			for arg in &javac_extra {
301				cmd.arg(arg);
302			}
303			let out = cmd
304				.arg("-d")
305				.arg(&tmp_dir)
306				.arg(&src)
307				.output()
308				.map_err(|e| JavaError::Io(e.to_string()))?;
309			if !out.status.success() {
310				return Err(JavaError::CompilationFailed(
311					String::from_utf8_lossy(&out.stderr).into_owned(),
312				));
313			}
314
315			std::fs::write(tmp_dir.join(".done"), b"").map_err(|e| JavaError::Io(e.to_string()))?;
316		}
317	}
318
319	let mut cmd = std::process::Command::new("java");
320	for arg in &java_extra {
321		cmd.arg(arg);
322	}
323	let mut child = cmd
324		.arg(full_class_name)
325		.stdin(Stdio::piped())
326		.stdout(Stdio::piped())
327		.stderr(Stdio::piped())
328		.spawn()
329		.map_err(|e| JavaError::Io(e.to_string()))?;
330
331	// Write stdin bytes then drop the handle to signal EOF.
332	if stdin_bytes.is_empty() {
333		// Drop stdin handle even when empty so Java doesn't block waiting.
334		drop(child.stdin.take());
335	} else if let Some(mut stdin_handle) = child.stdin.take() {
336		stdin_handle
337			.write_all(stdin_bytes)
338			.map_err(|e| JavaError::Io(e.to_string()))?;
339	}
340
341	let out = child
342		.wait_with_output()
343		.map_err(|e| JavaError::Io(e.to_string()))?;
344
345	if !out.status.success() {
346		return Err(JavaError::RuntimeFailed(
347			String::from_utf8_lossy(&out.stderr).into_owned(),
348		));
349	}
350
351	Ok(out.stdout)
352}
353
354#[cfg(test)]
355mod tests {
356	use super::cache_dir;
357
358	// -----------------------------------------------------------------------
359	// cache_dir is idempotent: two calls with identical arguments return the
360	// same path.
361	// -----------------------------------------------------------------------
362	#[test]
363	fn cache_dir_idempotent() {
364		let a = cache_dir("MyClass", "class body", "-cp /usr/lib", "-verbose").unwrap();
365		let b = cache_dir("MyClass", "class body", "-cp /usr/lib", "-verbose").unwrap();
366		assert_eq!(
367			a, b,
368			"cache_dir must return the same path for identical args"
369		);
370	}
371
372	// -----------------------------------------------------------------------
373	// cache_dir produces different paths for javac_raw strings that expand to
374	// different argument lists.
375	// -----------------------------------------------------------------------
376	#[test]
377	fn cache_dir_differs_for_different_javac_raw() {
378		let a = cache_dir("MyClass", "class body", "-cp /usr/lib/foo", "").unwrap();
379		let b = cache_dir("MyClass", "class body", "-cp /usr/lib/bar", "").unwrap();
380		assert_ne!(
381			a, b,
382			"cache_dir must differ when javac_raw expands to different args"
383		);
384	}
385
386	// -----------------------------------------------------------------------
387	// cache_dir produces different paths when java_class differs.
388	// -----------------------------------------------------------------------
389	#[test]
390	fn cache_dir_differs_for_different_java_class() {
391		let a = cache_dir("MyClass", "class body A", "", "").unwrap();
392		let b = cache_dir("MyClass", "class body B", "", "").unwrap();
393		assert_ne!(a, b, "cache_dir must differ when java_class differs");
394	}
395
396	// -----------------------------------------------------------------------
397	// cache_dir produces different paths when java_raw differs.
398	// -----------------------------------------------------------------------
399	#[test]
400	fn cache_dir_differs_for_different_java_raw() {
401		let a = cache_dir("MyClass", "class body", "", "-Xmx256m").unwrap();
402		let b = cache_dir("MyClass", "class body", "", "-Xmx512m").unwrap();
403		assert_ne!(a, b, "cache_dir must differ when java_raw differs");
404	}
405
406	// -----------------------------------------------------------------------
407	// cache_dir result is inside base_cache_dir and uses the class_name as a
408	// prefix.
409	// -----------------------------------------------------------------------
410	#[test]
411	fn cache_dir_path_structure() {
412		let result = cache_dir("InlineJava_abc123", "src", "", "").unwrap();
413		let base = super::base_cache_dir();
414		assert!(
415			result.starts_with(&base),
416			"cache_dir result must be under base_cache_dir ({}); got: {}",
417			base.display(),
418			result.display()
419		);
420		let file_name = result.file_name().unwrap().to_string_lossy();
421		assert!(
422			file_name.starts_with("InlineJava_abc123_"),
423			"cache_dir result filename must start with the class name; got: {file_name}"
424		);
425	}
426
427	// -----------------------------------------------------------------------
428	// detect_java_version returns a non-empty numeric string when javac is
429	// available in PATH (as it is in this repo's dev environment).
430	// -----------------------------------------------------------------------
431	#[test]
432	fn detect_java_version_returns_major() {
433		let version = super::detect_java_version().expect("javac must be on PATH");
434		assert!(
435			version.parse::<u32>().is_ok(),
436			"version string must be a plain integer (major); got: {version:?}"
437		);
438	}
439
440	// -----------------------------------------------------------------------
441	// INLINE_JAVA_CACHE_DIR env var overrides the base cache directory.
442	// -----------------------------------------------------------------------
443	#[test]
444	fn base_cache_dir_respects_env_var() {
445		unsafe { std::env::set_var("INLINE_JAVA_CACHE_DIR", "/custom/cache") };
446		let base = super::base_cache_dir();
447		unsafe { std::env::remove_var("INLINE_JAVA_CACHE_DIR") };
448		assert_eq!(base, std::path::PathBuf::from("/custom/cache"));
449	}
450}