From 8113aff16ef0cd5e3ceef7258c0fc74a3a815bae Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:55:58 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(windows):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BF=87=E6=B8=A1=20crate=20lithe-legacy-java=EF=BC=8C?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=20Windows=20=E4=BB=8D=E5=9C=A8=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E6=97=A7=20Java/Maven=20Core=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1495 把 Java/Maven 逻辑迁出 Rust Core 和宿主。Windows 前端仍通过旧命令名调用这些能力, 本提交把它们放进 Windows 专属的过渡 crate,对外只有 try_execute:命中旧命令名时返回与 Core 原先逐字节一致的 JSON 响应和错误码,其余命令返回 None,由调用方继续交给 Core。Windows 前端因此无需改动。 覆盖的命令:maven.scan、maven.launchPlan、maven.dependencyPlan、maven.dependencies、maven.diagnostics、 maven.testResults、java.workspacePolicy、java.jdtCacheRetention、java.jdtWorkspaceFingerprint、 java.sourceDefinition、java.runMarkers、spring.index、mybatis.index。 同时提供 Windows 宿主直接调用的函数:超长类路径 jar 规划、启动命令规划、JDK 选择、JDT 工作区键、 Maven settings 本地仓库改写、运行源码枚举。对应的 Core 单元测试一并移入 crate 的 src/tests。 crate 文档说明了它是过渡实现以及删除条件:Windows 的 Java/Maven 能力改由插件提供之后整体删除。 try_execute 仅模拟 Core 的取消作用域,不经过 request_trace、远程工作区作用域和 Git 执行策略作用域。 (cherry picked from commit 20fbe23da99a9a14e3252c645af877464a9f84df) --- .../tauri/crates/lithe-legacy-java/Cargo.toml | 18 + .../lithe-legacy-java/src/classpath_jar.rs | 619 +++++ .../crates/lithe-legacy-java/src/commands.rs | 220 ++ .../crates/lithe-legacy-java/src/contracts.rs | 369 +++ .../lithe-legacy-java/src/java_run_markers.rs | 483 ++++ .../lithe-legacy-java/src/java_selection.rs | 237 ++ .../src/java_source_definition.rs | 364 +++ .../lithe-legacy-java/src/java_workspace.rs | 537 ++++ .../src/jdt_workspace_key.rs | 164 ++ .../lithe-legacy-java/src/launch_command.rs | 493 ++++ .../tauri/crates/lithe-legacy-java/src/lib.rs | 53 + .../crates/lithe-legacy-java/src/local_fs.rs | 30 + .../src/maven/dependency_tree.rs | 474 ++++ .../crates/lithe-legacy-java/src/maven/mod.rs | 2386 ++++++++++++++++ .../src/maven/test_reports.rs | 572 ++++ .../crates/lithe-legacy-java/src/mybatis.rs | 497 ++++ .../lithe-legacy-java/src/run_sources.rs | 204 ++ .../crates/lithe-legacy-java/src/spring.rs | 2448 +++++++++++++++++ .../lithe-legacy-java/src/tests/java.rs | 298 ++ .../lithe-legacy-java/src/tests/maven.rs | 1350 +++++++++ .../crates/lithe-legacy-java/src/tests/mod.rs | 9 + .../lithe-legacy-java/src/tests/mybatis.rs | 501 ++++ .../lithe-legacy-java/src/tests/spring.rs | 605 ++++ .../lithe-legacy-java/src/tests/support.rs | 37 + 24 files changed, 12968 insertions(+) create mode 100644 windows/tauri/crates/lithe-legacy-java/Cargo.toml create mode 100644 windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/commands.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/contracts.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/java_selection.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/launch_command.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/lib.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/local_fs.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/mybatis.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/run_sources.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/spring.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/java.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs create mode 100644 windows/tauri/crates/lithe-legacy-java/src/tests/support.rs diff --git a/windows/tauri/crates/lithe-legacy-java/Cargo.toml b/windows/tauri/crates/lithe-legacy-java/Cargo.toml new file mode 100644 index 000000000..6fde013fc --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lithe-legacy-java" +version = "0.1.0" +# Edition 2021 on purpose: most of this crate is code copied verbatim from +# lithe-core, which is an edition 2021 crate. Keeping the edition means the +# copies behave exactly as they did there, which is the whole point of the crate. +edition = "2021" + +[dependencies] +lithe-core = { path = "../../../../rust/lithe-core" } +quick-xml = "0.37" +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +tree-sitter = "0.25" +tree-sitter-java = "0.23" +zip = { version = "2.4", default-features = false, features = ["deflate"] } diff --git a/windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs b/windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs new file mode 100644 index 000000000..b81dafde8 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs @@ -0,0 +1,619 @@ +//! Shortens an oversized Java 8 class path through a manifest-only JAR. +//! +//! JDK 9 added `java @file` argument files, so [`crate::plan_launch_command`] +//! cannot help a JDK 8 launch. The launcher has always accepted a JAR whose +//! `META-INF/MANIFEST.MF` lists further class-path entries in its `Class-Path` +//! attribute. Passing that one JAR with `-cp` keeps the command line short +//! while the application class loader still sees every original entry in order. +//! +//! Core converts each entry to an absolute `file:` URL and renders the manifest +//! text, including the 72-byte line rule. The host owns the filesystem: it +//! answers whether an entry is a directory, writes the JAR at the path it +//! supplied, and deletes it after that execution exits. Every URL is +//! percent-encoded UTF-8, so the manifest is pure ASCII and, unlike an argument +//! file, does not depend on the Windows system code page. + +use crate::launch_command::{command_line_length, COMMAND_LINE_MARGIN, WINDOWS_COMMAND_LINE_LIMIT}; +use std::path::Path; + +/// First JDK release whose launcher reads argument files; from this version on +/// the argument-file planner owns shortening. +const FIRST_ARGFILE_JAVA_VERSION: u32 = 9; + +/// Launcher options that a JDK 8 `java` accepts for the class path. The long +/// `--class-path` spelling only exists from JDK 9 on. +const CLASSPATH_OPTIONS: &[&str] = &["-cp", "-classpath"]; + +/// Longest manifest line in bytes, excluding the line break. Longer headers +/// continue on lines that start with one space (JAR File Specification). +const MANIFEST_LINE_LIMIT: usize = 72; + +/// Path syntax of the host that will start the JVM. It decides the class-path +/// separator and how absolute paths are recognized and turned into URLs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClasspathStyle { + /// `;`-separated entries with drive-letter or UNC absolute paths. + Windows, + /// `:`-separated entries with `/`-rooted absolute paths. + Posix, +} + +/// Inputs for [`plan_classpath_jar_launch`]. +#[derive(Debug, Clone, Copy)] +pub struct ClasspathJarRequest<'a> { + /// Java executable path used for launcher detection. + pub executable: &'a str, + /// Complete argument vector before shortening. + pub arguments: &'a [String], + /// Directory the JVM starts in. Relative class-path entries resolve against + /// it on the command line, but against the JAR inside a manifest, so Core + /// makes them absolute first. + pub working_directory: &'a str, + /// Host-owned path that may be referenced by a classpath-JAR plan. + pub classpath_jar_path: &'a Path, + /// Optional host-specific command-line cap; `None` uses the Windows cap. + pub limit: Option, + /// JDK feature version read by the host from its `release` file. + pub java_feature_version: Option, + /// Path syntax used by the class-path value. + pub style: ClasspathStyle, +} + +/// How a host should start a JDK 8 launch whose class path may be too long. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClasspathJarPlan { + /// Spawn with the original arguments. + Direct, + /// Write a JAR containing only `META-INF/MANIFEST.MF` with `manifest` at the + /// supplied path, then spawn with `arguments`. + ClasspathJar { + /// Arguments whose effective class-path value is the JAR path. + arguments: Vec, + /// Complete ASCII manifest text with CRLF line breaks. + manifest: String, + }, +} + +/// Plans a manifest-JAR launch for a known JDK older than 9. +/// +/// `is_directory` receives each absolute entry and reports whether it names a +/// directory: a manifest URL must end in `/` for the class loader to treat it as +/// a directory rather than an archive. Returns [`ClasspathJarPlan::Direct`] when +/// the JDK is unknown or supports argument files, the command fits, the launch +/// uses `-jar` (which ignores `-cp`), or an entry cannot be expressed as an +/// absolute URL without changing its meaning, such as a `lib/*` wildcard. +pub fn plan_classpath_jar_launch( + request: ClasspathJarRequest<'_>, + is_directory: impl Fn(&str) -> bool, +) -> ClasspathJarPlan { + let executable_name = request + .executable + .rsplit(['/', '\\']) + .next() + .unwrap_or(request.executable); + if !["java", "java.exe", "javaw", "javaw.exe"] + .iter() + .any(|name| executable_name.eq_ignore_ascii_case(name)) + || !request + .java_feature_version + .is_some_and(|version| version < FIRST_ARGFILE_JAVA_VERSION) + { + return ClasspathJarPlan::Direct; + } + let budget = request + .limit + .unwrap_or(WINDOWS_COMMAND_LINE_LIMIT) + .saturating_sub(COMMAND_LINE_MARGIN); + if command_line_length(request.executable, request.arguments) <= budget { + return ClasspathJarPlan::Direct; + } + let Some(value_index) = effective_classpath_value_index(request.arguments) else { + return ClasspathJarPlan::Direct; + }; + let Some(urls) = classpath_urls( + &request.arguments[value_index], + request.working_directory, + request.style, + &is_directory, + ) else { + return ClasspathJarPlan::Direct; + }; + let mut arguments = request.arguments.to_vec(); + arguments[value_index] = request.classpath_jar_path.display().to_string(); + let mut manifest = String::from("Manifest-Version: 1.0\r\n"); + manifest.push_str(&manifest_header("Class-Path", &urls.join(" "))); + // A header is only recognized once its line is terminated, and the blank + // line closes the main section. + manifest.push_str("\r\n"); + ClasspathJarPlan::ClasspathJar { + arguments, + manifest, + } +} + +/// Finds the class-path value the JDK 8 launcher will use. +/// +/// Launcher options end at the main class or `-jar`. When `-cp` appears more +/// than once the last occurrence wins, so only that value is replaced. `-jar` +/// ignores `-cp` entirely, which leaves nothing to shorten. +fn effective_classpath_value_index(arguments: &[String]) -> Option { + let mut effective = None; + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + if argument == "-jar" { + return None; + } + if !argument.starts_with('-') { + break; + } + if CLASSPATH_OPTIONS.contains(&argument) && index + 1 < arguments.len() { + effective = Some(index + 1); + index += 2; + } else { + index += 1; + } + } + effective +} + +/// Converts a joined class-path value into manifest URLs in launcher order. +fn classpath_urls( + value: &str, + working_directory: &str, + style: ClasspathStyle, + is_directory: &dyn Fn(&str) -> bool, +) -> Option> { + let separator = match style { + ClasspathStyle::Windows => ';', + ClasspathStyle::Posix => ':', + }; + value + .split(separator) + .map(|entry| { + // The launcher reads an empty element as the current directory. + let entry = if entry.is_empty() { "." } else { entry }; + let absolute = absolute_entry(entry, working_directory, style)?; + // Launcher wildcards are expanded only on the command line; the + // manifest class loader would look for a file literally named `*`. + if absolute.rsplit(['/', '\\']).next() == Some("*") { + return None; + } + Some(file_url(&absolute, style, is_directory(&absolute))) + }) + .collect() +} + +/// Makes one entry absolute, or returns `None` when its base is ambiguous. +fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) -> Option { + match style { + ClasspathStyle::Posix => { + if entry.starts_with('/') { + Some(entry.to_string()) + } else if working_directory.starts_with('/') { + Some(format!( + "{}/{entry}", + working_directory.trim_end_matches('/') + )) + } else { + None + } + } + ClasspathStyle::Windows => { + let entry = strip_verbatim_prefix(&entry.replace('/', "\\"))?; + let working_directory = strip_verbatim_prefix(&working_directory.replace('/', "\\"))?; + let working_directory = working_directory.as_str(); + let path = if windows_root(&entry).is_some() { + entry + } else if entry.starts_with('\\') { + // `\lib\a.jar` is rooted on the working directory's drive. + format!( + "{}{entry}", + windows_root(working_directory)?.trim_end_matches('\\') + ) + } else if entry.as_bytes().get(1) == Some(&b':') { + // `C:lib` depends on a per-drive current directory that the + // host never sees, so it cannot be resolved deterministically. + return None; + } else { + windows_root(working_directory)?; + format!( + "{}\\{entry}", + working_directory.trim_end_matches(['\\', '/']) + ) + }; + Some(normalize_windows_path(&path)) + } + } +} + +/// Rewrites `\\?\C:\x` to `C:\x` and `\\?\UNC\host\share` to `\\host\share`. +/// +/// Other `\\?\` and `\\.\` device paths have no `file:` URL form, so they +/// return `None` and keep the launch direct instead of becoming a bogus UNC +/// host named `?` or `.`. +fn strip_verbatim_prefix(entry: &str) -> Option { + if let Some(rest) = entry.strip_prefix("\\\\?\\UNC\\") { + return Some(format!("\\\\{rest}")); + } + if let Some(rest) = entry.strip_prefix("\\\\?\\") { + let bytes = rest.as_bytes(); + let drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\'; + return drive.then(|| rest.to_string()); + } + if entry.starts_with("\\\\.\\") { + return None; + } + Some(entry.to_string()) +} + +/// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`. +fn windows_root(path: &str) -> Option { + let path = path.replace('/', "\\"); + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { + return Some(path[..3].to_string()); + } + let unc = path.strip_prefix("\\\\")?; + let mut parts = unc.splitn(3, '\\'); + let host = parts.next().filter(|part| !part.is_empty())?; + let share = parts.next().filter(|part| !part.is_empty())?; + Some(format!("\\\\{host}\\{share}")) +} + +/// Removes `.` and `..` segments the way Windows does, which is lexical. +fn normalize_windows_path(path: &str) -> String { + let root = windows_root(path).expect("absolute Windows path"); + let mut segments: Vec<&str> = Vec::new(); + for segment in path[root.len()..].split('\\') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + _ => segments.push(segment), + } + } + let root = root.trim_end_matches('\\'); + if segments.is_empty() { + format!("{root}\\") + } else { + format!("{root}\\{}", segments.join("\\")) + } +} + +/// Renders an absolute path as a `file:` URL the JDK class loader can open. +/// +/// Drive paths become `file:/C:/...` and UNC paths `file:////host/share/...`, +/// matching `java.io.File.toURI`. Every byte outside a small unreserved set is +/// percent-encoded as UTF-8; the JDK decodes the URL before opening the file. +fn file_url(path: &str, style: ClasspathStyle, directory: bool) -> String { + let path = match style { + ClasspathStyle::Windows => { + let slashed = path.replace('\\', "/"); + if slashed.starts_with("//") { + format!("//{slashed}") + } else { + format!("/{slashed}") + } + } + ClasspathStyle::Posix => path.to_string(), + }; + let mut url = String::from("file:"); + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') { + url.push(byte as char); + } else { + url.push_str(&format!("%{byte:02X}")); + } + } + if directory && !url.ends_with('/') { + url.push('/'); + } + url +} + +/// Renders one manifest header, continuing lines after 72 bytes. +/// +/// The text is ASCII because every URL is percent-encoded, so splitting at a +/// byte offset can never cut a character in half. +fn manifest_header(name: &str, value: &str) -> String { + let line = format!("{name}: {value}"); + debug_assert!(line.is_ascii()); + let mut rendered = String::with_capacity(line.len() + line.len() / 35 + 2); + let mut rest = line.as_str(); + let mut width = MANIFEST_LINE_LIMIT; + loop { + let (head, tail) = rest.split_at(rest.len().min(width)); + rendered.push_str(head); + rendered.push_str("\r\n"); + if tail.is_empty() { + return rendered; + } + rendered.push(' '); + rest = tail; + width = MANIFEST_LINE_LIMIT - 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jar_path() -> &'static Path { + Path::new("C:\\Temp\\lithe-run\\launch-1-0.classpath.jar") + } + + /// Mirrors a resolved Maven runtime classpath that exceeds the Windows cap. + fn classpath(entries: usize) -> String { + (0..entries) + .map(|index| { + format!( + "C:\\Users\\developer\\.m2\\repository\\org\\example\\artifact-{index}\\1.0.{index}\\artifact-{index}-1.0.{index}.jar" + ) + }) + .collect::>() + .join(";") + } + + fn request<'a>(arguments: &'a [String], version: Option) -> ClasspathJarRequest<'a> { + ClasspathJarRequest { + executable: "C:\\jdk8\\bin\\java.exe", + arguments, + working_directory: "C:\\work\\demo", + classpath_jar_path: jar_path(), + limit: None, + java_feature_version: version, + style: ClasspathStyle::Windows, + } + } + + /// Joins continuation lines back into logical headers. + fn class_path_urls(manifest: &str) -> Vec { + let logical = manifest.replace("\r\n ", ""); + logical + .split("\r\n") + .find_map(|line| line.strip_prefix("Class-Path: ")) + .expect("manifest has a Class-Path header") + .split(' ') + .map(str::to_string) + .collect() + } + + fn no_directories(_: &str) -> bool { + false + } + + /// Regression for #955: a large JDK 8 project failed in `CreateProcessW` + /// because argument files only exist from JDK 9 on. + #[test] + fn an_oversized_jdk8_classpath_moves_into_a_manifest_jar() { + let arguments = vec![ + "-Dfile.encoding=UTF-8".to_string(), + "-cp".to_string(), + classpath(500), + "com.example.Main".to_string(), + "--server.port=8080".to_string(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("an oversized JDK 8 classpath must be shortened"); + }; + assert_eq!( + shortened, + [ + "-Dfile.encoding=UTF-8", + "-cp", + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar", + "com.example.Main", + "--server.port=8080", + ] + ); + assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/Users/")); + assert!(manifest.ends_with("\r\n\r\n")); + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert_eq!( + urls[0], + "file:/C:/Users/developer/.m2/repository/org/example/artifact-0/1.0.0/artifact-0-1.0.0.jar" + ); + } + + #[test] + fn manifest_lines_never_exceed_72_bytes() { + let arguments = vec!["-cp".into(), classpath(500), "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + let body = manifest + .strip_suffix("\r\n\r\n") + .expect("terminated section"); + let lines: Vec<&str> = body.split("\r\n").collect(); + assert!(lines.len() > 100, "a long header must wrap"); + assert!(lines.iter().all(|line| line.len() <= MANIFEST_LINE_LIMIT)); + // Continuation lines start with the one marker space and fill the + // remaining 71 bytes, except the last one. The content after the + // marker may itself begin with the space that separates two URLs. + for line in &lines[2..lines.len() - 1] { + assert_eq!(line.len(), MANIFEST_LINE_LIMIT); + assert!(line.starts_with(' ')); + } + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert!(urls.iter().all(|url| url.starts_with("file:/C:/Users/"))); + // Every break is CRLF, the form the `jar` tool writes. + assert_eq!( + manifest.matches('\n').count(), + manifest.matches("\r\n").count() + ); + } + + #[test] + fn spaces_chinese_and_reserved_characters_are_percent_encoded() { + let entries = format!( + "C:\\Program Files\\示例 库\\a#b%c+d.jar;D:/mixed/slash.jar;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert!( + manifest.is_ascii(), + "manifest must not depend on a code page" + ); + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/C:/Program%20Files/%E7%A4%BA%E4%BE%8B%20%E5%BA%93/a%23b%25c%2Bd.jar" + ); + assert_eq!(urls[1], "file:/D:/mixed/slash.jar"); + } + + #[test] + fn directories_relative_unc_and_empty_entries_become_absolute_urls() { + let entries = format!( + "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;\\\\?\\C:\\long\\v.jar;\\\\?\\UNC\\server\\share\\w.jar;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let directories = ["C:\\work\\demo\\target\\classes", "C:\\work\\demo"]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), |path| { + directories.contains(&path) + }) + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!(urls[0], "file:/C:/work/demo/target/classes/"); + assert_eq!(urls[1], "file:/C:/work/shared/lib.jar"); + assert_eq!(urls[2], "file:/C:/tools/x.jar"); + assert_eq!(urls[3], "file:////server/share/dep.jar"); + // An empty element means the working directory to the launcher. + assert_eq!(urls[4], "file:/C:/work/demo/"); + // Verbatim paths from canonicalized tooling keep their real target. + assert_eq!(urls[5], "file:/C:/long/v.jar"); + assert_eq!(urls[6], "file:////server/share/w.jar"); + } + + #[test] + fn jdk9_unknown_short_and_non_java_launches_stay_direct() { + let long = vec!["-cp".into(), classpath(500), "Main".into()]; + for version in [None, Some(9), Some(21)] { + assert_eq!( + plan_classpath_jar_launch(request(&long, version), no_directories), + ClasspathJarPlan::Direct + ); + } + let short = vec!["-cp".into(), classpath(3), "Main".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&short, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + let relative = vec![ + "-cp".into(), + format!("classes;{}", classpath(500)), + "Main".into(), + ]; + let mut verbatim = request(&relative, Some(8)); + verbatim.working_directory = "\\\\?\\C:\\work\\demo"; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(verbatim, no_directories) + else { + panic!("a verbatim working directory still resolves relative entries"); + }; + assert_eq!(class_path_urls(&manifest)[0], "file:/C:/work/demo/classes"); + let mut node = request(&long, Some(8)); + node.executable = "node.exe"; + assert_eq!( + plan_classpath_jar_launch(node, no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn launches_whose_meaning_a_manifest_cannot_preserve_stay_direct() { + for (entries, target) in [ + (format!("lib\\*;{}", classpath(500)), "Main"), + (format!("C:lib\\a.jar;{}", classpath(500)), "Main"), + (format!("\\\\.\\pipe\\x;{}", classpath(500)), "Main"), + (classpath(500), "-jar"), + ] { + let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories), + ClasspathJarPlan::Direct, + "{target}" + ); + } + let without_classpath = vec!["Main".into(), classpath(500)]; + assert_eq!( + plan_classpath_jar_launch(request(&without_classpath, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn only_the_effective_classpath_before_the_main_class_is_replaced() { + let arguments = vec![ + "-cp".into(), + "ignored.jar".into(), + "-classpath".into(), + classpath(500), + "Main".into(), + "-cp".into(), + "program-value".into(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert_eq!(&shortened[..3], &arguments[..3]); + assert_eq!( + shortened[3], + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar" + ); + assert_eq!(&shortened[4..], &arguments[4..]); + assert!(!manifest.contains("program-value")); + assert!(!manifest.contains("ignored.jar")); + } + + #[test] + fn posix_paths_use_colon_separators() { + let entries = (0..800) + .map(|index| format!("/home/dev/.m2/repository/lib {index}/artifact-{index}.jar")) + .chain(["classes".to_string()]) + .collect::>() + .join(":"); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let mut posix = request(&arguments, Some(8)); + posix.executable = "/opt/jdk8/bin/java"; + posix.working_directory = "/work/demo"; + posix.style = ClasspathStyle::Posix; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(posix, |path| path == "/work/demo/classes") + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/home/dev/.m2/repository/lib%200/artifact-0.jar" + ); + assert_eq!(urls[800], "file:/work/demo/classes/"); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/commands.rs b/windows/tauri/crates/lithe-legacy-java/src/commands.rs new file mode 100644 index 000000000..1468d55a6 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/commands.rs @@ -0,0 +1,220 @@ +//! Answers the legacy Java command names with Core's request and response shape. +//! +//! The Windows frontend still sends `maven.*`, `java.*`, `spring.index` and +//! `mybatis.index` as ordinary Core requests. [`try_execute`] mirrors the part +//! of Core's dispatcher these commands go through, so the JSON a caller gets +//! back, including every error code and message, does not change. + +use crate::java_run_markers::java_run_markers; +use crate::java_source_definition::source_definition; +use crate::java_workspace::{ + java_workspace_policy, jdt_cache_retention, jdt_workspace_fingerprint, +}; +use crate::maven; +use crate::mybatis::mybatis_index; +use crate::spring::spring_index; +use lithe_core::{CancellationScope, CoreError, CoreRequest, CoreResponse, ErrorCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +/// Runs `request` when it names a legacy Java command. +/// +/// Returns `None` for anything else, including text that is not a request, so +/// the caller can pass the same text on to Core and get Core's own answer. +pub fn try_execute(request: &str) -> Option { + let parsed: CoreRequest = serde_json::from_str(request).ok()?; + let command = parsed.command.clone(); + let response = match command.as_str() { + "maven.scan" => respond(parsed, "Invalid Maven scan request", maven::scan), + "maven.launchPlan" => respond( + parsed, + "Invalid Maven launch-plan request", + maven::launch_plan, + ), + "maven.dependencyPlan" => respond( + parsed, + "Invalid Maven dependency-plan request", + maven::dependency_plan, + ), + "maven.dependencies" => respond( + parsed, + "Invalid Maven dependencies request", + maven::dependencies, + ), + "maven.diagnostics" => respond( + parsed, + "Invalid Maven diagnostics request", + maven::diagnostics, + ), + "maven.testResults" => respond( + parsed, + "Invalid Maven test-results request", + maven::test_results, + ), + "java.workspacePolicy" => respond( + parsed, + "Invalid Java workspace-policy request", + java_workspace_policy, + ), + "java.jdtCacheRetention" => respond( + parsed, + "Invalid JDT LS cache-retention request", + jdt_cache_retention, + ), + "java.jdtWorkspaceFingerprint" => respond( + parsed, + "Invalid JDT LS workspace-fingerprint request", + jdt_workspace_fingerprint, + ), + "java.sourceDefinition" => respond( + parsed, + "Invalid Java source definition request", + source_definition, + ), + "java.runMarkers" => respond(parsed, "Invalid Java Run-marker request", java_run_markers), + "spring.index" => respond(parsed, "Invalid Spring index request", spring_index), + "mybatis.index" => respond(parsed, "Invalid MyBatis index request", mybatis_index), + _ => return None, + }; + Some(response) +} + +/// Runs one command inside the request's cancellation scope and encodes the +/// answer. Cancellation is checked before and after the work, as Core does, so a +/// cancelled or expired request never reports success. +fn respond( + parsed: CoreRequest, + invalid_request: &str, + run: impl FnOnce(Request) -> Result, +) -> String +where + Request: DeserializeOwned, + Response: Serialize, +{ + let id = parsed.id.clone(); + let operation_id = parsed.operation_id.or_else(|| id.clone()); + let _scope = CancellationScope::begin(operation_id, parsed.timeout_milliseconds); + let result = lithe_core::check_cancellation() + .and_then(|()| execute(parsed.payload, invalid_request, run)) + .and_then(|data| lithe_core::check_cancellation().map(|()| data)); + let response = match result { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }; + encode(&response) +} + +fn execute( + payload: serde_json::Value, + invalid_request: &str, + run: impl FnOnce(Request) -> Result, +) -> Result +where + Request: DeserializeOwned, + Response: Serialize, +{ + let request = serde_json::from_value::(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, invalid_request).with_details(error.to_string()) + })?; + let data = run(request)?; + Ok(serde_json::to_value(data).expect("legacy Java response should encode")) +} + +/// Serializes like Core's `execute_json`, including its fallback failure. +fn encode(response: &CoreResponse) -> String { + serde_json::to_string(response).unwrap_or_else(|_| { + serde_json::to_string(&CoreResponse::failure( + None, + CoreError::new(ErrorCode::Unknown, "Could not encode core response"), + )) + .expect("fallback response should encode") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn run(request: Value) -> Option { + try_execute(&request.to_string()) + .map(|response| serde_json::from_str(&response).expect("response should be JSON")) + } + + #[test] + fn other_commands_and_other_text_are_left_to_core() { + assert!(run(json!({"id": "a", "command": "ping"})).is_none()); + assert!(run(json!({"id": "a", "command": "maven.unknown"})).is_none()); + assert!(try_execute("not json").is_none()); + assert!(try_execute(r#"{"id":"a"}"#).is_none()); + } + + #[test] + fn an_invalid_payload_keeps_cores_error_shape() { + let response = run(json!({ + "id": "scan-1", + "command": "maven.scan", + "payload": {"root": 7}, + })) + .expect("maven.scan is a legacy command"); + + assert_eq!(response["id"], "scan-1"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); + assert_eq!(response["error"]["message"], "Invalid Maven scan request"); + assert!(response["error"]["details"].is_string()); + assert!(response.get("data").is_none()); + } + + #[test] + fn a_successful_command_returns_its_data() { + let response = run(json!({ + "id": "policy-1", + "command": "java.workspacePolicy", + "payload": {"workspacePaths": [], "changedPaths": []}, + })) + .expect("java.workspacePolicy is a legacy command"); + + assert_eq!(response["id"], "policy-1"); + assert_eq!(response["ok"], true, "{response}"); + assert!(response["data"].is_object()); + } + + /// Core turns a success into a failure when the request was cancelled while + /// it ran, so a caller that gave up never receives stale data. + #[test] + fn a_cancellation_during_the_work_replaces_success_with_an_error() { + let operation = "legacy-java-cancelled"; + let parsed: CoreRequest = serde_json::from_value(json!({ + "id": "cancelled", + "operationId": operation, + "command": "maven.scan", + })) + .expect("request envelope"); + + let response = respond(parsed, "invalid", |_: Value| { + assert!(lithe_core::cancel_operation(operation)); + Ok(json!({"done": true})) + }); + + let response: Value = serde_json::from_str(&response).expect("response should be JSON"); + assert_eq!(response["id"], "cancelled"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "cancelled"); + } + + #[test] + fn requests_match_what_core_answers_for_the_shared_commands() { + let request = json!({ + "id": "policy-2", + "command": "java.workspacePolicy", + "payload": {"workspacePaths": ["a/pom.xml"], "changedPaths": ["a/pom.xml"]}, + }) + .to_string(); + + assert_eq!( + try_execute(&request).expect("legacy command"), + lithe_core::execute_json(&request) + ); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/contracts.rs b/windows/tauri/crates/lithe-legacy-java/src/contracts.rs new file mode 100644 index 000000000..777460a67 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/contracts.rs @@ -0,0 +1,369 @@ +//! Response models for the legacy Java commands. +//! +//! These are copies of the types in lithe-core's `protocol/contracts.rs`. Core +//! keeps them private to its crate, and the JSON they produce must stay +//! identical to what Core returns today so the Windows TypeScript needs no +//! change. + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Maven profile identity and its default activation state. +pub struct MavenProfileResponse { + pub id: String, + pub is_active_by_default: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One module in the declared Maven reactor hierarchy. +pub struct MavenModuleResponse { + pub relative_path: String, + pub group_id: Option, + pub artifact_id: String, + pub version: Option, + pub packaging: String, + pub source_roots: Vec, + pub modules: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Maven source root relative to its owning module. +pub struct MavenSourceRootResponse { + /// Module-relative path using `/` separators. + pub path: String, + /// Semantic source-set used by project and Java tooling views. + pub kind: MavenSourceRootKind, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +/// Maven source-root categories kept distinct for main, test, and generated code. +pub enum MavenSourceRootKind { + /// Production Java sources. + MainJava, + /// Production resource files. + MainResources, + /// Test Java sources. + TestJava, + /// Test resource files. + TestResources, + /// Generated production sources. + GeneratedMain, + /// Generated test sources. + GeneratedTest, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Maven reactor identity, modules, profiles, and wrapper availability. +pub struct MavenScanResponse { + pub relative_path: String, + pub group_id: Option, + pub artifact_id: String, + pub version: Option, + pub packaging: String, + pub source_roots: Vec, + pub modules: Vec, + pub profiles: Vec, + pub has_wrapper: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Platform-neutral executable reference returned by Maven launch planning. +pub struct MavenLaunchExecutableResponse { + pub toolchain: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministic Maven invocation consumed by native process adapters. +pub struct MavenLaunchPlanResponse { + pub version: u32, + pub executable: MavenLaunchExecutableResponse, + pub arguments: Vec, + pub working_directory: String, + pub configuration_fingerprint: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Resolution outcome reported by Maven for one dependency coordinate. +pub enum MavenDependencyResolutionResponse { + /// Maven selected this dependency in the effective tree. + Resolved, + /// Maven omitted this occurrence because the same dependency was already selected. + OmittedDuplicate, + /// Maven omitted this occurrence in favor of another version. + OmittedConflict, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One bounded Maven dependency node with recursively nested transitive children. +pub struct MavenDependencyResponse { + /// Reactor-relative module whose POM produced this dependency tree. + pub module_path: String, + pub group_id: String, + pub artifact_id: String, + pub version: String, + /// Maven artifact type such as `jar`, `war`, or `test-jar`. + pub r#type: String, + /// Optional Maven classifier such as `tests`. + pub classifier: Option, + pub scope: String, + pub resolution: MavenDependencyResolutionResponse, + /// Version Maven selected when this occurrence was omitted for conflict. + pub selected_version: Option, + /// Version before dependency management replaced it with `version`. + pub premanaged_version: Option, + /// Scope before dependency management replaced it with `scope`. + pub premanaged_scope: Option, + /// Declared scope before mediation widened it to `scope`. + pub original_scope: Option, + /// Wider scope from another path that mediation did not apply here. + pub ignored_scope: Option, + pub children: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministically ordered dependency tree for one Maven reactor module. +pub struct MavenDependenciesResponse { + /// Reactor-relative module path, using `.` for the reactor root. + pub module_path: String, + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One normalized issue parsed from Maven process output. +pub struct MavenDiagnosticResponse { + pub path: String, + pub line: usize, + pub column: Option, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Maven diagnostics in stable source order. +pub struct MavenDiagnosticsResponse { + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One failed or errored JUnit test reported by Maven Surefire/Failsafe. +pub struct MavenTestFailureResponse { + /// Provider-reported test name, usually `method(Class)` or `Class.method`. + pub name: String, + /// Either `failure` or `error`, matching the Surefire result section. + pub kind: String, + /// Short assertion or exception message when Maven printed one. + pub message: Option, + /// Workspace-relative source path when a stack frame resolves to a file. + pub path: Option, + /// One-based source line from the first matching stack frame. + pub line: Option, + /// UTF-16 column is not emitted by Surefire's text reporter. + pub column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Bounded, deterministic JUnit result summary parsed from Maven output. +pub struct MavenTestResultsResponse { + /// Total tests from the final Results summary or aggregated class summaries. + pub tests_run: usize, + /// Tests that failed an assertion. + pub failures: usize, + /// Tests that terminated with an error or exception. + pub errors: usize, + /// Tests skipped by assumptions, tags, or configuration. + pub skipped: usize, + /// Derived number of tests that completed successfully. + pub passed: usize, + /// True when the parsed summary contains no failures or errors. + pub success: bool, + /// Individual failures and errors in the order Maven reported them. + pub failure_details: Vec, + /// Per-method outcomes read from the run's XML reports, ordered by class + /// and method. Empty when the request did not ask for reports or the run + /// wrote none. + pub test_cases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Aggregated outcome of one test method in a Maven test run. +pub struct MavenTestCaseResponse { + /// Binary class name from the report, such as `demo.OrderTest$Refunds`. + pub class_name: String, + /// Java method name without parameters or invocation index. + pub method: String, + /// `passed`, `failed`, `error`, or `skipped`. With several invocations + /// (parameterized or repeated tests) the most severe one wins. + pub status: String, + /// First assertion or exception message of the failing invocation. + pub message: Option, + /// Number of `` entries aggregated into this method. + pub invocations: usize, + /// Sum of reported invocation times in milliseconds; absent if any time is missing or invalid. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_millis: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based UTF-16 location of a Java declaration. +pub struct JavaSourceDefinitionResponse { + pub line: usize, + pub utf16_column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Spring configuration property and its optional Java declaration. +pub struct SpringPropertyResponse { + pub name: String, + pub type_name: Option, + pub description: Option, + pub default_value: Option, + pub source_path: Option, + pub source_line: Option, + pub source_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One key/value occurrence from a Spring application configuration document. +pub struct SpringConfigurationValueResponse { + pub key: String, + pub value: String, + pub path: String, + pub line: usize, + pub column: usize, + pub profile: Option, + pub overrides_base_value: bool, + pub target_path: Option, + pub target_line: Option, + pub target_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java `@Value` reference to a Spring configuration property. +pub struct SpringPropertyReferenceResponse { + pub key: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Spring configuration problem projected onto one source location. +pub struct SpringDiagnosticResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Component or `@Bean` declaration available for dependency injection. +pub struct SpringBeanResponse { + pub id: String, + pub name: String, + pub type_name: String, + pub path: String, + pub line: usize, + pub column: usize, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Injection point and the bean declarations that satisfy it. +pub struct SpringInjectionResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub type_name: String, + pub qualifier: Option, + pub bean_ids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// HTTP endpoint declared by a Spring MVC controller method. +pub struct SpringEndpointResponse { + pub id: String, + pub http_methods: Vec, + pub route: String, + pub controller: String, + pub method: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete deterministic Spring semantic index for one workspace snapshot. +pub struct SpringIndexResponse { + pub properties: Vec, + pub values: Vec, + pub property_references: Vec, + pub diagnostics: Vec, + pub beans: Vec, + pub injections: Vec, + pub endpoints: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One MyBatis statement that has both a Java mapper method and an XML `id`. +pub struct MybatisStatementResponse { + /// Stable identity: namespace, statement id, XML path, and XML line. + pub id: String, + /// Fully qualified mapper type from the XML `namespace`. + pub namespace: String, + /// XML statement `id`, which matches the Java method name. + pub statement_id: String, + /// MyBatis statement kind: `select`, `insert`, `update`, or `delete`. + pub kind: String, + /// Workspace-relative Java mapper path. + pub java_path: String, + /// One-based line of the Java method name. + pub java_line: usize, + /// One-based UTF-16 column of the Java method name. + pub java_column: usize, + /// One-based line of the Java method signature terminator. + pub java_end_line: usize, + /// Exclusive one-based UTF-16 column after the Java method name. + pub java_end_column: usize, + /// Workspace-relative mapper XML path. + pub xml_path: String, + /// One-based line of the XML statement `id` value. + pub xml_line: usize, + /// One-based UTF-16 column of the XML statement `id` value. + pub xml_column: usize, + /// Exclusive one-based UTF-16 column after the XML statement `id` value. + pub xml_end_column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete deterministic MyBatis mapper/XML index for one workspace snapshot. +pub struct MybatisIndexResponse { + /// Paired Java methods and XML statements, ordered by namespace and id. + pub statements: Vec, +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs b/windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs new file mode 100644 index 000000000..11bc8f139 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs @@ -0,0 +1,483 @@ +//! Projects JDT facts and recorded test outcomes into editor Run markers. +//! +//! Both products show IDEA-style gutter markers: a Run marker beside each +//! launchable `main` method, a Run-all marker beside each test class, and a +//! Run marker beside each test method, the latter two switching to a passed or +//! failed icon once a run recorded an outcome. Which declarations qualify is +//! decided upstream by JDT (`javaMainMethods`) and the Java Test extension +//! (`javaTestItems`); outcomes come from canonical build-provider reports. This +//! module only combines those answers, so the two platforms cannot drift on +//! placement, labels, or how method outcomes roll up into a class outcome. +//! +//! Note: the design is recorded in .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md + +use lithe_core::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Java Test extension level of a test class item. +const TEST_LEVEL_CLASS: i64 = 5; +/// Java Test extension level of a test method item. +const TEST_LEVEL_METHOD: i64 = 6; +/// Upper bound on markers returned for one file. +const MAX_MARKERS: usize = 5_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Upstream answers for one open Java file. +pub struct JavaRunMarkersRequest { + /// `methods` from the file's `javaMainMethods` answer. + #[serde(default)] + pub main_methods: Vec, + /// `items` from the file's `javaTestItems` answer. Platforms pass an empty + /// list when they cannot run tests for this file. + #[serde(default)] + pub test_items: Vec, + /// Canonical report cases (`suite`, `case`, `status`); entries for other + /// classes are ignored. Legacy `className`/`method` remain accepted. + #[serde(default)] + pub test_cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One `javaMainMethods` entry. +pub struct MainMethodInput { + pub main_class: String, + #[serde(default)] + pub project_name: Option, + pub range: RangeInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One `javaTestItems` node, including its children. +pub struct TestItemInput { + pub id: String, + pub label: String, + pub full_name: String, + pub test_level: i64, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub children: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based UTF-16 range as returned by the upstream operations. +pub struct RangeInput { + pub start_line: i64, + /// Java Test ranges run to the end of the declaration body; Java Debug + /// main-method ranges cover only the method name. + #[serde(default)] + pub end_line: Option, +} + +impl RangeInput { + fn end_line(self) -> i64 { + self.end_line + .filter(|end| *end >= self.start_line) + .unwrap_or(self.start_line) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One canonical test case, with legacy field aliases for older consumers. +pub struct TestCaseInput { + #[serde(alias = "suite")] + pub class_name: String, + #[serde(alias = "case")] + pub method: String, + pub status: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +/// What a marker launches. The declaration order is the order of markers +/// sharing one line. +pub enum JavaRunMarkerKind { + /// A launchable `main` method. + Main, + /// A test class; running it runs every test the class contains. + TestClass, + /// One test method. + TestMethod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Last recorded outcome shown by a marker. +pub enum JavaRunMarkerStatus { + /// Not run yet, or no outcome was recorded; shows the plain Run icon. + None, + Passed, + /// A failure or an error. + Failed, + /// Every recorded invocation was skipped. + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One gutter marker. +pub struct JavaRunMarker { + /// Zero-based line of the declaration name. + pub line: i64, + /// Last zero-based line of the declaration as JDT reports it: the end of + /// the body for test classes and methods, the name line for `main`. + pub end_line: i64, + pub kind: JavaRunMarkerKind, + /// Target named in menus, such as `App.main()`, `OrderTest`, or + /// `OrderTest.creates`; platforms wrap it in their localized verbs. + pub label: String, + /// `main` launch target, matching a `javaEntrypoints` entry. + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_name: Option, + /// Fully qualified test class for test markers, as the Java Test + /// extension names it. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_class: Option, + /// Java method name for test method markers, without parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_method: Option, + /// Java Test extension item identity, for platforms that launch or debug + /// through the extension. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_item_id: Option, + pub status: JavaRunMarkerStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Markers for one file, ordered by line and then [`JavaRunMarkerKind`]. +pub struct JavaRunMarkersResponse { + pub markers: Vec, +} + +/// Builds the markers for one file (`java.runMarkers`). +pub fn java_run_markers( + request: JavaRunMarkersRequest, +) -> Result { + let outcomes = recorded_outcomes(&request.test_cases); + let mut markers = Vec::new(); + for method in &request.main_methods { + let main_class = method.main_class.trim(); + if main_class.is_empty() || method.range.start_line < 0 { + continue; + } + markers.push(JavaRunMarker { + line: method.range.start_line, + end_line: method.range.end_line(), + kind: JavaRunMarkerKind::Main, + label: format!("{}.main()", simple_class_name(main_class)), + main_class: Some(main_class.to_string()), + project_name: method + .project_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + test_class: None, + test_method: None, + test_item_id: None, + status: JavaRunMarkerStatus::None, + }); + } + for item in &request.test_items { + collect_test_markers(item, None, &outcomes, &mut markers)?; + } + if markers.len() > MAX_MARKERS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many Run markers for one file", + ) + .with_details(format!("maximumMarkers={MAX_MARKERS}"))); + } + markers.sort_by(|left, right| { + (left.line, left.kind, &left.label).cmp(&(right.line, right.kind, &right.label)) + }); + markers.dedup_by(|left, right| { + left.line == right.line && left.kind == right.kind && left.label == right.label + }); + Ok(JavaRunMarkersResponse { markers }) +} + +/// Outcomes keyed by `(class, method)`, with `$` nested-class separators +/// folded to `.` so report binary names match Java Test's source names. +fn recorded_outcomes(cases: &[TestCaseInput]) -> HashMap<(String, String), JavaRunMarkerStatus> { + let mut outcomes = HashMap::new(); + for case in cases { + let status = match case.status.as_str() { + "passed" => JavaRunMarkerStatus::Passed, + "failed" | "error" => JavaRunMarkerStatus::Failed, + "skipped" => JavaRunMarkerStatus::Skipped, + _ => continue, + }; + outcomes.insert( + ( + source_class_name(&case.class_name), + case.method.trim().to_string(), + ), + status, + ); + } + outcomes +} + +fn collect_test_markers( + item: &TestItemInput, + owning_class: Option<&str>, + outcomes: &HashMap<(String, String), JavaRunMarkerStatus>, + markers: &mut Vec, +) -> Result, CoreError> { + lithe_core::check_cancellation()?; + match item.test_level { + TEST_LEVEL_CLASS => { + let class_name = item.full_name.trim(); + let mut statuses = Vec::new(); + for child in &item.children { + statuses.extend(collect_test_markers( + child, + Some(class_name), + outcomes, + markers, + )?); + } + if let Some(range) = item.range.filter(|range| range.start_line >= 0) { + markers.push(JavaRunMarker { + line: range.start_line, + end_line: range.end_line(), + kind: JavaRunMarkerKind::TestClass, + label: simple_class_name(class_name).to_string(), + main_class: None, + project_name: None, + test_class: Some(class_name.to_string()), + test_method: None, + test_item_id: Some(item.id.clone()), + status: class_status(&statuses), + }); + } + Ok(statuses) + } + TEST_LEVEL_METHOD => { + let Some(class_name) = owning_class.or_else(|| declaring_class(&item.full_name)) else { + return Ok(Vec::new()); + }; + let Some(method) = method_name(item) else { + return Ok(Vec::new()); + }; + let status = outcomes + .get(&(source_class_name(class_name), method.clone())) + .copied() + .unwrap_or(JavaRunMarkerStatus::None); + if let Some(range) = item.range.filter(|range| range.start_line >= 0) { + markers.push(JavaRunMarker { + line: range.start_line, + end_line: range.end_line(), + kind: JavaRunMarkerKind::TestMethod, + label: format!("{}.{}", simple_class_name(class_name), method), + main_class: None, + project_name: None, + test_class: Some(class_name.to_string()), + test_method: Some(method), + test_item_id: Some(item.id.clone()), + status, + }); + } + Ok(vec![status]) + } + // Package, project, and workspace levels only group classes. + _ => { + let mut statuses = Vec::new(); + for child in &item.children { + statuses.extend(collect_test_markers( + child, + owning_class, + outcomes, + markers, + )?); + } + Ok(statuses) + } + } +} + +/// A class shows failed when any recorded method failed and passed when at +/// least one passed and none failed; unrun methods do not hide a failure. +fn class_status(statuses: &[JavaRunMarkerStatus]) -> JavaRunMarkerStatus { + if statuses.contains(&JavaRunMarkerStatus::Failed) { + JavaRunMarkerStatus::Failed + } else if statuses.contains(&JavaRunMarkerStatus::Passed) { + JavaRunMarkerStatus::Passed + } else if statuses.contains(&JavaRunMarkerStatus::Skipped) { + JavaRunMarkerStatus::Skipped + } else { + JavaRunMarkerStatus::None + } +} + +/// Method name without its parameter list, from `Class#method(Args)` or the label. +fn method_name(item: &TestItemInput) -> Option { + let semantic = item + .full_name + .rsplit_once('#') + .map(|(_, method)| method) + .unwrap_or(&item.label); + let name = semantic.split('(').next().unwrap_or_default().trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +fn declaring_class(full_name: &str) -> Option<&str> { + full_name + .split_once('#') + .map(|(class_name, _)| class_name.trim()) + .filter(|class_name| !class_name.is_empty()) +} + +fn source_class_name(class_name: &str) -> String { + class_name.trim().replace('$', ".") +} + +fn simple_class_name(class_name: &str) -> &str { + let without_module = class_name + .rsplit_once('/') + .map_or(class_name, |(_, name)| name); + without_module + .rsplit(['.', '$']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(without_module) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn markers(request: serde_json::Value) -> Vec { + let request = serde_json::from_value(request).expect("valid request"); + let response = java_run_markers(request).expect("markers"); + serde_json::to_value(response).expect("encodes")["markers"] + .as_array() + .cloned() + .unwrap_or_default() + } + + fn range(line: i64) -> serde_json::Value { + json!({ "startLine": line, "startUtf16Column": 0, "endLine": line, "endUtf16Column": 4 }) + } + + #[test] + fn main_methods_become_run_markers_named_like_idea() { + let markers = markers(json!({ + "mainMethods": [ + { "mainClass": "demo.App", "projectName": "app", "range": range(4) }, + { "mainClass": "app/demo.Modular", "range": range(9) } + ] + })); + assert_eq!( + markers, + vec![ + json!({ "line": 4, "endLine": 4, "kind": "main", "label": "App.main()", "mainClass": "demo.App", "projectName": "app", "status": "none" }), + json!({ "line": 9, "endLine": 9, "kind": "main", "label": "Modular.main()", "mainClass": "app/demo.Modular", "status": "none" }), + ] + ); + } + + #[test] + fn test_classes_and_methods_carry_their_recorded_outcomes() { + let markers = markers(json!({ + "testItems": [{ + "id": "app@demo.OrderTest", "label": "OrderTest", "fullName": "demo.OrderTest", + "testLevel": 5, "range": range(3), + "children": [ + { "id": "m1", "label": "creates()", "fullName": "demo.OrderTest#creates()", "testLevel": 6, "range": range(5), "children": [] }, + { "id": "m2", "label": "priced(int)", "fullName": "demo.OrderTest#priced(int)", "testLevel": 6, "range": range(8), "children": [] }, + { "id": "m3", "label": "later()", "fullName": "demo.OrderTest#later()", "testLevel": 6, "range": range(11), "children": [] } + ] + }], + "testCases": [ + { "className": "demo.OrderTest", "method": "creates", "status": "passed" }, + { "className": "demo.OrderTest", "method": "priced", "status": "error" }, + { "className": "demo.OtherTest", "method": "later", "status": "passed" } + ] + })); + let summary = markers + .iter() + .map(|marker| { + ( + marker["line"].as_i64().unwrap(), + marker["kind"].as_str().unwrap().to_string(), + marker["label"].as_str().unwrap().to_string(), + marker["status"].as_str().unwrap().to_string(), + ) + }) + .collect::>(); + assert_eq!( + summary, + vec![ + (3, "testClass".into(), "OrderTest".into(), "failed".into()), + ( + 5, + "testMethod".into(), + "OrderTest.creates".into(), + "passed".into() + ), + ( + 8, + "testMethod".into(), + "OrderTest.priced".into(), + "failed".into() + ), + ( + 11, + "testMethod".into(), + "OrderTest.later".into(), + "none".into() + ), + ] + ); + assert_eq!(markers[2]["testMethod"], json!("priced")); + assert_eq!(markers[2]["testClass"], json!("demo.OrderTest")); + assert_eq!(markers[2]["testItemId"], json!("m2")); + } + + // Java Test nests inner test classes under their outer class and names + // them by binary name (`Outer$Inner`), as JDT LS 1.61.0 does for + // `@Nested`; source spelling (`Outer.Inner`) must match reports too. + #[test] + fn nested_class_outcomes_match_either_spelling() { + let markers = markers(json!({ + "testItems": [{ + "id": "outer", "label": "OrderTest", "fullName": "demo.OrderTest", "testLevel": 5, "range": range(2), + "children": [{ + "id": "inner", "label": "Refunds", "fullName": "demo.OrderTest$Refunds", "testLevel": 5, "range": range(4), + "children": [{ "id": "m", "label": "refunds()", "fullName": "demo.OrderTest$Refunds#refunds()", "testLevel": 6, "range": range(5), "children": [] }] + }] + }], + "testCases": [{ "className": "demo.OrderTest.Refunds", "method": "refunds", "status": "passed" }] + })); + assert_eq!( + markers + .iter() + .map(|marker| marker["status"].as_str().unwrap()) + .collect::>(), + vec!["passed", "passed", "passed"] + ); + assert_eq!(markers[1]["label"], json!("Refunds")); + } + + #[test] + fn items_without_a_source_range_get_no_marker() { + let markers = markers(json!({ + "testItems": [{ "id": "c", "label": "InheritedSuite", "fullName": "demo.InheritedSuite", "testLevel": 5, "children": [ + { "id": "m", "label": "inherited()", "fullName": "demo.BaseTests#inherited()", "testLevel": 6, "children": [] } + ] }] + })); + assert!(markers.is_empty()); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_selection.rs b/windows/tauri/crates/lithe-legacy-java/src/java_selection.rs new file mode 100644 index 000000000..55489bc06 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/java_selection.rs @@ -0,0 +1,237 @@ +//! Selects an automatic project JDK from platform-probed candidates. +//! Project requirements remain owned by the existing run-configuration document. +//! +//! Copied from lithe-core's `execution/java_selection.rs`, which macOS still +//! calls through `runConfig.selectJava`. Requirement reading and version +//! comparison stay in Core and are imported, so both hosts apply one policy. + +use lithe_core::execution::{ + java_feature_version_parts, read_requirements, validate_sidecar_version, version_parts, + version_satisfies, ToolchainRequirementsDocument, +}; +use lithe_core::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A probed candidate; `id` is opaque and is never persisted or opened by Core. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelectionCandidate { + /// Opaque machine-local identity supplied by the host. + pub id: String, + /// Version reported by the Java executable; empty means unknown. + pub version: String, + /// Lower values win: JAVA_HOME, PATH, installations, project-managed JDK. + pub priority: u32, +} + +/// Platform candidates and the workspace whose existing requirements apply. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelectionRequest { + /// Open workspace; absent means no project requirements. + pub root: Option, + /// Host-read snapshot for UI-safe selection; mutually exclusive with `root`. + /// The same document validation and selection policy apply to both sources. + pub requirements: Option, + /// Usable executable homes probed by the platform. + pub candidates: Vec, + /// The platform's unconstrained choice; used when requirements are absent + /// or no candidate satisfies them. This keeps fallback behavior explicit. + pub fallback_id: Option, +} + +/// Selected candidate and an actionable warning when no compatible JDK exists. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelection { + /// Selected identity, or absent when the host found no usable JDK. + pub id: Option, + /// No candidate satisfies the minimum; the fallback remains usable. + pub warning: Option, +} + +/// Orders equivalent sources numerically, including legacy Java `1.8` versions. +/// Stable IDs break equal-version ties independently of filesystem enumeration. +pub fn compare_java_candidates( + left: &JavaSelectionCandidate, + right: &JavaSelectionCandidate, +) -> std::cmp::Ordering { + left.priority + .cmp(&right.priority) + .then_with(|| { + java_feature_version_parts(version_parts(&right.version)) + .cmp(&java_feature_version_parts(version_parts(&left.version))) + }) + .then_with(|| left.id.cmp(&right.id)) +} + +/// Reuses Core's requirement reader and version semantics without generating +/// requirements, probing executables, or changing an explicit user selection. +pub fn select_java(request: JavaSelectionRequest) -> Result { + if request.root.is_some() && request.requirements.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java selection must use either a workspace or a requirements snapshot", + )); + } + let document = request.requirements.or(request + .root + .as_deref() + .map(read_requirements) + .transpose()? + .flatten()); + if let Some(document) = &document { + validate_sidecar_version(document.version)?; + } + let minimum = document + .as_ref() + .and_then(|doc| doc.toolchains.get("project-jdk")) + .filter(|requirement| requirement.kind == "java") + .and_then(|requirement| requirement.minimum_version.as_deref()); + Ok(select_java_candidates( + &request.candidates, + request.fallback_id.as_deref(), + minimum, + )) +} + +/// Java feature version (`8` for `1.8.0_402`, `21` for `21.0.4`); `None` when unknown. +fn java_feature(version: &str) -> Option { + java_feature_version_parts(version_parts(version)) + .first() + .copied() +} + +/// Applies only the project's declared Java version; explicit paths and vendor +/// preferences remain the responsibility of existing launch validation and diagnostics. +/// +/// A JDK of exactly the declared feature version wins over a newer one, even from a +/// higher-priority source such as `JAVA_HOME`: a Java 8 project must not run on the +/// JDK 21 a shell exports. Without one, the oldest newer JDK is the closest match. +/// Source priority only breaks ties within the same feature version. +/// +/// The policy follows OpenIDEA's `pick_jdk` (`crates/ide-sdk/src/java.rs`, +/// , Apache-2.0); see the repository `NOTICE`. +pub fn select_java_candidates( + candidates: &[JavaSelectionCandidate], + fallback_id: Option<&str>, + minimum: Option<&str>, +) -> JavaSelection { + let fallback = candidates + .iter() + .find(|candidate| Some(candidate.id.as_str()) == fallback_id) + .or_else(|| { + candidates + .iter() + .min_by(|a, b| compare_java_candidates(a, b)) + }); + let Some(minimum) = minimum else { + return JavaSelection { + id: fallback.map(|candidate| candidate.id.clone()), + warning: None, + }; + }; + let required = java_feature(minimum); + let distance = |candidate: &JavaSelectionCandidate| { + java_feature(&candidate.version) + .zip(required) + .map(|(feature, required)| feature.saturating_sub(required)) + }; + let matching = candidates + .iter() + .filter(|candidate| version_satisfies("java", &candidate.version, minimum, true)) + .min_by(|a, b| { + distance(a) + .cmp(&distance(b)) + .then_with(|| compare_java_candidates(a, b)) + }); + JavaSelection { + id: matching.or(fallback).map(|candidate| candidate.id.clone()), + warning: matching.is_none().then(|| format!("No installed JDK satisfies Java {minimum} or newer. Install a compatible JDK or choose one in project settings.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + /// The shared fixture also drives Core's `runConfig.selectJava` test, so a + /// drift between the two selection copies fails on whichever side changed. + #[test] + fn selection_follows_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/run-configuration/automatic-java-selection.json" + )) + .expect("selection fixture should be valid JSON"); + for case in fixture["cases"].as_array().expect("cases") { + let name = case["name"].as_str().expect("case name"); + let requirements = json!({"version": 1, "toolchains": {"project-jdk": { + "type": "java", "minimumVersion": case["minimum"] + }}}); + // Reverse enumeration order too: tie-breaking is not filesystem order. + for reverse in [false, true] { + let mut candidates = case["candidates"].as_array().expect("candidates").clone(); + if reverse { + candidates.reverse(); + } + let request: JavaSelectionRequest = serde_json::from_value(json!({ + "requirements": requirements, + "candidates": candidates, + "fallbackId": case["fallbackId"], + })) + .expect("selection request"); + + let selection = select_java(request).expect("selection"); + + assert_eq!( + json!(selection.id), + fixture["expected"][name]["id"], + "{name}" + ); + assert_eq!( + selection.warning.is_some(), + fixture["expected"][name]["warning"].as_bool().unwrap(), + "{name}" + ); + } + } + } + + #[test] + fn ambiguous_or_invalid_snapshots_are_rejected() { + let ambiguous = JavaSelectionRequest { + root: Some(PathBuf::from("/unused")), + requirements: Some( + serde_json::from_value(json!({"version": 1, "toolchains": {}})).unwrap(), + ), + candidates: Vec::new(), + fallback_id: None, + }; + assert_eq!( + json!(select_java(ambiguous).unwrap_err().code), + "invalid_request" + ); + + let unsupported = JavaSelectionRequest { + root: None, + requirements: Some( + serde_json::from_value(json!({"version": 999, "toolchains": {}})).unwrap(), + ), + candidates: Vec::new(), + fallback_id: None, + }; + assert_eq!( + json!(select_java(unsupported).unwrap_err().code), + "not_supported" + ); + + // A snapshot without a version fails to deserialize, as in Core. + assert!(serde_json::from_value::(json!({ + "requirements": {"toolchains": {}}, + "candidates": [], + })) + .is_err()); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs b/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs new file mode 100644 index 000000000..ca994ed21 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs @@ -0,0 +1,364 @@ +//! Locates a Java type, method, or field declaration inside one source text. +//! +//! Copied from lithe-core's `languages/java.rs` (`java.sourceDefinition`). Core +//! keeps its own copy for macOS; this one serves the Windows host until the Java +//! plugin backend replaces both. + +use crate::contracts::JavaSourceDefinitionResponse; +use lithe_core::{CoreError, ErrorCode}; +use regex::Regex; +use serde::Deserialize; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Source lookup for a type, method, or field declaration. +pub struct JavaSourceDefinitionRequest { + pub source: String, + pub declaration_name: String, + #[serde(default)] + pub member_name: Option, + /// Only a real member declaration counts, never a call or an assignment. + #[serde(default)] + pub declarations_only: bool, + /// Where the member is used. Resolves it from that scope instead of as a + /// member of `declaration_name`, and finds nothing when syntax cannot tell. + #[serde(default)] + pub reference: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based position of the first character of a member reference. +pub struct JavaSourceReference { + pub line: usize, + pub utf16_column: usize, +} + +/// Finds a type or member declaration in one Java source document. +pub fn source_definition( + request: JavaSourceDefinitionRequest, +) -> Result, CoreError> { + let lines = request.source.split('\n').collect::>(); + if let Some(member) = request.member_name { + let declared = match &request.reference { + Some(reference) => referenced_member(&request.source, &member, reference), + None => member_declaration(&request.source, &request.declaration_name, &member), + }; + if let Some((line, utf16_column)) = declared { + return Ok(Some(JavaSourceDefinitionResponse { line, utf16_column })); + } + if request.declarations_only { + return Ok(None); + } + let method = + Regex::new(&format!(r"\b{}\s*\(", regex::escape(&member))).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java member") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = method.find(line) { + let prefix = line[..found.start()].trim(); + if !prefix.starts_with('*') + && !prefix.starts_with("//") + && !prefix.contains('#') + && !prefix.ends_with('.') + { + return Ok(Some(JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, found.start()), + })); + } + } + } + let field = + Regex::new(&format!(r"\b{}\s*(?:=|;)", regex::escape(&member))).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java field") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = field.find(line) { + return Ok(Some(JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, found.start()), + })); + } + } + } + + let declaration = Regex::new(&format!( + r"\b(?:class|interface|enum|record)\s+{}\b", + regex::escape(&request.declaration_name) + )) + .map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java declaration") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = declaration.find(line) { + let offset = line[found.start()..found.end()] + .find(&request.declaration_name) + .map(|value| found.start() + value) + .unwrap_or(found.start()); + return Ok(Some(JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, offset), + })); + } + } + Ok(None) +} + +fn parse_java(source: &str) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .ok()?; + parser.parse(source, None) +} + +/// Zero-based line and UTF-16 column of a declared name. +fn declaration_position(source: &str, declared: tree_sitter::Node) -> Option<(usize, usize)> { + let point = declared.start_position(); + let line = source.split('\n').nth(point.row)?; + Some((point.row, utf16_column(line, point.column))) +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum MemberKind { + Method, + Field, +} + +fn is_type_body(node: tree_sitter::Node) -> bool { + matches!( + node.kind(), + "class_body" | "interface_body" | "enum_body" | "annotation_type_body" + ) +} + +fn has_name(node: tree_sitter::Node, source: &str, name: &str) -> bool { + node.child_by_field_name("name") + .is_some_and(|declared| declared.utf8_text(source.as_bytes()) == Ok(name)) +} + +/// Finds a member declared directly in a type body, not in a type nested inside it. +/// +/// `kind` narrows the search because Java keeps methods and fields in separate +/// namespaces; `None` accepts either. +fn direct_member<'tree>( + body: tree_sitter::Node<'tree>, + name: &str, + kind: Option, + source: &str, +) -> Option> { + let accepts = |wanted: MemberKind| kind.is_none_or(|kind| kind == wanted); + let named = |node: tree_sitter::Node<'tree>| { + has_name(node, source, name) + .then(|| node.child_by_field_name("name")) + .flatten() + }; + // A record component is a field the body can use without declaring it. + if let Some(components) = body + .parent() + .filter(|parent| parent.kind() == "record_declaration" && accepts(MemberKind::Field)) + .and_then(|parent| parent.child_by_field_name("parameters")) + { + let mut cursor = components.walk(); + let component = components + .named_children(&mut cursor) + .find_map(|component| named(component)); + if component.is_some() { + return component; + } + } + let mut cursor = body.walk(); + for member in body.named_children(&mut cursor) { + let declared = match member.kind() { + "method_declaration" if accepts(MemberKind::Method) => named(member), + "enum_constant" | "annotation_type_element_declaration" + if accepts(MemberKind::Field) => + { + named(member) + } + "field_declaration" | "constant_declaration" if accepts(MemberKind::Field) => { + let mut declarators = member.walk(); + let declared = member + .children_by_field_name("declarator", &mut declarators) + .find_map(|declarator| named(declarator)); + declared + } + "enum_body_declarations" => direct_member(member, name, kind, source), + _ => None, + }; + if declared.is_some() { + return declared; + } + } + None +} + +/// Finds a method, field, or enum constant that the type `type_name` declares itself. +fn member_declaration(source: &str, type_name: &str, name: &str) -> Option<(usize, usize)> { + let tree = parse_java(source)?; + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if let Some(body) = node + .child_by_field_name("body") + .filter(|body| is_type_body(*body) && has_name(node, source, type_name)) + { + let declared = direct_member(body, name, None, source)?; + return declaration_position(source, declared); + } + // Reversed so the stack visits declarations in source order. + let mut cursor = node.walk(); + let children: Vec<_> = node.children(&mut cursor).collect(); + pending.extend(children.into_iter().rev()); + } + None +} + +/// Whether a type may inherit a member, which syntax cannot see. +fn may_inherit(declaration: tree_sitter::Node) -> bool { + let mut cursor = declaration.walk(); + let extends_interfaces = declaration + .children(&mut cursor) + .any(|child| child.kind() == "extends_interfaces"); + extends_interfaces + || matches!( + declaration.kind(), + "object_creation_expression" | "enum_constant" + ) + || declaration.child_by_field_name("superclass").is_some() + || declaration.child_by_field_name("interfaces").is_some() +} + +/// Whether a member's code declares a local variable or parameter called `name`. +/// +/// This ignores block scope on purpose. A same-named local elsewhere in the +/// member makes the answer uncertain, and an uncertain name stays unanswered. +fn declares_local(member: tree_sitter::Node, name: &str, source: &str) -> bool { + let mut pending = vec![member]; + while let Some(node) = pending.pop() { + let declares = match node.kind() { + "variable_declarator" => { + !matches!( + node.parent().map(|parent| parent.kind()), + Some("field_declaration" | "constant_declaration") + ) && has_name(node, source, name) + } + "formal_parameter" + | "catch_formal_parameter" + | "enhanced_for_statement" + | "resource" + | "instanceof_expression" => has_name(node, source, name), + "inferred_parameters" + | "lambda_expression" + | "type_pattern" + | "record_pattern_component" => { + let mut cursor = node.walk(); + let declares = node + .named_children(&mut cursor) + .filter(|child| child.kind() == "identifier") + .any(|child| child.utf8_text(source.as_bytes()) == Ok(name)); + declares + } + _ => false, + }; + if declares { + return true; + } + let mut cursor = node.walk(); + pending.extend( + node.named_children(&mut cursor) + .filter(|child| !is_type_body(*child)), + ); + } + false +} + +/// Resolves a member reference to the declaration its scope binds it to. +/// +/// Only a plain name or a `this.` access qualifies: any other receiver has a +/// type that syntax cannot resolve. The search starts in the innermost type and +/// moves outward, stopping at a type that may inherit the name and, for a plain +/// field name, at a local that may shadow it. +fn referenced_member( + source: &str, + name: &str, + reference: &JavaSourceReference, +) -> Option<(usize, usize)> { + let tree = parse_java(source)?; + let line = source.split('\n').nth(reference.line)?; + let mut utf16_offset = 0; + let byte = line + .char_indices() + .find(|(_, character)| { + let reached = utf16_offset >= reference.utf16_column; + utf16_offset += character.len_utf16(); + reached + })? + .0; + let start = tree_sitter::Point::new(reference.line, byte); + let end = tree_sitter::Point::new(reference.line, byte + 1); + let identifier = tree.root_node().descendant_for_point_range(start, end)?; + if identifier.kind() != "identifier" || identifier.utf8_text(source.as_bytes()) != Ok(name) { + return None; + } + + let parent = identifier.parent()?; + let receiver = match parent.kind() { + "method_invocation" if parent.child_by_field_name("name") == Some(identifier) => { + parent.child_by_field_name("object") + } + "field_access" if parent.child_by_field_name("field") == Some(identifier) => { + parent.child_by_field_name("object") + } + "method_reference" | "element_value_pair" | "labeled_statement" | "break_statement" + | "continue_statement" => return None, + _ => None, + }; + if receiver.is_some_and(|receiver| receiver.kind() != "this") { + return None; + } + let is_call = parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(identifier); + let kind = if is_call { + MemberKind::Method + } else { + MemberKind::Field + }; + + let mut inner = identifier; + while let Some(scope) = inner.parent() { + if is_type_body(scope) { + if kind == MemberKind::Field + && receiver.is_none() + && declares_local(inner, name, source) + { + return None; + } + if let Some(declared) = direct_member(scope, name, Some(kind), source) { + return declaration_position(source, declared); + } + if receiver.is_some() || scope.parent().is_none_or(may_inherit) { + return None; + } + } + inner = scope; + } + None +} + +fn utf16_column(line: &str, byte: usize) -> usize { + line[..byte.min(line.len())].encode_utf16().count() +} + +fn line_number(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())] + .bytes() + .filter(|value| *value == b'\n') + .count() +} + +fn utf16_offset(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())].encode_utf16().count() +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs b/windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs new file mode 100644 index 000000000..7f7f1b6f1 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs @@ -0,0 +1,537 @@ +//! Deterministic Java workspace discovery and change classification shared by hosts. + +use lithe_core::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +const JDT_CACHE_RETENTION_DAYS: u64 = 30; +const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + +/// Directory names that never contain the opened project's own Java modules. +pub(crate) const IGNORED_DIRECTORIES: &[&str] = &[ + ".git", + ".gradle", + ".idea", + ".lithe", + ".worktree", + ".worktrees", + "build", + "dist", + "node_modules", + "out", + "target", + "vendor", +]; + +/// Directory depth at which a build descriptor or Java source still describes the +/// opened project rather than sample, fixture, or vendored content. +/// +/// A real project declares its build at the root, or one or two levels down in a +/// monorepo. A descriptor buried deeper almost always belongs to test data checked +/// into a repository of some other ecosystem. +const MAX_ACTIVATION_DEPTH: usize = 2; + +const BUILD_FILE_NAMES: &[&str] = &[ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradlew", + "gradlew.bat", + "mvnw", + "mvnw.cmd", + "pom.xml", + "settings.gradle", + "settings.gradle.kts", +]; + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Workspace-relative paths used to decide Java activation and refresh policy. +pub(crate) struct JavaWorkspacePolicyRequest { + /// Current files discovered by the platform workspace adapter. + #[serde(default)] + pub workspace_paths: Vec, + /// Files added, changed, or removed since the previous observation. + #[serde(default)] + pub changed_paths: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Shared Java workspace activation and change plan. +pub(crate) struct JavaWorkspacePolicyResponse { + /// Whether the workspace is a Java project that JDT LS should be started for. + /// + /// Requires a Java source *and* evidence that Java is what the workspace is + /// for: a build descriptor near the root, or a Java source near the root for + /// projects that have no build system at all. A lone Java file deep inside a + /// repository of another ecosystem does not qualify. + pub should_start: bool, + /// Deterministically selected Java source used only to attach the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub representative_java_path: Option, + /// Deterministically ordered classifications for the supplied changes. + pub changes: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One normalized changed path and the action category it belongs to. +pub(crate) struct JavaWorkspaceChange { + /// Normalized workspace-relative path with `/` separators. + pub path: String, + /// Shared semantic category consumed by platform workflow adapters. + pub kind: JavaWorkspaceChangeKind, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java workspace reaction required for one changed path. +pub(crate) enum JavaWorkspaceChangeKind { + /// An ignored output, dependency, metadata, or linked-worktree path. + Ignored, + /// A Java document synchronized through versioned LSP document events. + Source, + /// Maven or Gradle structure that requires a debounced project refresh. + BuildConfiguration, + /// A workspace file that does not affect the Java project model. + Other, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Filesystem metadata used to select expired JDT LS workspace directories. +pub(crate) struct JdtCacheRetentionRequest { + /// Unix timestamp supplied by the platform clock. + pub now_unix_seconds: u64, + /// Workspace key currently owned by a running session and never removable. + #[serde(default)] + pub active_workspace_key: Option, + /// Candidate directories discovered by the platform filesystem adapter. + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Modification metadata for one candidate JDT LS workspace directory. +pub(crate) struct JdtCacheEntry { + /// SHA-256 workspace directory name produced by `lsp.jdtWorkspaceKey`. + pub workspace_key: String, + /// Last modification time reported by the host filesystem. + pub last_modified_unix_seconds: u64, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministically ordered JDT LS directories that a platform may remove. +pub(crate) struct JdtCacheRetentionResponse { + /// Stable product retention period exposed for diagnostics and tests. + pub retention_days: u64, + /// Valid inactive workspace keys older than the retention period. + pub expired_workspace_keys: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Platform-observed inputs used to derive one portable JDT LS workspace fingerprint. +pub(crate) struct JdtWorkspaceFingerprintRequest { + /// Root build descriptors observed without recursive filesystem traversal. + #[serde(default)] + pub build_files: Vec, + /// Names of direct child directories that contain a Maven descriptor. + #[serde(default)] + pub direct_maven_modules: Vec, + /// `distributionUrl` of the root `gradle/wrapper/gradle-wrapper.properties`, + /// when present. Unlike edits to build scripts, which JDT LS notices on + /// restart, a Gradle version change is invisible to its import check, so + /// the version itself selects a fresh state directory. + #[serde(default)] + pub gradle_wrapper_distribution: Option, + /// JDT LS version resolved by the platform package adapter. + pub jdtls_version: String, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One root-level Java build descriptor that exists in the workspace. +/// +/// Only its presence counts. Edits to a descriptor are JDT LS's to detect: on a +/// restart it compares each POM and Gradle file's timestamp with the state it +/// saved and updates only the projects that changed, and while running it +/// receives the change through `workspace/didChangeWatchedFiles`. Mixing the +/// timestamp into the state-directory name would discard the whole index after +/// any touch of the file, a `git checkout` included. +pub(crate) struct JdtBuildFileObservation { + /// Workspace-relative build descriptor path with `/` separators. + pub path: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable opaque fingerprint consumed by JDT LS workspace-key derivation. +pub(crate) struct JdtWorkspaceFingerprintResponse { + /// Opaque deterministic value that platforms pass back without parsing. + pub workspace_fingerprint: String, +} + +/// Builds the cross-platform Java activation and change-classification plan. +pub(crate) fn java_workspace_policy( + request: JavaWorkspacePolicyRequest, +) -> Result { + let workspace_paths = normalize_paths(request.workspace_paths)?; + let changed_paths = normalize_paths(request.changed_paths)?; + let representative_java_path = workspace_paths + .iter() + .find(|path| !is_ignored(path) && is_java_source(path)) + .cloned(); + // A stray `.java` file does not make a Java project. Fixtures, samples, and + // vendored snippets appear routinely in repositories of other ecosystems, and + // starting JDT LS for one costs a JVM, a full workspace import, and a cache + // directory retained for `JDT_CACHE_RETENTION_DAYS`. Require positive evidence + // instead. Opening a Java file in the editor stays extension-based on the host + // side, so an unrecognized project still gets language support on demand. + let describes_java_project = workspace_paths.iter().any(|path| { + is_activating_build_file(path) + || (!is_ignored(path) + && is_java_source(path) + && directory_depth(path) <= MAX_ACTIVATION_DEPTH) + }); + let changes = changed_paths + .into_iter() + .map(|path| JavaWorkspaceChange { + kind: classify_change(&path), + path, + }) + .collect(); + + Ok(JavaWorkspacePolicyResponse { + should_start: representative_java_path.is_some() && describes_java_project, + representative_java_path, + changes, + }) +} + +/// Reduces platform filesystem observations to the shared JDT LS fingerprint format. +pub(crate) fn jdt_workspace_fingerprint( + request: JdtWorkspaceFingerprintRequest, +) -> Result { + let version = request.jdtls_version.trim(); + if version.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The JDT LS version must not be empty.", + )); + } + + let build_files = request + .build_files + .into_iter() + .map(|observation| normalize_relative_path(&observation.path)) + .collect::, _>>()?; + let modules = request + .direct_maven_modules + .into_iter() + .map(|module| normalize_module_name(&module)) + .collect::, _>>()?; + let build_files = build_files.into_iter().collect::>().join(","); + let modules = modules.into_iter().collect::>().join(","); + let mut workspace_fingerprint = + format!("build={build_files}|modules={modules}|jdtls={version}"); + if let Some(distribution) = request.gradle_wrapper_distribution { + let distribution = distribution.trim(); + if distribution.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Gradle wrapper distribution must not be empty.", + )); + } + // Appended only when present, so workspaces without a wrapper keep + // the state directory they already have. + workspace_fingerprint.push_str("|gradle="); + workspace_fingerprint.push_str(distribution); + } + + Ok(JdtWorkspaceFingerprintResponse { + workspace_fingerprint, + }) +} + +/// Selects inactive JDT LS workspace caches older than the shared retention period. +pub(crate) fn jdt_cache_retention( + request: JdtCacheRetentionRequest, +) -> Result { + if let Some(active_key) = request.active_workspace_key.as_deref() { + validate_workspace_key(active_key)?; + } + + // Duplicate observations are reduced to the newest timestamp so a stale + // filesystem event cannot delete a directory that was subsequently reused. + let mut newest_by_key = BTreeMap::new(); + for entry in request.entries { + if !is_workspace_key(&entry.workspace_key) { + continue; + } + newest_by_key + .entry(entry.workspace_key) + .and_modify(|timestamp: &mut u64| { + *timestamp = (*timestamp).max(entry.last_modified_unix_seconds) + }) + .or_insert(entry.last_modified_unix_seconds); + } + let retention_seconds = JDT_CACHE_RETENTION_DAYS * SECONDS_PER_DAY; + let expired_workspace_keys = newest_by_key + .into_iter() + .filter_map(|(workspace_key, last_modified)| { + let is_active = request.active_workspace_key.as_deref() == Some(workspace_key.as_str()); + let age = request.now_unix_seconds.saturating_sub(last_modified); + (!is_active && age > retention_seconds).then_some(workspace_key) + }) + .collect(); + + Ok(JdtCacheRetentionResponse { + retention_days: JDT_CACHE_RETENTION_DAYS, + expired_workspace_keys, + }) +} + +fn validate_workspace_key(value: &str) -> Result<(), CoreError> { + if is_workspace_key(value) { + Ok(()) + } else { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "The active JDT LS workspace key must be a lowercase SHA-256 value.", + )) + } +} + +fn is_workspace_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn normalize_paths(paths: Vec) -> Result, CoreError> { + paths + .into_iter() + .map(|path| normalize_relative_path(&path)) + .collect::, _>>() + .map(BTreeSet::into_iter) + .map(Iterator::collect) +} + +fn normalize_relative_path(path: &str) -> Result { + let normalized = path.trim().replace('\\', "/"); + let normalized = normalized.trim_start_matches("./"); + let is_windows_absolute = normalized + .as_bytes() + .get(1) + .is_some_and(|separator| *separator == b':'); + if normalized.is_empty() + || normalized.starts_with('/') + || is_windows_absolute + || normalized + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java workspace paths must be workspace-relative.", + ) + .with_details(path.to_string())); + } + Ok(normalized.to_string()) +} + +fn normalize_module_name(value: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() || normalized.contains(['/', '\\']) || matches!(normalized, "." | "..") + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Direct Maven module names must be single path components.", + ) + .with_details(value.to_string())); + } + Ok(normalized.to_string()) +} + +fn classify_change(path: &str) -> JavaWorkspaceChangeKind { + if is_ignored(path) { + JavaWorkspaceChangeKind::Ignored + } else if is_java_source(path) { + JavaWorkspaceChangeKind::Source + } else if is_build_configuration(path) { + JavaWorkspaceChangeKind::BuildConfiguration + } else { + JavaWorkspaceChangeKind::Other + } +} + +fn is_ignored(path: &str) -> bool { + path.split('/').any(|component| { + let component = component.to_ascii_lowercase(); + IGNORED_DIRECTORIES.contains(&component.as_str()) + }) +} + +fn is_java_source(path: &str) -> bool { + path.rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("java")) +} + +/// Number of directories between the workspace root and `path`. +/// +/// Root-level files are depth zero, matching the workspace-relative path format +/// that every host normalizes to before calling in. +fn directory_depth(path: &str) -> usize { + path.matches('/').count() +} + +/// Whether `path` is a build descriptor close enough to the root to describe the +/// opened project. +/// +/// Only file names count. `is_build_configuration` additionally treats any `.mvn` +/// or `gradle` directory component as build configuration, which is correct for +/// invalidating a cached project model but too loose for activation: an unrelated +/// `docs/gradle/` directory would otherwise start JDT LS. +fn is_activating_build_file(path: &str) -> bool { + if is_ignored(path) || directory_depth(path) > MAX_ACTIVATION_DEPTH { + return false; + } + path.rsplit('/') + .next() + .map(|name| name.to_ascii_lowercase()) + .is_some_and(|name| BUILD_FILE_NAMES.contains(&name.as_str())) +} + +fn is_build_configuration(path: &str) -> bool { + let components: Vec<_> = path.split('/').collect(); + let file_name = components + .last() + .map(|component| component.to_ascii_lowercase()) + .unwrap_or_default(); + BUILD_FILE_NAMES.contains(&file_name.as_str()) + || components.iter().any(|component| { + component.eq_ignore_ascii_case(".mvn") || component.eq_ignore_ascii_case("gradle") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn should_start(paths: &[&str]) -> bool { + java_workspace_policy(JavaWorkspacePolicyRequest { + workspace_paths: paths.iter().map(|path| path.to_string()).collect(), + changed_paths: Vec::new(), + }) + .expect("valid workspace paths must produce a policy") + .should_start + } + + #[test] + fn starts_for_projects_that_declare_a_java_build_near_the_root() { + // Conventional source layouts put sources well below MAX_ACTIVATION_DEPTH, + // so activation has to come from the descriptor rather than the sources. + assert!(should_start(&[ + "pom.xml", + "src/main/java/com/example/demo/DemoApplication.java", + ])); + assert!(should_start(&[ + "services/backend/build.gradle.kts", + "services/backend/src/main/java/com/example/App.java", + ])); + } + + #[test] + fn does_not_start_for_java_fixtures_checked_into_another_ecosystem() { + // Regression for this repository: a Swift, Rust, and TypeScript product + // that checks a sample Maven project into `shared/fixtures/`. Every such + // workspace used to start JDT LS and import the fixture as a real project. + assert!(!should_start(&[ + "Package.swift", + "Cargo.toml", + "shared/fixtures/projects/demo/pom.xml", + "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java", + ])); + } + + #[test] + fn starts_for_java_sources_that_have_no_build_system() { + // Single-file programs and standalone `main` classes are supported by the + // run configuration layer, so language support must not require Maven or + // Gradle to be present. + assert!(should_start(&["Main.java"])); + assert!(should_start(&["src/Main.java"])); + } + + #[test] + fn ignores_build_descriptors_and_sources_under_ignored_directories() { + // Build output carries copies of both descriptors and sources. Treating + // them as evidence would reactivate on exactly the trees the ignore list + // exists to exclude. + assert!(!should_start(&[ + "target/pom.xml", + "target/classes/Main.java" + ])); + assert!(!should_start(&["node_modules/pkg/pom.xml"])); + } + + #[test] + fn requires_a_java_source_even_when_a_build_descriptor_exists() { + // A Gradle or Maven build in a Kotlin-only or resources-only repository + // has nothing for JDT LS to open. + assert!(!should_start(&["pom.xml", "src/main/kotlin/App.kt"])); + } + + #[test] + fn rejects_paths_that_escape_or_replace_the_workspace_root() { + for path in ["../Main.java", "/tmp/Main.java", "C:/work/Main.java"] { + let error = java_workspace_policy(JavaWorkspacePolicyRequest { + workspace_paths: vec![path.to_string()], + changed_paths: Vec::new(), + }) + .expect_err("absolute and escaping paths must be rejected"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + } + + #[test] + fn rejects_invalid_workspace_fingerprint_observations() { + for request in [ + JdtWorkspaceFingerprintRequest { + build_files: vec![JdtBuildFileObservation { + path: "../pom.xml".to_string(), + }], + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, + jdtls_version: "1.38.0".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: vec!["nested/core".to_string()], + gradle_wrapper_distribution: None, + jdtls_version: "1.38.0".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, + jdtls_version: " ".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: Some(" ".to_string()), + jdtls_version: "1.38.0".to_string(), + }, + ] { + let error = jdt_workspace_fingerprint(request) + .expect_err("invalid fingerprint observations must be rejected"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs b/windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs new file mode 100644 index 000000000..0b8f2a4c7 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs @@ -0,0 +1,164 @@ +//! Directory key for one JDT language-server workspace state. +//! +//! Copied from lithe-core's `lsp/languages/jdt.rs`. The Windows host clears +//! this directory outside the language-server runtime, so the key must match +//! the one Core derives when it starts JDT LS. Both sides are held to the shared +//! `jdt-workspace-key-v1.json` fixture, which the test below replays. + +use sha2::{Digest, Sha256}; +use std::path::Path; + +/// Derives the shared directory key for one JDT LS workspace state. +/// +/// Hashes the normalized workspace identity and optional structural fingerprint. +pub fn jdt_workspace_key(workspace_root: &Path, fingerprint: Option<&str>) -> String { + let mut identity = normalized_workspace_identity(workspace_root); + // Mix the structural fingerprint in as a null-delimited suffix so changes + // to build files or the module set produce a fresh state directory without + // touching the previous one. Absent fingerprint keeps the legacy key. + if let Some(fp) = fingerprint { + identity.push('\x00'); + identity.push_str(fp); + } + let digest = Sha256::digest(identity.as_bytes()); + let mut key = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn normalized_workspace_identity(workspace_root: &Path) -> String { + let raw = workspace_root.to_string_lossy().replace('\\', "/"); + let bytes = raw.as_bytes(); + let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + let is_unc = raw.starts_with("//"); + let (prefix, remainder, is_absolute, protected_components, fold_case) = if has_drive { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let remainder = &raw[2..]; + ( + format!("{drive}:"), + remainder, + remainder.starts_with('/'), + 0, + true, + ) + } else if is_unc { + ("//".to_string(), raw.trim_start_matches('/'), true, 2, true) + } else if raw.starts_with('/') { + ("/".to_string(), raw.trim_start_matches('/'), true, 0, false) + } else { + (String::new(), raw.as_str(), false, 0, false) + }; + + let mut components: Vec = Vec::new(); + for component in remainder.split('/') { + match component { + "" | "." => {} + ".." => { + if components.len() > protected_components + && components.last().is_some_and(|value| value != "..") + { + components.pop(); + } else if !is_absolute { + components.push("..".to_string()); + } + } + value => components.push(if fold_case { + value.to_lowercase() + } else { + value.to_string() + }), + } + } + + let joined = components.join("/"); + match (prefix.as_str(), joined.is_empty(), is_absolute) { + ("", true, _) => ".".to_string(), + ("/", true, _) => "/".to_string(), + ("//", true, _) => "//".to_string(), + (prefix, true, _) => prefix.to_string(), + ("", false, _) => joined, + ("/", false, _) => format!("/{joined}"), + ("//", false, _) => format!("//{joined}"), + (prefix, false, true) => format!("{prefix}/{joined}"), + (prefix, false, false) => format!("{prefix}{joined}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn fixture_cases() -> Vec { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/lsp/jdt-workspace-key-v1.json" + )) + .expect("JDT workspace-key fixture should be valid JSON"); + fixture["cases"] + .as_array() + .expect("JDT workspace-key fixture should contain cases") + .clone() + } + + #[test] + fn key_matches_the_shared_compatibility_fixture() { + for case in fixture_cases() { + let root = case["workspaceRoot"].as_str().expect("workspaceRoot"); + let fingerprint = case["workspaceFingerprint"].as_str(); + + assert_eq!( + jdt_workspace_key(Path::new(root), fingerprint), + case["workspaceKey"].as_str().expect("workspaceKey"), + "case {}", + case["name"] + ); + } + } + + /// Core derives the same key when it starts JDT LS. The Windows host clears + /// the directory by this crate's key, so the two must never drift apart. + #[test] + fn key_matches_the_key_core_returns() { + for case in fixture_cases() { + let request = json!({ + "id": case["name"], + "command": "lsp.jdtWorkspaceKey", + "payload": { + "workspaceRoot": case["workspaceRoot"], + "workspaceFingerprint": case["workspaceFingerprint"], + }, + }); + let response: Value = + serde_json::from_str(&lithe_core::execute_json(&request.to_string())) + .expect("Core response should be JSON"); + let root = case["workspaceRoot"].as_str().expect("workspaceRoot"); + + assert_eq!( + response["data"]["workspaceKey"], + jdt_workspace_key(Path::new(root), case["workspaceFingerprint"].as_str()), + "case {}", + case["name"] + ); + } + } + + #[test] + fn identity_is_lexical_cross_platform_and_unique() { + assert_eq!( + jdt_workspace_key(Path::new(r"C:\Users\Ada\Project\.\src\.."), None), + jdt_workspace_key(Path::new("c:/users/ada/project"), None) + ); + assert_eq!( + jdt_workspace_key(Path::new("/workspace/project/./src/.."), None), + jdt_workspace_key(Path::new("/workspace/project"), None) + ); + assert_ne!( + jdt_workspace_key(Path::new("/workspace/project"), None), + jdt_workspace_key(Path::new("/workspace/other"), None) + ); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/launch_command.rs b/windows/tauri/crates/lithe-legacy-java/src/launch_command.rs new file mode 100644 index 000000000..3594ef796 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/launch_command.rs @@ -0,0 +1,493 @@ +//! Keeps an assembled launch command within the operating system's limit. +//! +//! A Java project launch passes the resolved runtime classpath on the command +//! line. For a large multi-module Maven project that is hundreds of absolute +//! jar paths, and Windows refuses to create a process whose command line +//! exceeds 32,767 UTF-16 code units. The JDK's own answer is an argument file: +//! `java @file` reads options from disk, so only the short reference stays on +//! the command line. +//! +//! Core decides whether shortening is required and what the file must contain. +//! The host owns the filesystem: it chooses the path, writes the file, and +//! deletes it after that execution exits. The returned text is Unicode; the +//! host must encode it losslessly with the launcher's native platform encoding. +//! JEP 400 does not make Windows launcher argument files UTF-8. +//! +//! JDK 8 has no argument files; [`crate::plan_classpath_jar_launch`] covers +//! that release with a manifest-only class-path JAR under the same budget. + +use std::path::Path; + +/// Maximum command line `CreateProcessW` accepts, including its terminating +/// null. Exceeding it fails the spawn with `ERROR_FILENAME_EXCED_RANGE` (206). +pub const WINDOWS_COMMAND_LINE_LIMIT: usize = 32_767; + +/// Headroom for the null terminator and for host quoting that this estimate +/// does not model exactly. Shortening early costs nothing; shortening too late +/// fails the launch. +pub(crate) const COMMAND_LINE_MARGIN: usize = 2_048; + +/// Options whose value is a joined list of absolute paths. These carry +/// practically all of an oversized command line, so moving them into the +/// argument file leaves the JVM options and program arguments untouched. +const PATH_LIST_OPTIONS: &[&str] = &["-cp", "-classpath", "--class-path", "-p", "--module-path"]; + +/// How a host should pass the assembled arguments to the operating system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchCommandPlan { + /// The command line fits: spawn with the original arguments. + Direct, + /// The command line is too long. Write `argfile_contents` to the argfile + /// path that was supplied, then spawn with `arguments`. + Argfile { + /// Arguments to spawn with; the moved options become one `@file` entry. + arguments: Vec, + /// Complete argument-file text, already quoted and newline terminated. + argfile_contents: String, + }, +} + +/// First JDK release that accepts `@file` argument files. +const MINIMUM_ARGFILE_JAVA_VERSION: u32 = 9; + +/// Reads the feature version from a JDK `release` file. +/// +/// `JAVA_VERSION="21.0.2"` yields 21 and the legacy `"1.8.0_392"` yields 8. +/// Hosts pass the result to [`plan_launch_command`], which needs it to know +/// whether an argument file is supported. Encoding is the host's responsibility. +pub fn java_feature_version_from_release(contents: &str) -> Option { + let value = java_version_from_release(contents)?; + release_feature_version(value) +} + +/// Borrows the full version from JDK-owned release metadata without running Java. +/// Native discovery uses this value with the shared runtime candidate ordering; +/// absent or unrecognized metadata must not trigger an implicit process probe. +fn java_version_from_release(contents: &str) -> Option<&str> { + let value = contents + .lines() + .find_map(|line| line.trim().strip_prefix("JAVA_VERSION="))? + .trim() + .trim_matches('"'); + release_feature_version(value)?; + Some(value) +} + +fn release_feature_version(value: &str) -> Option { + let mut parts = value.split(['.', '_', '-', '+']); + let first = parts.next()?.parse::().ok()?; + if first == 1 { + parts.next()?.parse::().ok() + } else { + Some(first) + } +} + +/// Plans how to spawn `executable` with `arguments` under `limit`. +/// +/// `argfile_path` is the file the host will write when the returned plan asks +/// for one; it is only referenced, never created here. `limit` defaults to the +/// Windows command-line cap, which is the only mainstream limit small enough +/// for a Java class path to reach. `java_feature_version` is the JVM that will +/// expand the file; `None` means unknown and keeps the plan conservative. +pub fn plan_launch_command( + executable: &str, + arguments: &[String], + argfile_path: &Path, + limit: Option, + java_feature_version: Option, +) -> LaunchCommandPlan { + let executable_name = executable.rsplit(['/', '\\']).next().unwrap_or(executable); + if !["java", "java.exe", "javaw", "javaw.exe"] + .iter() + .any(|name| executable_name.eq_ignore_ascii_case(name)) + || !java_feature_version.is_some_and(|version| version >= MINIMUM_ARGFILE_JAVA_VERSION) + { + return LaunchCommandPlan::Direct; + } + let limit = limit.unwrap_or(WINDOWS_COMMAND_LINE_LIMIT); + let budget = limit.saturating_sub(COMMAND_LINE_MARGIN); + if command_line_length(executable, arguments) <= budget { + return LaunchCommandPlan::Direct; + } + let mut remaining = Vec::with_capacity(arguments.len()); + let mut argfile_lines = Vec::new(); + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + let value = arguments.get(index + 1); + // The first application target ends launcher options. Everything after + // it belongs to the program, even when named -p or --class-path. + if !argument.starts_with('-') + || matches!(argument, "-jar" | "-m" | "--module") + || argument.starts_with("--module=") + { + remaining.extend_from_slice(&arguments[index..]); + break; + } + if argument == "--disable-@files" { + return LaunchCommandPlan::Direct; + } + match value { + Some(value) if PATH_LIST_OPTIONS.contains(&argument) => { + if argfile_lines.is_empty() { + remaining.push(format!("@{}", argfile_path.display())); + } + argfile_lines.push(argument.to_string()); + argfile_lines.push(quote_argfile_value(value)); + index += 2; + } + Some(value) + if matches!( + argument, + "--add-modules" + | "--limit-modules" + | "--add-reads" + | "--add-exports" + | "--add-opens" + | "--patch-module" + | "--upgrade-module-path" + | "--enable-native-access" + | "--source" + | "--describe-module" + | "-d" + ) => + { + remaining.push(argument.to_string()); + remaining.push(value.clone()); + index += 2; + } + _ => { + remaining.push(argument.to_string()); + index += 1; + } + } + } + if argfile_lines.is_empty() { + // Nothing movable: the caller's own arguments are oversized, and the + // host should report the operating system's refusal rather than + // silently rewrite a command it does not understand. + return LaunchCommandPlan::Direct; + } + let mut argfile_contents = argfile_lines.join("\n"); + argfile_contents.push('\n'); + LaunchCommandPlan::Argfile { + arguments: remaining, + argfile_contents, + } +} + +/// Estimates the command line the host will hand to the operating system. +/// +/// Windows counts UTF-16 code units, and each argument containing whitespace or +/// a quote is wrapped in quotes. The estimate never has to be exact because +/// [`COMMAND_LINE_MARGIN`] absorbs the difference. +pub(crate) fn command_line_length(executable: &str, arguments: &[String]) -> usize { + let mut length = quoted_length(executable); + for argument in arguments { + length += 1 + quoted_length(argument); + } + length +} + +fn quoted_length(value: &str) -> usize { + let units = value.encode_utf16().count(); + if value.is_empty() || value.contains([' ', '\t', '"']) { + units + 2 + value.matches('"').count() + } else { + units + } +} + +/// Quotes one value for a JDK argument file. +/// +/// Inside a quoted argfile string the backslash is an escape character, so +/// every separator in a Windows path has to be doubled. Quoting also keeps +/// paths that contain spaces in one argument. +fn quote_argfile_value(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn argfile() -> PathBuf { + PathBuf::from("C:\\Temp\\lithe-run\\launch.argfile") + } + + /// Mirrors a real Maven runtime classpath: absolute repository paths whose + /// length is what pushes a multi-module project past the Windows limit. + fn classpath(entries: usize) -> String { + (0..entries) + .map(|index| { + format!( + "C:\\Users\\developer\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-{index}\\3.2.{index}\\spring-boot-starter-{index}-3.2.{index}.jar" + ) + }) + .collect::>() + .join(";") + } + + #[test] + fn a_command_that_fits_is_spawned_unchanged() { + let arguments = vec![ + "-cp".to_string(), + classpath(4), + "org.dromara.DromaraApplication".to_string(), + ]; + assert_eq!( + plan_launch_command( + "C:\\jdk\\bin\\java.exe", + &arguments, + &argfile(), + None, + Some(21) + ), + LaunchCommandPlan::Direct + ); + } + + /// Regression: RuoYi-Vue-Plus resolves hundreds of jars, and the inline + /// `-cp` made `CreateProcessW` fail before the JVM ever started. + #[test] + fn an_oversized_classpath_moves_into_an_argument_file() { + let entries = classpath(500); + let arguments = vec![ + "-Dfile.encoding=UTF-8".to_string(), + "-cp".to_string(), + entries.clone(), + "org.dromara.DromaraApplication".to_string(), + "--spring.profiles.active=dev".to_string(), + ]; + + let plan = plan_launch_command( + "C:\\jdk\\bin\\java.exe", + &arguments, + &argfile(), + None, + Some(21), + ); + let LaunchCommandPlan::Argfile { + arguments: shortened, + argfile_contents, + } = plan + else { + panic!("an oversized classpath must be shortened"); + }; + // The argfile replaces the option in place, so JVM options still + // precede the main class and program arguments still follow it. + assert_eq!( + shortened, + [ + "-Dfile.encoding=UTF-8", + "@C:\\Temp\\lithe-run\\launch.argfile", + "org.dromara.DromaraApplication", + "--spring.profiles.active=dev", + ] + ); + assert!( + command_line_length("C:\\jdk\\bin\\java.exe", &shortened) < 1_000, + "the remaining command line must be short: {shortened:?}" + ); + let expected_value = format!("\"{}\"", entries.replace('\\', "\\\\")); + assert_eq!(argfile_contents, format!("-cp\n{expected_value}\n")); + } + + #[test] + fn module_paths_are_moved_with_the_classpath() { + let arguments = vec![ + "--module-path".to_string(), + classpath(300), + "-cp".to_string(), + classpath(300), + "-m".to_string(), + "app/com.example.Main".to_string(), + ]; + + let LaunchCommandPlan::Argfile { + arguments: shortened, + argfile_contents, + } = plan_launch_command( + "C:\\jdk\\bin\\java.exe", + &arguments, + &argfile(), + None, + Some(21), + ) + else { + panic!("both path lists must be shortened"); + }; + // One argfile reference covers every moved option, and `-m` keeps its + // own value because it names a module, not a path list. + assert_eq!( + shortened, + [ + "@C:\\Temp\\lithe-run\\launch.argfile", + "-m", + "app/com.example.Main", + ] + ); + assert!(argfile_contents.starts_with("--module-path\n")); + assert!(argfile_contents.contains("\n-cp\n")); + assert!(argfile_contents.ends_with('\n')); + } + + #[test] + fn a_value_with_spaces_stays_one_argument_in_the_file() { + let entries = format!("C:\\Program Files\\lib.jar;{}", classpath(500)); + let arguments = vec!["-cp".to_string(), entries, "Main".to_string()]; + + let LaunchCommandPlan::Argfile { + argfile_contents, .. + } = plan_launch_command("java", &arguments, &argfile(), None, Some(21)) + else { + panic!("an oversized classpath must be shortened"); + }; + assert!(argfile_contents.contains("\"C:\\\\Program Files\\\\lib.jar;")); + } + + #[test] + fn an_oversized_command_without_path_lists_is_left_to_the_operating_system() { + let arguments = vec!["Main".to_string(), classpath(500)]; + assert_eq!( + plan_launch_command("java", &arguments, &argfile(), None, Some(21)), + LaunchCommandPlan::Direct + ); + } + + #[test] + fn the_limit_is_configurable_for_hosts_with_a_different_cap() { + let arguments = vec![ + "-cp".to_string(), + classpath(20), + "com.example.Main".to_string(), + ]; + assert_eq!( + plan_launch_command("java", &arguments, &argfile(), None, Some(21)), + LaunchCommandPlan::Direct + ); + assert!(matches!( + plan_launch_command("java", &arguments, &argfile(), Some(2_500), Some(21)), + LaunchCommandPlan::Argfile { .. } + )); + } + + #[test] + fn unicode_text_is_returned_for_the_host_to_encode_on_supported_jdks() { + let arguments = vec![ + "-cp".into(), + format!("C:\\Users\\示例;{}", classpath(500)), + "Main".into(), + ]; + for version in [9, 17, 21] { + let LaunchCommandPlan::Argfile { + argfile_contents, .. + } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(version)) + else { + panic!("a known supported JDK can use a host-encoded file"); + }; + assert!(argfile_contents.contains("示例")); + } + } + + #[test] + fn non_java_and_unknown_or_unsupported_jdks_are_not_rewritten() { + let arguments = vec!["-p".into(), classpath(500)]; + for executable in ["node.exe", "python.exe", "custom-launcher"] { + assert_eq!( + plan_launch_command(executable, &arguments, &argfile(), None, Some(21)), + LaunchCommandPlan::Direct + ); + } + for version in [None, Some(8)] { + assert_eq!( + plan_launch_command("java.exe", &arguments, &argfile(), None, version), + LaunchCommandPlan::Direct + ); + } + } + + #[test] + fn application_arguments_keep_their_meaning_after_every_launch_target() { + for target in [ + vec!["Main"], + vec!["-jar", "app.jar"], + vec!["-m", "app/Main"], + vec!["--module=app/Main"], + ] { + let mut arguments = vec!["-cp".into(), classpath(500)]; + arguments.extend(target.into_iter().map(str::to_string)); + arguments.extend(["-p", "8080", "--class-path", "program-value"].map(str::to_string)); + let LaunchCommandPlan::Argfile { + arguments: shortened, + argfile_contents, + } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(21)) + else { + panic!("classpath should move"); + }; + assert_eq!(&shortened[1..], &arguments[2..]); + assert!(!argfile_contents.contains("8080")); + assert!(!argfile_contents.contains("program-value")); + } + } + + #[test] + fn launcher_option_values_are_not_mistaken_for_path_options() { + let arguments = vec![ + "--add-modules".into(), + "-p".into(), + "-cp".into(), + classpath(500), + "Main".into(), + ]; + let LaunchCommandPlan::Argfile { + arguments: shortened, + .. + } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(21)) + else { + panic!("classpath should move"); + }; + assert_eq!(&shortened[..2], &arguments[..2]); + let disabled = vec![ + "--disable-@files".into(), + "-cp".into(), + classpath(500), + "Main".into(), + ]; + assert_eq!( + plan_launch_command("java.exe", &disabled, &argfile(), None, Some(21)), + LaunchCommandPlan::Direct + ); + } + + #[test] + fn the_release_file_reports_the_feature_version() { + for version in ["21.0.2", "1.8.0_392", "25-ea+35", "17.0.12+7"] { + let release = format!("IMPLEMENTOR=\"fixture\"\nJAVA_VERSION=\"{version}\"\n"); + assert_eq!(java_version_from_release(&release), Some(version)); + } + assert_eq!( + java_version_from_release("JAVA_VERSION=\"unknown\"\n"), + None + ); + assert_eq!( + java_feature_version_from_release("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\"21.0.2\"\n"), + Some(21) + ); + assert_eq!( + java_feature_version_from_release("JAVA_VERSION=\"1.8.0_392\"\n"), + Some(8) + ); + assert_eq!( + java_feature_version_from_release("JAVA_VERSION=\"17\"\n"), + Some(17) + ); + assert_eq!( + java_feature_version_from_release("MODULES=\"java.base\"\n"), + None + ); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/lib.rs b/windows/tauri/crates/lithe-legacy-java/src/lib.rs new file mode 100644 index 000000000..af9ac6075 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/lib.rs @@ -0,0 +1,53 @@ +//! Transitional Java, Maven, Spring and MyBatis logic for the Windows host. +//! +//! Issue #1495 moves this logic out of Rust Core and into the Java plugin. The +//! Windows host cannot reach a plugin yet, so it keeps calling the old Core +//! command names and a few Core helper functions. This crate answers exactly +//! those calls with the code Core used to hold, so the Windows frontend and its +//! JSON contract do not change while Core sheds the Java-specific parts. +//! +//! Two kinds of code live here: +//! +//! - Moved from Core, which no longer has them: the six `maven.*` command +//! entries, the class-path JAR planner and the Java source listing. Core keeps +//! only the Maven reading it still uses for builds and JDT LS settings. +//! - Copied from Core, which keeps its own: `java.*`, `spring.index`, +//! `mybatis.index`, Java selection, launch-command planning, the JDT workspace +//! key and the Maven settings writer. macOS or Core's own JDT LS adapter still +//! call these, so both copies exist until the macOS migration lands. +//! +//! This crate is transitional. Delete it once the Windows host implements the +//! generic plugin interface and can install `lithe-plugin-java`; tracking Issue +//! #1495. Nothing here may grow new features: change `lithe-plugin-java` instead. + +mod classpath_jar; +mod commands; +mod contracts; +mod java_run_markers; +mod java_selection; +mod java_source_definition; +mod java_workspace; +mod jdt_workspace_key; +mod launch_command; +mod local_fs; +mod maven; +mod mybatis; +mod run_sources; +mod spring; +#[cfg(test)] +mod tests; + +pub use classpath_jar::{ + plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, +}; +pub use commands::try_execute; +pub use java_selection::{ + compare_java_candidates, select_java, JavaSelection, JavaSelectionCandidate, + JavaSelectionRequest, +}; +pub use jdt_workspace_key::jdt_workspace_key; +pub use launch_command::{ + java_feature_version_from_release, plan_launch_command, LaunchCommandPlan, +}; +pub use maven::settings_with_local_repository; +pub use run_sources::{is_run_scan_excluded_directory, list_run_java_sources}; diff --git a/windows/tauri/crates/lithe-legacy-java/src/local_fs.rs b/windows/tauri/crates/lithe-legacy-java/src/local_fs.rs new file mode 100644 index 000000000..54e145ad0 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/local_fs.rs @@ -0,0 +1,30 @@ +//! Local filesystem calls with the shape the copied Maven code expects. +//! +//! Core reaches these through `remote::workspace_io`, which also serves remote +//! workspaces. Windows has no remote workspaces, so every call here goes +//! straight to `std::fs`. The std functions already match Core's local +//! behavior; only the two existence probes differ, and they are kept here so +//! callers can tell a missing path from an unreadable one. + +use std::io; +use std::path::Path; + +pub use std::fs::{canonicalize, metadata, read, read_dir, File}; + +/// Tests whether a path is a directory, treating only a missing path as `false`. +pub fn is_dir(path: impl AsRef) -> io::Result { + match metadata(path) { + Ok(value) => Ok(value.is_dir()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +/// Tests whether a path is a regular file, treating only a missing path as `false`. +pub fn is_file(path: impl AsRef) -> io::Result { + match metadata(path) { + Ok(value) => Ok(value.is_file()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs b/windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs new file mode 100644 index 000000000..93e38d3a1 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs @@ -0,0 +1,474 @@ +//! Maven dependency trees read from the file the dependency plugin writes. +//! +//! The tool window asks `maven-dependency-plugin:tree` for its verbose text +//! tree and sends it to a platform-owned file through `-DoutputFile`, so the +//! tree never shares a channel or a size budget with Maven's console log. +//! Resolution semantics stay with Maven: Core only validates the file's +//! structure and turns each node's annotations into explicit fields. +//! +//! The plugin version is pinned, which fixes the serialization format. Every +//! line after the root must therefore be a well-formed node carrying only +//! annotations that version produces; anything else is reported as a format +//! failure instead of being skipped, because skipping would present a partial +//! tree as a successful result. +//! +//! Note: the data-source decision is recorded in .agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md + +use super::normalized_project_path; +use crate::contracts::{ + MavenDependenciesResponse, MavenDependencyResolutionResponse, MavenDependencyResponse, +}; +use crate::local_fs::File; +use lithe_core::{CoreError, ErrorCode}; +use serde::Deserialize; +use std::io::{BufRead, BufReader, ErrorKind, Read}; +use std::path::Path; + +/// Fully qualified goal; pinning the version pins the text format parsed here. +const DEPENDENCY_PLUGIN_GOAL: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"; +/// Most nodes accepted from one module's tree. +const MAX_NODES: usize = 10_000; +/// Deepest nesting accepted; each level adds one three-character indent. +const MAX_DEPTH: usize = 64; +/// Longest accepted line in bytes. The deepest indent plus a long coordinate +/// and every annotation the plugin writes stays well below this. +const MAX_LINE_BYTES: usize = 4 * 1024; +/// Largest accepted tree file: the root line plus the node limit, each at the +/// line limit. The budget follows from the tree limits, not from log volume. +const MAX_TREE_BYTES: u64 = ((MAX_NODES + 1) * MAX_LINE_BYTES) as u64; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Dependency-tree file written by one `maven.dependencyPlan` invocation. +pub struct MavenDependenciesRequest { + /// Reactor-relative module the plan selected; `.` is the reactor root. + pub module_path: String, + /// Absolute platform-owned path passed to the same plan as `outputFile`. + /// The platform creates a fresh path per invocation and removes it after + /// the result, cancellation, or failure, so a file from an earlier run is + /// never read here. + pub output_file: String, +} + +/// Validates the platform-owned file the plugin will write the tree to. +pub(crate) fn validated_output_file(value: &str) -> Result { + let invalid = || { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven dependency-tree output path is invalid", + ) + }; + let trimmed = value.trim(); + // The path becomes one `-DoutputFile=` argument. Surrounding whitespace or + // control characters would change which file Maven writes. + if trimmed.is_empty() || trimmed != value || value.chars().any(char::is_control) { + return Err(invalid()); + } + if !Path::new(value).is_absolute() { + return Err(invalid()); + } + Ok(value.to_string()) +} + +/// Plugin goal and properties that write one module's verbose tree to a file. +/// +/// Every format-affecting property is explicit so user or project defaults +/// cannot change the parsed representation: text output, standard tree +/// tokens, UTF-8, and overwrite rather than append. +pub(crate) fn dependency_tree_arguments(output_file: &str) -> Vec { + vec![ + DEPENDENCY_PLUGIN_GOAL.to_string(), + "-Dverbose=true".to_string(), + "-DoutputType=text".to_string(), + "-Dtokens=standard".to_string(), + format!("-DoutputFile={output_file}"), + "-DoutputEncoding=UTF-8".to_string(), + "-DappendOutput=false".to_string(), + "-Dstyle.color=never".to_string(), + "-Duser.language=en".to_string(), + "-Duser.country=US".to_string(), + ] +} + +/// Reads and normalizes the tree file written by a finished dependency plan. +pub fn dependencies( + request: MavenDependenciesRequest, +) -> Result { + let module_path = normalized_project_path(&request.module_path, "Maven module")?; + let output_file = validated_output_file(&request.output_file)?; + let parsed = read_tree_file(Path::new(&output_file), &module_path)?; + + let mut cursor = 0; + let dependencies = if parsed.is_empty() { + Vec::new() + } else { + if parsed[0].depth != 0 { + return Err(invalid_structure()); + } + let dependencies = build_level(&parsed, &mut cursor, 0)?; + if cursor != parsed.len() { + return Err(invalid_structure()); + } + dependencies + }; + Ok(MavenDependenciesResponse { + module_path, + dependencies, + }) +} + +struct ParsedDependency { + depth: usize, + node: MavenDependencyResponse, +} + +fn read_tree_file(path: &Path, module_path: &str) -> Result, CoreError> { + let file = File::open(path).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + // A successful exit without the file means the plugin did not + // honor `outputFile`, for example because the project's POM + // configures a different output for the dependency plugin. + CoreError::new( + ErrorCode::ProcessFailed, + "Maven finished without writing the dependency tree", + ) + } else { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + } + })?; + let length = file + .metadata() + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + })? + .len(); + if length > MAX_TREE_BYTES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree exceeds the supported size", + ) + .with_details(format!("maximumBytes={MAX_TREE_BYTES}"))); + } + + // `take` keeps the bound even if the file grows after the size check. + let mut reader = BufReader::new(file.take(MAX_TREE_BYTES + 1)); + let mut line = Vec::new(); + let mut line_number = 0usize; + let mut saw_root = false; + let mut parsed = Vec::new(); + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line).map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + })?; + if read == 0 { + break; + } + line_number += 1; + if line.len() > MAX_LINE_BYTES { + return Err(unexpected_format(line_number)); + } + let text = std::str::from_utf8(&line).map_err(|_| unexpected_format(line_number))?; + // The plugin writes with the JVM line separator, so Windows files use CRLF. + let text = text.trim_end_matches(['\n', '\r']); + if text.trim().is_empty() { + continue; + } + if !saw_root { + if !is_root_line(text) { + return Err(unexpected_format(line_number)); + } + saw_root = true; + continue; + } + let entry = parse_line(text, module_path)?.ok_or_else(|| unexpected_format(line_number))?; + if parsed.len() == MAX_NODES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency count exceeds the supported limit", + ) + .with_details(format!("maximumNodes={MAX_NODES}"))); + } + parsed.push(entry); + } + if !saw_root { + return Err(unexpected_format(0)); + } + Ok(parsed) +} + +/// The first line names the module itself as `group:artifact:packaging:version`. +fn is_root_line(line: &str) -> bool { + let parts = line.split(':').collect::>(); + !line.starts_with(char::is_whitespace) + && !line.starts_with(['+', '\\', '|', '(']) + && parts.len() >= 4 + && parts.iter().all(|part| !part.trim().is_empty()) +} + +fn parse_line(line: &str, module_path: &str) -> Result, CoreError> { + let marker = match (line.find("+- "), line.find("\\- ")) { + (Some(left), Some(right)) => left.min(right), + (Some(index), None) | (None, Some(index)) => index, + (None, None) => return Ok(None), + }; + let prefix = &line[..marker]; + let mut chunks = prefix.as_bytes().chunks_exact(3); + if !chunks.all(|chunk| chunk == b"| " || chunk == b" ") || !chunks.remainder().is_empty() { + return Ok(None); + } + let depth = prefix.len() / 3; + if depth >= MAX_DEPTH { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree exceeds the supported depth", + ) + .with_details(format!("maximumDepth={MAX_DEPTH}"))); + } + + let value = &line[(marker + 3)..]; + // Included nodes are written as `coordinate (items)` and omitted nodes as + // `(coordinate - items)`; items are separated by `; ` in both forms. + let (coordinate, items) = if let Some(inner) = value + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + { + match inner.split_once(" - ") { + Some((coordinate, items)) => (coordinate, Some(items)), + None => return Ok(None), + } + } else if let Some((coordinate, items)) = value.split_once(" (") { + match items.strip_suffix(')') { + Some(items) => (coordinate, Some(items)), + None => return Ok(None), + } + } else { + (value, None) + }; + let Some(coordinate) = parse_coordinate(coordinate) else { + return Ok(None); + }; + let Some(annotations) = parse_annotations(items) else { + return Ok(None); + }; + // Only the omitted form may carry the omission item, and it must carry one. + if value.starts_with('(') != annotations.omission.is_some() { + return Ok(None); + } + let (resolution, selected_version) = match annotations.omission { + None => (MavenDependencyResolutionResponse::Resolved, None), + Some(Omission::Duplicate) => (MavenDependencyResolutionResponse::OmittedDuplicate, None), + Some(Omission::Conflict(winner)) => ( + MavenDependencyResolutionResponse::OmittedConflict, + Some(winner), + ), + }; + Ok(Some(ParsedDependency { + depth, + node: MavenDependencyResponse { + module_path: module_path.to_string(), + group_id: coordinate.group_id.to_string(), + artifact_id: coordinate.artifact_id.to_string(), + version: coordinate.version.to_string(), + r#type: coordinate.artifact_type.to_string(), + classifier: coordinate.classifier.map(str::to_string), + scope: coordinate.scope.to_string(), + resolution, + selected_version, + premanaged_version: annotations.premanaged_version, + premanaged_scope: annotations.premanaged_scope, + original_scope: annotations.original_scope, + ignored_scope: annotations.ignored_scope, + children: Vec::new(), + }, + })) +} + +struct Coordinate<'a> { + group_id: &'a str, + artifact_id: &'a str, + artifact_type: &'a str, + classifier: Option<&'a str>, + version: &'a str, + scope: &'a str, +} + +/// Splits `group:artifact:type[:classifier]:version:scope`. +fn parse_coordinate(value: &str) -> Option> { + let parts = value.split(':').collect::>(); + let coordinate = match parts.as_slice() { + [group_id, artifact_id, artifact_type, version, scope] => Coordinate { + group_id, + artifact_id, + artifact_type, + classifier: None, + version, + scope, + }, + [group_id, artifact_id, artifact_type, classifier, version, scope] => Coordinate { + group_id, + artifact_id, + artifact_type, + classifier: Some(classifier), + version, + scope, + }, + _ => return None, + }; + let required = [ + coordinate.group_id, + coordinate.artifact_id, + coordinate.artifact_type, + coordinate.version, + coordinate.scope, + ]; + let valid = required + .iter() + .chain(coordinate.classifier.iter()) + .all(|part| !part.is_empty() && !part.contains(char::is_whitespace)); + valid.then_some(coordinate) +} + +/// Why Maven left an occurrence out of the effective tree. +enum Omission { + /// The same version was already selected elsewhere in the tree. + Duplicate, + /// Another version won mediation; holds the selected version. + Conflict(String), +} + +/// Annotation items the pinned plugin writes after a node's coordinate. +#[derive(Default)] +struct Annotations { + /// `version managed from X`: the version before dependency management. + premanaged_version: Option, + /// `scope managed from X`: the scope before dependency management. + premanaged_scope: Option, + /// `scope updated from X`: the declared scope before mediation widened it. + original_scope: Option, + /// `scope not updated to X`: a wider scope mediation did not apply. + ignored_scope: Option, + omission: Option, +} + +fn parse_annotations(items: Option<&str>) -> Option { + let mut annotations = Annotations::default(); + let Some(items) = items else { + return Some(annotations); + }; + for item in items.split("; ") { + let slot_and_value = [ + ("version managed from ", &mut annotations.premanaged_version), + ("scope managed from ", &mut annotations.premanaged_scope), + ("scope updated from ", &mut annotations.original_scope), + ("scope not updated to ", &mut annotations.ignored_scope), + ] + .into_iter() + .find_map(|(prefix, slot)| item.strip_prefix(prefix).map(|value| (slot, value))); + if let Some((slot, value)) = slot_and_value { + if slot.is_some() || !is_token(value) { + return None; + } + *slot = Some(value.to_string()); + continue; + } + if annotations.omission.is_some() { + return None; + } + if item == "omitted for duplicate" { + annotations.omission = Some(Omission::Duplicate); + } else if let Some(winner) = item.strip_prefix("omitted for conflict with ") { + if !is_token(winner) { + return None; + } + annotations.omission = Some(Omission::Conflict(winner.to_string())); + } else { + return None; + } + } + Some(annotations) +} + +fn is_token(value: &str) -> bool { + !value.is_empty() && !value.contains(char::is_whitespace) +} + +fn build_level( + parsed: &[ParsedDependency], + cursor: &mut usize, + depth: usize, +) -> Result, CoreError> { + let mut dependencies = Vec::new(); + while *cursor < parsed.len() { + let entry_depth = parsed[*cursor].depth; + if entry_depth < depth { + break; + } + if entry_depth > depth { + return Err(invalid_structure()); + } + let mut dependency = parsed[*cursor].node.clone(); + *cursor += 1; + if *cursor < parsed.len() { + let next_depth = parsed[*cursor].depth; + if next_depth > depth + 1 { + return Err(invalid_structure()); + } + if next_depth == depth + 1 { + dependency.children = build_level(parsed, cursor, depth + 1)?; + } + } + sort_dependencies(&mut dependency.children); + dependencies.push(dependency); + } + sort_dependencies(&mut dependencies); + Ok(dependencies) +} + +fn sort_dependencies(dependencies: &mut [MavenDependencyResponse]) { + dependencies.sort_by(|left, right| { + ( + &left.group_id, + &left.artifact_id, + &left.r#type, + &left.classifier, + &left.version, + &left.scope, + ) + .cmp(&( + &right.group_id, + &right.artifact_id, + &right.r#type, + &right.classifier, + &right.version, + &right.scope, + )) + }); +} + +fn invalid_structure() -> CoreError { + CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree structure is invalid", + ) +} + +fn unexpected_format(line_number: usize) -> CoreError { + CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree is not in the expected text format", + ) + .with_details(format!("line={line_number}")) +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs b/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs new file mode 100644 index 000000000..f5271819b --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs @@ -0,0 +1,2386 @@ +//! Maven reactor inspection, profile discovery, and source diagnostics. +//! +//! Copied from lithe-core's `project/maven.rs`, minus the pieces only Core's +//! JDT language server adapter uses. + +mod dependency_tree; +mod test_reports; + +pub use self::dependency_tree::{dependencies, MavenDependenciesRequest}; +use self::test_reports::MavenTestReportsRequest; +use crate::contracts::{ + MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenLaunchExecutableResponse, + MavenLaunchPlanResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, + MavenSourceRootKind, MavenSourceRootResponse, MavenTestFailureResponse, + MavenTestResultsResponse, +}; +use crate::local_fs as fs; +use lithe_core::{CoreError, ErrorCode}; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::{Reader, Writer}; +use regex::Regex; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths used to locate and inspect the owning Maven reactor. +pub struct MavenScanRequest { + pub root: String, + #[serde(default)] + pub paths: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Maven process output to normalize into workspace diagnostics. +pub struct MavenDiagnosticsRequest { + pub root: String, + pub output: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Maven Surefire/Failsafe output to normalize into JUnit result data. +pub struct MavenTestResultsRequest { + pub root: String, + pub output: String, + /// When present, per-method outcomes are also read from the XML reports + /// the run wrote; see [`MavenTestReportsRequest`]. + #[serde(default)] + pub reports: Option, +} + +const MAVEN_CONTEXT_VERSION: u32 = 1; +const MAX_MAVEN_TEST_OUTPUT_CHARACTERS: usize = 500_000; +const MAX_MAVEN_TEST_FAILURES: usize = 10_000; +const MAX_MAVEN_TEST_SOURCE_SEARCH_DIRECTORIES: usize = 10_000; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Versioned Maven defaults merged by the platform before launch planning. +pub struct MavenLaunchContextRequest { + pub version: u32, + pub reactor_path: String, + #[serde(default)] + pub profiles: Vec, + #[serde(default)] + pub settings_path: Option, + #[serde(default)] + pub local_repository_path: Option, + #[serde(default)] + pub skip_tests: bool, + #[serde(default)] + pub maven_executable_path: Option, + #[serde(default)] + pub java_home_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Ordered Maven invocation tokens selected by a tool-window consumer. +/// +/// The first token is a lifecycle or custom goal. Remaining tokens are passed +/// directly to Maven as arguments without shell interpretation. +pub struct MavenLaunchPlanRequest { + pub root: String, + pub context: MavenLaunchContextRequest, + #[serde(default)] + pub module: Option, + pub goals: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One bounded dependency-tree invocation for a validated Maven module. +pub struct MavenDependencyPlanRequest { + pub root: String, + pub context: MavenLaunchContextRequest, + #[serde(default)] + pub module: Option, + /// Absolute platform-owned file the plugin writes the tree to. The same + /// path is later passed to `maven.dependencies`. + pub output_file: String, +} + +struct ValidatedMavenContext { + reactor_path: String, + canonical_reactor: PathBuf, + profiles: Vec, + settings_path: Option, + local_repository_path: Option, + skip_tests: bool, + maven_executable_path: Option, + java_home_path: Option, +} + +/// Produces a deterministic Maven plan without resolving a native executable. +pub fn launch_plan(request: MavenLaunchPlanRequest) -> Result { + let arguments = normalized_tool_window_arguments(request.goals)?; + launch_plan_with_arguments( + request.root, + request.context, + request.module, + arguments, + true, + ) +} + +/// Produces a fixed invocation that writes one module's tree to a file. +/// +/// Exactly one project runs, because every project in the session would +/// overwrite the same file: a module is selected with `-pl`, and the reactor +/// root with `-N` so its modules are not visited. +pub fn dependency_plan( + request: MavenDependencyPlanRequest, +) -> Result { + let output_file = dependency_tree::validated_output_file(&request.output_file)?; + let selects_root = request + .module + .as_deref() + .map(|module| normalized_project_path(module, "Maven module")) + .transpose()? + .is_none_or(|module| module == "."); + let mut arguments = Vec::new(); + if selects_root { + arguments.push("-N".to_string()); + } + arguments.extend(dependency_tree::dependency_tree_arguments(&output_file)); + launch_plan_with_arguments( + request.root, + request.context, + request.module, + arguments, + false, + ) +} + +/// Applies a validated Maven context to Core-owned run/debug arguments. +/// +/// Unlike the public tool-window command, the trailing arguments may contain +/// properties such as `-Dexec.mainClass`: those values were generated and +/// validated by the run-configuration domain rather than entered as goals. +fn launch_plan_with_arguments( + root: String, + context: MavenLaunchContextRequest, + module: Option, + trailing_arguments: Vec, + also_make: bool, +) -> Result { + let validated = validated_maven_context(&root, context)?; + + let module = module + .as_deref() + .map(|value| normalized_project_path(value, "Maven module")) + .transpose()?; + if let Some(module) = module.as_deref().filter(|value| *value != ".") { + let declared = declared_modules(&validated.canonical_reactor)?; + if !declared + .iter() + .any(|candidate| candidate.relative_path == module) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven module is not part of the selected reactor", + ) + .with_details(module)); + } + } + + let arguments = maven_arguments( + &validated.profiles, + validated.settings_path.as_deref(), + validated.local_repository_path.as_deref(), + module.as_deref(), + also_make, + validated.skip_tests, + &trailing_arguments, + ); + let configuration_fingerprint = maven_context_fingerprint( + &validated.reactor_path, + &validated.profiles, + validated.settings_path.as_deref(), + validated.local_repository_path.as_deref(), + validated.skip_tests, + validated.maven_executable_path.as_deref(), + validated.java_home_path.as_deref(), + ); + + Ok(MavenLaunchPlanResponse { + version: MAVEN_CONTEXT_VERSION, + executable: MavenLaunchExecutableResponse { + toolchain: "project-maven".to_string(), + }, + arguments, + working_directory: validated.reactor_path, + configuration_fingerprint, + }) +} + +/// Rewrites `` in a Maven settings document, preserving every +/// other element, comment, and attribute exactly as written. +/// +/// The generated document replaces the user-level settings JDT LS reads, so it +/// has to keep the mirrors, servers, and proxies the workspace already builds +/// with. Dropping them would send project import to the default remote +/// repositories instead of the ones Maven resolves through. +pub fn settings_with_local_repository( + source: &str, + local_repository: &str, +) -> Result { + let mut writer = Writer::new(Vec::new()); + // `` is optional, and an absent element has to be inserted + // rather than rewritten. Detect it first so the writing pass stays linear. + let insert = !declares_local_repository(source)?; + let mut reader = Reader::from_reader(source.as_bytes()); + // Comments and indentation are part of the document the user maintains. + reader.config_mut().trim_text(false); + let mut buffer = Vec::new(); + let mut depth = 0usize; + // Depth of the element whose original content is being replaced. + let mut replacing = None; + let mut done = false; + + loop { + let event = reader + .read_event_into(&mut buffer) + .map_err(settings_parse_error)?; + match event { + Event::Eof => break, + Event::Start(start) => { + if replacing.is_some() { + // Children of the replaced element belong to the discarded + // content, so their end tags are dropped below as well. + depth += 1; + continue; + } + let name = local_name(start.name().as_ref()); + if !done && depth == 1 && name == "localRepository" { + write_settings_event(&mut writer, Event::Start(start.borrow()))?; + write_local_repository_text(&mut writer, local_repository)?; + replacing = Some(depth); + done = true; + depth += 1; + continue; + } + write_settings_event(&mut writer, Event::Start(start.borrow()))?; + depth += 1; + if !done && insert && depth == 1 && name == "settings" { + write_local_repository_element(&mut writer, local_repository)?; + done = true; + } + } + Event::Empty(empty) => { + if replacing.is_some() { + continue; + } + let name = local_name(empty.name().as_ref()); + if !done && depth == 1 && name == "localRepository" { + // An empty element carries no text node to rewrite, so it is + // expanded into the explicit form. + write_local_repository_element(&mut writer, local_repository)?; + done = true; + continue; + } + if !done && insert && depth == 0 && name == "settings" { + // A self-closing root has to become an explicit element + // before the repository can be added inside it. + let qualified = String::from_utf8_lossy(empty.name().as_ref()).into_owned(); + write_settings_event(&mut writer, Event::Start(empty.borrow()))?; + write_local_repository_element(&mut writer, local_repository)?; + write_settings_event(&mut writer, Event::End(BytesEnd::new(qualified)))?; + done = true; + continue; + } + write_settings_event(&mut writer, Event::Empty(empty.borrow()))?; + } + Event::End(end) => { + depth = depth.saturating_sub(1); + if replacing == Some(depth) { + replacing = None; + } else if replacing.is_some() { + continue; + } + write_settings_event(&mut writer, Event::End(end.borrow()))?; + } + other => { + if replacing.is_none() { + write_settings_event(&mut writer, other)?; + } + } + } + buffer.clear(); + } + + String::from_utf8(writer.into_inner()).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Generated Maven settings are not valid UTF-8", + ) + .with_details(error.to_string()) + }) +} + +/// Reports whether the document already declares a top-level local repository. +fn declares_local_repository(source: &str) -> Result { + let mut reader = Reader::from_reader(source.as_bytes()); + reader.config_mut().trim_text(false); + let mut buffer = Vec::new(); + let mut depth = 0usize; + loop { + match reader + .read_event_into(&mut buffer) + .map_err(settings_parse_error)? + { + Event::Eof => return Ok(false), + Event::Start(start) => { + if depth == 1 && local_name(start.name().as_ref()) == "localRepository" { + return Ok(true); + } + depth += 1; + } + Event::Empty(empty) => { + if depth == 1 && local_name(empty.name().as_ref()) == "localRepository" { + return Ok(true); + } + } + Event::End(_) => depth = depth.saturating_sub(1), + _ => {} + } + buffer.clear(); + } +} + +fn write_local_repository_element( + writer: &mut Writer>, + local_repository: &str, +) -> Result<(), CoreError> { + write_settings_event(writer, Event::Start(BytesStart::new("localRepository")))?; + write_local_repository_text(writer, local_repository)?; + write_settings_event(writer, Event::End(BytesEnd::new("localRepository"))) +} + +/// Writes the repository path as escaped text so a path containing `&` or `<` +/// cannot corrupt the generated document. +fn write_local_repository_text( + writer: &mut Writer>, + local_repository: &str, +) -> Result<(), CoreError> { + write_settings_event(writer, Event::Text(BytesText::new(local_repository))) +} + +fn write_settings_event(writer: &mut Writer>, event: Event<'_>) -> Result<(), CoreError> { + writer.write_event(event).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Could not write the generated Maven settings", + ) + .with_details(error.to_string()) + }) +} + +fn settings_parse_error(error: quick_xml::Error) -> CoreError { + CoreError::new(ErrorCode::ParseFailed, "Could not read Maven settings.xml") + .with_details(error.to_string()) +} + +fn validated_maven_context( + root: &str, + context: MavenLaunchContextRequest, +) -> Result { + let workspace_root = existing_root(root)?; + if context.version != MAVEN_CONTEXT_VERSION { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Maven context version", + ) + .with_details(context.version.to_string())); + } + + let reactor_path = normalized_project_path(&context.reactor_path, "Maven reactor")?; + let reactor_root = if reactor_path == "." { + workspace_root.clone() + } else { + workspace_root.join(&reactor_path) + }; + let canonical_reactor = fs::canonicalize(&reactor_root).map_err(|_| { + CoreError::new(ErrorCode::WorkspaceNotFound, "Maven reactor does not exist") + })?; + if !canonical_reactor.starts_with(&workspace_root) + || !fs::is_file(canonical_reactor.join("pom.xml")).map_err(CoreError::from)? + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven reactor must contain pom.xml inside the workspace", + )); + } + + Ok(ValidatedMavenContext { + reactor_path, + canonical_reactor, + profiles: normalized_profiles(context.profiles)?, + settings_path: normalized_local_path(context.settings_path, "Maven settings")?, + local_repository_path: normalized_local_path( + context.local_repository_path, + "Maven local repository", + )?, + skip_tests: context.skip_tests, + maven_executable_path: normalized_local_path( + context.maven_executable_path, + "Maven executable", + )?, + java_home_path: normalized_local_path(context.java_home_path, "Maven JDK")?, + }) +} + +/// Builds the shared Maven option prefix used by tool-window and run plans. +fn maven_arguments( + profiles: &[String], + settings_path: Option<&str>, + local_repository_path: Option<&str>, + module: Option<&str>, + also_make: bool, + skip_tests: bool, + goals: &[String], +) -> Vec { + let mut arguments = vec!["-B".to_string(), "-ntp".to_string()]; + if !profiles.is_empty() { + arguments.extend(["-P".to_string(), profiles.join(",")]); + } + if let Some(settings_path) = settings_path { + arguments.extend(["-s".to_string(), settings_path.to_string()]); + } + if let Some(local_repository_path) = local_repository_path { + arguments.push(format!("-Dmaven.repo.local={local_repository_path}")); + } + if let Some(module) = module.filter(|value| *value != ".") { + arguments.extend(["-pl".to_string(), module.to_string()]); + if also_make { + arguments.push("-am".to_string()); + } + } + if skip_tests { + arguments.push("-DskipTests".to_string()); + } + arguments.extend(goals.iter().cloned()); + arguments +} + +fn normalized_profiles(values: Vec) -> Result, CoreError> { + let mut profiles = BTreeSet::new(); + for value in values { + let profile = value.trim(); + if profile.is_empty() || profile.contains(',') || profile.chars().any(char::is_control) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven profile ID is invalid", + )); + } + profiles.insert(profile.to_string()); + } + Ok(profiles.into_iter().collect()) +} + +fn normalized_tool_window_arguments(values: Vec) -> Result, CoreError> { + if values.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "At least one Maven goal is required", + )); + } + + let mut arguments = Vec::with_capacity(values.len()); + for (index, value) in values.into_iter().enumerate() { + let argument = value.trim(); + if argument.is_empty() || argument.chars().any(char::is_control) { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Maven argument is invalid") + .with_details(argument), + ); + } + if index == 0 { + let valid_goal = !argument.starts_with('-') + && argument.chars().all(|character| { + character.is_ascii_alphanumeric() || ".:_-".contains(character) + }); + if !valid_goal { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Maven goal is invalid") + .with_details(argument), + ); + } + } + arguments.push(argument.to_string()); + } + Ok(arguments) +} + +fn normalized_project_path(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed == "." { + return Ok(".".to_string()); + } + normalize_relative_path(trimmed).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("{label} path is invalid"), + ) + }) +} + +fn normalized_local_path(value: Option, label: &str) -> Result, CoreError> { + let Some(value) = value else { return Ok(None) }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.chars().any(char::is_control) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("{label} path is invalid"), + )); + } + Ok(Some(trimmed.to_string())) +} + +fn maven_context_fingerprint( + reactor_path: &str, + profiles: &[String], + settings_path: Option<&str>, + local_repository_path: Option<&str>, + skip_tests: bool, + maven_executable_path: Option<&str>, + java_home_path: Option<&str>, +) -> String { + let mut digest = Sha256::new(); + for value in [ + MAVEN_CONTEXT_VERSION.to_string(), + reactor_path.to_string(), + profiles.join(","), + settings_path.unwrap_or_default().to_string(), + local_repository_path.unwrap_or_default().to_string(), + skip_tests.to_string(), + maven_executable_path.unwrap_or_default().to_string(), + java_home_path.unwrap_or_default().to_string(), + ] { + digest.update(value.as_bytes()); + digest.update([0]); + } + format!("sha256:{:x}", digest.finalize()) +} + +#[derive(Debug, Default, Clone)] +/// Parsed POM fields needed by reactor discovery and run-configuration detection. +struct Descriptor { + group_id: Option, + artifact_id: Option, + version: Option, + /// Coordinates of ``, recorded so a module can be matched back to the + /// reactor entry it inherits from. Maven identifies a parent by all three + /// coordinates: a shared `artifactId` does not prove the relationship, so a + /// module whose parent lives outside this reactor keeps its own plugins. + parent_group_id: Option, + parent_artifact_id: Option, + parent_version: Option, + packaging: String, + build_directory: Option, + /// `` values configured for Surefire or Failsafe, raw. + test_report_directories: Vec, + module_paths: Vec, + profiles: Vec, + plugins: Vec, + source_directory: Option, + test_source_directory: Option, + resource_directories: Vec, + test_resource_directories: Vec, + generated_source_directories: Vec, + generated_test_source_directories: Vec, +} + +impl Descriptor { + /// The coordinates a child must name in `` to inherit from this POM. + /// + /// `groupId` and `version` fall back to what `` declares, which is the + /// value Maven gives a module that inherits them. A coordinate that is still a + /// `${...}` expression proves nothing -- see `still_an_expression`. + fn coordinates(&self) -> Option { + verified_coordinates( + self.group_id.as_deref(), + self.artifact_id.as_deref(), + self.version.as_deref(), + ) + } + + /// The coordinates this POM names in its own ``, when it declares all + /// three. A partial declaration, or one still carrying a `${...}` expression, + /// names no verifiable parent. + fn parent_key(&self) -> Option { + verified_coordinates( + self.parent_group_id.as_deref(), + self.parent_artifact_id.as_deref(), + self.parent_version.as_deref(), + ) + } +} + +/// Whether a coordinate value is still a `${...}` expression. +/// +/// Maven interpolates expressions before it matches a parent, so the same raw text +/// can resolve to different values: two POMs writing `${revision}` may end up with +/// different versions once their own properties apply. A coordinate that has not +/// been resolved therefore cannot prove which POM a module inherits from, and is +/// refused rather than compared as a literal. +fn still_an_expression(value: &str) -> bool { + value.contains("${") +} + +/// Builds the `groupId:artifactId:version` triple a parent is identified by, or +/// `None` when any part is missing or still an unresolved expression. +fn verified_coordinates( + group_id: Option<&str>, + artifact_id: Option<&str>, + version: Option<&str>, +) -> Option { + let parts = [group_id?, artifact_id?, version?]; + if parts.iter().any(|value| still_an_expression(value)) { + return None; + } + Some(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )) +} + +/// One `` entry as Maven applies it. +/// +/// `inherited` mirrors ``: `false` keeps the plugin on the module that +/// declares it and stops it from reaching any child, which is the only way a +/// parent can ship a framework plugin to itself without making the whole reactor +/// runnable. + +/// Maven's default `` for a plugin declaration that omits one. The +/// `Plugin` model initializes the field to this value, so a bare artifactId is +/// never resolved against another group. +const DEFAULT_PLUGIN_GROUP: &str = "org.apache.maven.plugins"; + +#[derive(Debug, Clone)] +/// Visibility matches `DeclaredModule`, which is re-exported crate-wide. +struct BuildPlugin { + /// The group Maven resolves the plugin under. A declaration that omits + /// `` gets Maven's default of `org.apache.maven.plugins`, so a + /// bare `spring-boot-maven-plugin` names a different plugin than + /// `org.springframework.boot:spring-boot-maven-plugin`. + pub(crate) group_id: String, + pub(crate) artifact_id: String, + pub(crate) inherited: bool, +} + +#[derive(Debug, Default)] +/// Configuration buffered until the owning build plugin's `artifactId` is known. +struct PendingBuildPlugin { + /// ``; absent means Maven's default of `org.apache.maven.plugins`. + group_id: Option, + artifact_id: Option, + /// ``; absent means Maven's default of `true`. + inherited: Option, + compiler_generated_source_directories: Vec, + compiler_generated_test_source_directories: Vec, + build_helper_source_directories: Vec, + build_helper_test_source_directories: Vec, + reports_directories: Vec, +} + +/// One module of the declared build graph, flattened with the root first. +/// +/// Callers inside the core read this instead of walking directories: Maven +/// modules are declared in ``, so a module may sit in a directory the +/// shared detector walk prunes -- one named `build` or `out` is invisible to a +/// directory-driven scan -- while a stray `pom.xml` outside the graph is not a +/// module at all. +#[derive(Debug, Clone)] +pub struct DeclaredModule { + /// Project-relative, forward-slashed, `.` for the root module. + pub relative_path: String, + pub artifact_id: String, + pub packaging: String, + /// Every plugin in the module's effective ``, including the + /// ones it inherits from ``. + pub plugins: Vec, + /// Source roots parsed from this module's own POM. + pub source_roots: Vec, +} + +impl DeclaredModule { + /// Reports whether this module applies a build plugin, whether it declares + /// the plugin itself or inherits it from ``. + /// + /// A plugin is identified by `groupId:artifactId`. Maven gives a declaration + /// that omits `` the default `org.apache.maven.plugins`, so a bare + /// `spring-boot-maven-plugin` names a different artifact than + /// `org.springframework.boot:spring-boot-maven-plugin` -- matching on the + /// artifactId alone would hand the module a framework whose goal it never + /// applied. + pub fn applies_plugin(&self, group_id: &str, artifact_id: &str) -> bool { + self.plugins + .iter() + .any(|plugin| plugin.group_id == group_id && plugin.artifact_id == artifact_id) + } + + /// `pom` packaging is an aggregator: it produces no artifact to run, so a + /// plugin declared there configures its children rather than a service. + pub fn is_aggregator(&self) -> bool { + self.packaging == "pom" + } +} + +/// Reads the declared module graph, or an empty list when the root is not a +/// Maven project. +pub fn declared_modules(root: &Path) -> Result, CoreError> { + let Some(root_descriptor) = descriptor(&root.join("pom.xml"))? else { + return Ok(Vec::new()); + }; + // Inheritance is resolved from the `` coordinates a module names, so + // the reactor is indexed by directory first and the walk reads that resolved + // set instead of assuming an ancestor sits above the module in the module graph. + let resolved = inheritance_index(root, &root_descriptor)?; + let mut modules = Vec::new(); + let mut visited = vec![root.to_path_buf()]; + collect_modules( + root, + root, + root_descriptor, + &mut modules, + &mut visited, + &resolved, + ); + Ok(modules) +} + +/// Full `groupId:artifactId:version` identity of one POM. +/// +/// Maven matches a `` reference against this triple, so a shared +/// `artifactId` cannot prove which POM a module inherits from: a reactor may +/// aggregate a module whose real parent lives outside it under that name. +type ModuleCoordinates = (String, String, String); + +/// Indexes every module the reactor declares by the directory it lives in, with +/// the plugin set it effectively applies. +/// +/// Only the reactor is consulted. A `` this reactor cannot prove -- one +/// whose full coordinates match no module here, including a parent reached through +/// `` outside the workspace or from the local repository -- +/// contributes nothing, which is what keeps a module from being handed a +/// framework the reactor cannot verify. +/// +/// The reader stays deliberately shallow rather than reproducing Maven's +/// effective model: it reads coordinates, ``, and `` +/// with their `` flags, and resolves only parent links the reactor +/// itself proves. Property interpolation, profiles, and `relativePath` resolution +/// stay with Maven, and anything that would need them resolves to no inheritance +/// instead of a guess. +fn inheritance_index( + root: &Path, + root_descriptor: &Descriptor, +) -> Result>, CoreError> { + // Pass one: every POM the reactor declares. Inheritance can only be resolved + // once all of them are known, because a child may name a parent that is not + // its aggregator and sits later in the module graph. + let mut modules: Vec<(PathBuf, Descriptor)> = Vec::new(); + let mut pending = vec![(root.to_path_buf(), root_descriptor.clone())]; + let mut visited = vec![root.to_path_buf()]; + + while let Some((directory, current)) = pending.pop() { + modules.push((directory.clone(), current.clone())); + for raw_path in ¤t.module_paths { + let Some(relative) = normalize_relative_path(raw_path) else { + continue; + }; + let child = directory.join(&relative).clean(); + if visited.iter().any(|path| path == &child) || !child.starts_with(root) { + continue; + } + let Ok(Some(child_descriptor)) = descriptor(&child.join("pom.xml")) else { + continue; + }; + visited.push(child.clone()); + pending.push((child, child_descriptor)); + } + } + + // Two modules sharing one coordinate set leave a child unable to say which it + // inherits, so neither is offered as a parent. + let mut by_coordinates: BTreeMap = BTreeMap::new(); + let mut ambiguous: BTreeSet = BTreeSet::new(); + for (position, (_, module)) in modules.iter().enumerate() { + let Some(coordinates) = module.coordinates() else { + continue; + }; + if by_coordinates.contains_key(&coordinates) { + by_coordinates.remove(&coordinates); + ambiguous.insert(coordinates); + } else if !ambiguous.contains(&coordinates) { + by_coordinates.insert(coordinates, position); + } + } + + // Pass two: resolve from the top down, so every module reads the set its own + // parent ended up with. Reading the ancestors' raw declarations instead would + // let a plugin an intermediate parent stopped reappear below it. + let mut resolved: BTreeMap> = BTreeMap::new(); + let mut visiting: BTreeSet = BTreeSet::new(); + for position in 0..modules.len() { + resolve_plugins( + position, + &modules, + &by_coordinates, + &mut resolved, + &mut visiting, + ); + } + + Ok(modules + .into_iter() + .enumerate() + .map(|(position, (directory, _))| { + ( + directory, + resolved.get(&position).cloned().unwrap_or_default(), + ) + }) + .collect()) +} + +/// Resolves one module's effective ``: its own declarations plus +/// what its parent hands down. +/// +/// `false` stops a plugin at the module that declares it. +/// Because a child reads this resolved set rather than the ancestors' raw +/// declarations, the stop holds for every descendant instead of letting a further +/// ancestor supply the plugin again. +fn resolve_plugins( + position: usize, + modules: &[(PathBuf, Descriptor)], + by_coordinates: &BTreeMap, + resolved: &mut BTreeMap>, + visiting: &mut BTreeSet, +) -> Vec { + if let Some(existing) = resolved.get(&position) { + return existing.clone(); + } + // Two POMs naming each other cannot produce a model; stopping at the repeat + // keeps the walk finite and leaves that module with its own declarations. + if !visiting.insert(position) { + return modules[position].1.plugins.clone(); + } + + let descriptor = &modules[position].1; + let inherited = descriptor + .parent_key() + .and_then(|key| by_coordinates.get(&key).copied()) + .filter(|parent| *parent != position) + .map(|parent| resolve_plugins(parent, modules, by_coordinates, resolved, visiting)) + .unwrap_or_default(); + visiting.remove(&position); + + let effective = merge_inherited(&descriptor.plugins, &inherited); + resolved.insert(position, effective.clone()); + effective +} + +/// Merges what a module declares with what its parent hands down. +/// +/// The module's own declaration wins, matching the single `groupId:artifactId` +/// Maven merges the two under, and only plugins the parent passes on are +/// considered at all. +fn merge_inherited(own: &[BuildPlugin], inherited: &[BuildPlugin]) -> Vec { + let mut merged = own.to_vec(); + for plugin in inherited { + if plugin.inherited + && !merged.iter().any(|existing| { + existing.group_id == plugin.group_id && existing.artifact_id == plugin.artifact_id + }) + { + merged.push(plugin.clone()); + } + } + merged +} +/// Flattens the graph depth-first. Unlike `module`, which builds the nested +/// response, a visited path is never released: a module reachable through two +/// parents is one module, and emitting it twice would produce two run +/// configurations for one service. +fn collect_modules( + root: &Path, + directory: &Path, + current: Descriptor, + modules: &mut Vec, + visited: &mut Vec, + resolved: &BTreeMap>, +) { + let relative_path = directory + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/")) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| ".".to_string()); + let current_source_roots = source_roots(Some(¤t)); + // The index already resolved this directory against the `` it names, + // so these are the effective plugins. A directory it did not resolve has no + // provable parent and keeps only what it declares. + let effective_plugins = resolved + .get(directory) + .cloned() + .unwrap_or_else(|| current.plugins.clone()); + modules.push(DeclaredModule { + relative_path, + artifact_id: current.artifact_id.unwrap_or_else(|| { + directory + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("module") + .to_string() + }), + packaging: current.packaging, + plugins: effective_plugins, + source_roots: current_source_roots, + }); + for raw_path in ¤t.module_paths { + let Some(relative) = normalize_relative_path(raw_path) else { + continue; + }; + let child = directory.join(&relative).clean(); + if visited.iter().any(|path| path == &child) || !child.starts_with(root) { + continue; + } + let Ok(Some(child_descriptor)) = descriptor(&child.join("pom.xml")) else { + continue; + }; + visited.push(child.clone()); + collect_modules(root, &child, child_descriptor, modules, visited, resolved); + } +} + +/// Reads a Maven reactor and returns its nested module, profile, and identity data. +pub fn scan(request: MavenScanRequest) -> Result, CoreError> { + let workspace_root = existing_root(&request.root)?; + let Some((root, relative_path)) = maven_root(&workspace_root, &request.paths)? else { + return Ok(None); + }; + let pom = root.join("pom.xml"); + let Some(root_descriptor) = contained_descriptor(&workspace_root, &pom)? else { + return Ok(None); + }; + + let mut visited = vec![root.clone()]; + let modules = root_descriptor + .module_paths + .iter() + .filter_map(|path| module(&root, &root, path, &mut visited)) + .collect(); + let source_roots = source_roots(Some(&root_descriptor)); + + Ok(Some(MavenScanResponse { + relative_path, + group_id: root_descriptor.group_id, + artifact_id: root_descriptor.artifact_id.unwrap_or_else(|| { + root.file_name() + .and_then(|v| v.to_str()) + .unwrap_or("Project") + .to_string() + }), + version: root_descriptor.version, + packaging: root_descriptor.packaging, + source_roots, + modules, + profiles: root_descriptor.profiles, + has_wrapper: has_wrapper(&root), + })) +} + +/// Selects one parseable Maven root from visible workspace paths and their +/// ancestors. The Maven project scan currently represents one reactor, so the +/// shallowest valid descriptor wins; lexical ordering makes independent candidates +/// deterministic. Parse failures are retained only when no candidate is valid. +fn maven_root(root: &Path, paths: &[String]) -> Result, CoreError> { + let canonical_root = fs::canonicalize(root).map_err(CoreError::from)?; + let mut candidate_directories = BTreeSet::from([PathBuf::new()]); + for path in paths + .iter() + .filter_map(|path| normalize_relative_path(path)) + { + let mut directory = Path::new(&path).parent(); + while let Some(relative) = directory { + candidate_directories.insert(relative.to_path_buf()); + if relative.as_os_str().is_empty() { + break; + } + directory = relative.parent(); + } + } + + let mut candidates = candidate_directories + .into_iter() + .filter_map(|directory| { + let candidate = root.join(&directory); + if !fs::is_file(candidate.join("pom.xml")).ok()? { + return None; + } + let canonical_candidate = fs::canonicalize(&candidate).ok()?; + if !canonical_candidate.starts_with(&canonical_root) { + return None; + } + let relative_path = if directory.as_os_str().is_empty() { + ".".to_string() + } else { + directory.to_string_lossy().replace('\\', "/") + }; + let depth = directory.components().count(); + Some((candidate, relative_path, depth)) + }) + .collect::>(); + candidates.sort_by(|left, right| { + left.2 + .cmp(&right.2) + .then_with(|| left.1.to_lowercase().cmp(&right.1.to_lowercase())) + .then_with(|| left.1.cmp(&right.1)) + }); + candidates.dedup_by(|left, right| left.0 == right.0); + + let mut first_parse_error = None; + for (path, relative_path, _) in candidates { + match descriptor(&path.join("pom.xml")) { + Ok(Some(_)) => return Ok(Some((path, relative_path))), + Ok(None) => {} + Err(error) if first_parse_error.is_none() => first_parse_error = Some(error), + Err(_) => {} + } + } + match first_parse_error { + Some(error) => Err(error), + None => Ok(None), + } +} + +/// Parses Maven compiler output into stable workspace-relative diagnostics. +pub fn diagnostics( + request: MavenDiagnosticsRequest, +) -> Result { + existing_root(&request.root)?; + let expression = Regex::new(r"\[(ERROR|WARNING)]\s+(.*?):\[(\d+)(?:,(\d+))?]\s+(.*)") + .expect("static Maven diagnostic expression is valid"); + let mut seen = HashSet::new(); + let issues = request + .output + .lines() + .filter_map(|line| { + let captures = expression.captures(line)?; + let path = captures.get(2)?.as_str().trim().to_string(); + let line_number = captures.get(3)?.as_str().parse().ok()?; + let column = captures + .get(4) + .and_then(|value| value.as_str().parse().ok()); + let severity = captures.get(1)?.as_str().to_lowercase(); + let message = captures.get(5)?.as_str().trim().to_string(); + let key = ( + path.clone(), + line_number, + column, + severity.clone(), + message.clone(), + ); + seen.insert(key).then_some(MavenDiagnosticResponse { + path, + line: line_number, + column, + severity, + message, + }) + }) + .collect(); + Ok(MavenDiagnosticsResponse { issues }) +} + +#[derive(Clone, Copy)] +enum MavenTestFailureKind { + Failure, + Error, +} + +impl MavenTestFailureKind { + fn as_str(self) -> &'static str { + match self { + Self::Failure => "failure", + Self::Error => "error", + } + } +} + +/// Parses the common text reporter used by Maven Surefire and Failsafe. +/// +/// The summary and failure details come from bounded process output. When the +/// request names the run's module and classes, per-method outcomes are also +/// read from the XML reports that run wrote, because the text reporter names +/// only failing tests. +pub fn test_results( + request: MavenTestResultsRequest, +) -> Result { + let workspace_root = existing_root(&request.root)?; + if request + .output + .chars() + .nth(MAX_MAVEN_TEST_OUTPUT_CHARACTERS) + .is_some() + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven test output exceeds the supported limit", + ) + .with_details(format!( + "maximumCharacters={MAX_MAVEN_TEST_OUTPUT_CHARACTERS}" + ))); + } + + let ansi = + Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").expect("static ANSI escape expression is valid"); + let summary_expression = Regex::new( + r"(?i)^Tests\s+run:\s*(\d+)\s*,\s*Failures:\s*(\d+)\s*,\s*Errors:\s*(\d+)\s*,\s*(?:Skipped|Ignored):\s*(\d+)(?:\s+-+\s+in\s+(.+)|,\s*Time elapsed:.*)?\s*$", + ) + .expect("static Maven test summary expression is valid"); + let failure_expression = + Regex::new(r#"^(?:\d+\)\s*)?([A-Za-z_$][A-Za-z0-9_.$#<>$\[\]'\" ()-]*?)(?:\s*:\s*(.*))?$"#) + .expect("static Maven test failure expression is valid"); + let detailed_failure_expression = Regex::new( + r#"^([A-Za-z_$][A-Za-z0-9_.$#<>$\[\]'\" ()-]*?)\s+(?:--\s+)?Time elapsed:.*<<<\s+(FAILURE|ERROR)!\s*$"#, + ) + .expect("static Maven detailed failure expression is valid"); + let stack_expression = Regex::new( + r"^at\s+([A-Za-z_$][A-Za-z0-9_.$]*)(?:\.[A-Za-z_$][A-Za-z0-9_$<>]*)?\((.*?\.java):(\d+)\)$", + ) + .expect("static Maven test stack expression is valid"); + + let mut footer_summary = (0_usize, 0_usize, 0_usize, 0_usize); + let mut class_summary = (0_usize, 0_usize, 0_usize, 0_usize); + let mut saw_footer_summary = false; + let mut saw_class_summary = false; + let mut section = None; + let mut current_failure = None; + let mut failure_details: Vec = Vec::new(); + let mut source_index = None; + let mut source_cache = HashMap::new(); + + for raw_line in request.output.lines() { + lithe_core::check_cancellation()?; + let clean_line = ansi.replace_all(raw_line, ""); + let line = strip_maven_log_prefix(clean_line.as_ref()); + let trimmed = line.trim(); + if trimmed.eq_ignore_ascii_case("Results:") { + section = None; + current_failure = None; + continue; + } + if let Some(captures) = summary_expression.captures(line.trim()) { + let parsed = ( + captures[1].parse::().unwrap_or(0), + captures[2].parse::().unwrap_or(0), + captures[3].parse::().unwrap_or(0), + captures[4].parse::().unwrap_or(0), + ); + let is_class_summary = captures.get(5).is_some() || trimmed.contains(", Time elapsed:"); + if is_class_summary { + class_summary.0 = class_summary.0.saturating_add(parsed.0); + class_summary.1 = class_summary.1.saturating_add(parsed.1); + class_summary.2 = class_summary.2.saturating_add(parsed.2); + class_summary.3 = class_summary.3.saturating_add(parsed.3); + saw_class_summary = true; + } else { + footer_summary.0 = footer_summary.0.saturating_add(parsed.0); + footer_summary.1 = footer_summary.1.saturating_add(parsed.1); + footer_summary.2 = footer_summary.2.saturating_add(parsed.2); + footer_summary.3 = footer_summary.3.saturating_add(parsed.3); + saw_footer_summary = true; + } + // A summary terminates both the failure list and any preceding + // detailed failure. Do not let reactor diagnostics inherit it. + section = None; + current_failure = None; + continue; + } + + if let Some(captures) = detailed_failure_expression.captures(trimmed) { + let name = captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default(); + if looks_like_test_name(name) { + let kind = match captures.get(2).map(|value| value.as_str()) { + Some("ERROR") => MavenTestFailureKind::Error, + _ => MavenTestFailureKind::Failure, + }; + current_failure = Some(record_maven_failure( + &mut failure_details, + name, + kind, + None, + )?); + } + continue; + } + + if trimmed.eq_ignore_ascii_case("Failures:") + || trimmed.eq_ignore_ascii_case("Failed tests:") + { + section = Some(MavenTestFailureKind::Failure); + current_failure = None; + continue; + } + if trimmed.eq_ignore_ascii_case("Errors:") { + section = Some(MavenTestFailureKind::Error); + current_failure = None; + continue; + } + if trimmed.eq_ignore_ascii_case("Tests run:") || trimmed.starts_with("Tests run:") { + section = None; + current_failure = None; + continue; + } + + if let Some(captures) = stack_expression.captures(trimmed) { + if let Some(index) = current_failure { + if failure_details + .get(index) + .is_some_and(|detail| detail.path.is_some()) + { + continue; + } + let line_number = captures[3].parse::().ok(); + let location = if let Some(line_number) = line_number { + resolve_test_source_path( + &workspace_root, + captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default(), + captures + .get(2) + .map(|value| value.as_str()) + .unwrap_or_default(), + &mut source_index, + &mut source_cache, + )? + .map(|path| (path, line_number)) + } else { + None + }; + if let Some(detail) = failure_details.get_mut(index) { + let detail: &mut MavenTestFailureResponse = detail; + if let Some((path, line_number)) = location { + detail.path = Some(path); + detail.line = Some(line_number); + } + } + } + continue; + } + + let Some(kind) = section else { continue }; + let Some(captures) = failure_expression.captures(trimmed) else { + continue; + }; + let name = captures + .get(1) + .map(|value| value.as_str().trim()) + .unwrap_or_default(); + if !looks_like_test_name(name) { + continue; + } + let raw_message = captures + .get(2) + .map(|value| value.as_str().trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string); + // Surefire's compact footer encodes the source line as + // `TestName:line message`. Preserve a detailed entry's location, or use + // the footer line when legacy output provides no usable stack frame. + let (footer_line, message) = raw_message.map_or((None, None), |value| { + let mut parts = value.splitn(2, char::is_whitespace); + match parts.next() { + Some(token) if token.parse::().is_ok() => ( + token.parse::().ok(), + parts + .next() + .map(str::trim) + .filter(|rest| !rest.is_empty()) + .map(str::to_string), + ), + _ => (None, Some(value)), + } + }); + let index = record_maven_failure(&mut failure_details, name, kind, message)?; + if let Some(line_number) = footer_line { + resolve_footer_source_location( + &workspace_root, + &mut failure_details[index], + name, + line_number, + &mut source_index, + &mut source_cache, + )?; + } + current_failure = Some(index); + } + + let (tests_run, failures, errors, skipped) = if saw_footer_summary { + footer_summary + } else if saw_class_summary { + class_summary + } else { + let failures = failure_details + .iter() + .filter(|detail| detail.kind == "failure") + .count(); + let errors = failure_details + .iter() + .filter(|detail| detail.kind == "error") + .count(); + (failures + errors, failures, errors, 0) + }; + let passed = tests_run.saturating_sub(failures + errors + skipped); + let test_cases = match request.reports.as_ref() { + Some(reports) => { + let module_root = test_reports::module_root(&workspace_root, reports)?; + test_reports::read_test_cases( + &module_root, + &test_report_directories(&module_root)?, + reports, + )? + } + None => Vec::new(), + }; + Ok(MavenTestResultsResponse { + tests_run, + failures, + errors, + skipped, + passed, + success: failures == 0 && errors == 0, + failure_details, + test_cases, + }) +} + +/// Module-relative directories that may hold this module's test reports. +/// +/// Configured `` values come first; the Surefire and +/// Failsafe defaults under the build directory are always searched too, +/// because configuring one plugin leaves the other on its default. Values +/// that use properties Core cannot resolve are skipped rather than guessed. +fn test_report_directories(module_root: &Path) -> Result, CoreError> { + let descriptor = descriptor(&module_root.join("pom.xml"))?; + let build_directory = descriptor + .as_ref() + .and_then(|descriptor| descriptor.build_directory.as_deref()); + let mut directories = descriptor + .as_ref() + .map(|descriptor| { + descriptor + .test_report_directories + .iter() + .filter_map(|directory| normalize_maven_source_path(directory, build_directory)) + .collect::>() + }) + .unwrap_or_default(); + if let Some(build_directory) = normalize_build_directory(build_directory) { + directories.extend(test_reports::default_report_directories(&build_directory)); + } + let mut seen = std::collections::HashSet::new(); + directories.retain(|directory| seen.insert(directory.clone())); + Ok(directories) +} + +fn strip_maven_log_prefix(raw_line: &str) -> &str { + let mut line = raw_line.trim_start(); + loop { + let Some(rest) = line.strip_prefix('[') else { + break; + }; + let Some(end) = rest.find(']') else { break }; + let prefix = &rest[..end]; + if !matches!(prefix, "INFO" | "ERROR" | "WARNING" | "DEBUG") { + break; + } + line = rest[end + 1..].trim_start(); + } + line +} + +fn looks_like_test_name(name: &str) -> bool { + !name.is_empty() + && (name.contains('(') + || name.contains('#') + || name.contains('.') + || name.ends_with("Test") + || name.ends_with("Tests")) + && !name.ends_with("Exception") + && !name.ends_with("Error") +} + +fn same_maven_test_name(left: &str, right: &str) -> bool { + let left = normalized_maven_test_name(left); + let right = normalized_maven_test_name(right); + left == right + || left + .strip_suffix(&right) + .is_some_and(|prefix| prefix.ends_with('.')) + || right + .strip_suffix(&left) + .is_some_and(|prefix| prefix.ends_with('.')) +} + +fn normalized_maven_test_name(name: &str) -> String { + let Some(opening) = name.rfind('(') else { + return name.to_string(); + }; + if !name.ends_with(')') { + return name.to_string(); + } + let class_name = &name[(opening + 1)..name.len() - 1]; + if !class_name.contains('.') || class_name.chars().any(char::is_whitespace) { + return name.to_string(); + } + format!("{class_name}.{}", &name[..opening]) +} + +fn record_maven_failure( + details: &mut Vec, + name: &str, + kind: MavenTestFailureKind, + message: Option, +) -> Result { + if let Some(index) = details + .iter() + .position(|detail| same_maven_test_name(&detail.name, name)) + { + if let Some(message) = message { + if details[index].message.is_none() { + details[index].message = Some(message); + } + } + return Ok(index); + } + if details.len() >= MAX_MAVEN_TEST_FAILURES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven test failure count exceeds the supported limit", + ) + .with_details(format!("maximumFailures={MAX_MAVEN_TEST_FAILURES}"))); + } + details.push(MavenTestFailureResponse { + name: name.to_string(), + kind: kind.as_str().to_string(), + message, + path: None, + line: None, + column: None, + }); + Ok(details.len() - 1) +} + +fn resolve_test_source_path( + root: &Path, + class_name: &str, + file_name: &str, + source_index: &mut Option, + source_cache: &mut HashMap<(String, String), Option>, +) -> Result, CoreError> { + lithe_core::check_cancellation()?; + let cache_key = (class_name.to_string(), file_name.to_string()); + if let Some(cached) = source_cache.get(&cache_key) { + return Ok(cached.clone()); + } + let file_path = Path::new(file_name); + if file_path.is_absolute() { + if let Some(path) = workspace_relative_path(root, file_path) { + source_cache.insert(cache_key, Some(path.clone())); + return Ok(Some(path)); + } + } + + let class_name = class_name + .rsplit_once('.') + .map(|(class_name, _)| class_name) + .unwrap_or(class_name) + .split('$') + .next() + .unwrap_or(class_name); + let class_path = class_name.replace('.', "/") + ".java"; + let simple_file_name = Path::new(file_name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + if source_index.is_none() { + *source_index = Some(MavenTestSourceIndex::build(root)?); + } + let index = source_index + .as_ref() + .expect("source index should exist after construction"); + let resolved = index.resolve(&class_path, simple_file_name); + source_cache.insert(cache_key, resolved.clone()); + Ok(resolved) +} + +fn resolve_footer_source_location( + root: &Path, + detail: &mut MavenTestFailureResponse, + name: &str, + line_number: usize, + source_index: &mut Option, + source_cache: &mut HashMap<(String, String), Option>, +) -> Result<(), CoreError> { + if detail.path.is_some() { + return Ok(()); + } + let normalized = normalized_maven_test_name(name); + let Some((class_name, _)) = normalized.rsplit_once('.') else { + return Ok(()); + }; + let simple_class = class_name + .rsplit('.') + .next() + .unwrap_or(class_name) + .split('$') + .next() + .unwrap_or(class_name); + let file_name = format!("{simple_class}.java"); + if let Some(path) = + resolve_test_source_path(root, &normalized, &file_name, source_index, source_cache)? + { + detail.path = Some(path); + detail.line = Some(line_number); + } + Ok(()) +} + +/// One lazily built, parse-wide index bounds source traversal to 10,000 +/// directories total instead of repeating that cost for every stack frame. +struct MavenTestSourceIndex { + paths: Vec, + complete: bool, +} + +impl MavenTestSourceIndex { + fn build(root: &Path) -> Result { + let mut directories = vec![root.to_path_buf()]; + let mut paths = Vec::new(); + let mut visited = 0; + let mut complete = true; + while let Some(directory) = directories.pop() { + lithe_core::check_cancellation()?; + if visited >= MAX_MAVEN_TEST_SOURCE_SEARCH_DIRECTORIES { + complete = false; + break; + } + visited += 1; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(_) => { + complete = false; + continue; + } + }; + let mut children = Vec::new(); + for entry in entries { + lithe_core::check_cancellation()?; + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + complete = false; + continue; + } + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => { + complete = false; + continue; + } + }; + if file_type.is_dir() { + if !should_skip_test_source_directory(&entry.path()) { + children.push((entry.path(), true)); + } + } else if file_type.is_file() + && matches!( + entry.path().extension().and_then(|value| value.to_str()), + Some("java" | "kt") + ) + { + children.push((entry.path(), false)); + } + } + children.sort_by(|left, right| { + left.0 + .to_string_lossy() + .to_ascii_lowercase() + .cmp(&right.0.to_string_lossy().to_ascii_lowercase()) + .then_with(|| left.0.cmp(&right.0)) + }); + for (path, is_directory) in children.into_iter().rev() { + if is_directory { + directories.push(path); + } else if let Ok(relative) = path.strip_prefix(root) { + // Traversal starts at root and excludes symlinks via file_type, + // so lexical paths avoid two canonicalizations per source file. + // Absolute paths from process output still require containment checks. + paths.push(relative.to_string_lossy().replace('\\', "/")); + } else { + complete = false; + } + } + } + paths.sort_by(|left, right| { + left.to_ascii_lowercase() + .cmp(&right.to_ascii_lowercase()) + .then_with(|| left.cmp(right)) + }); + Ok(Self { paths, complete }) + } + + fn resolve(&self, class_path: &str, simple_file_name: &str) -> Option { + // A partial index cannot prove uniqueness, so returning any candidate + // would risk navigating to a same-named source in another module. + if !self.complete { + return None; + } + + let mut exact_matches = self + .paths + .iter() + .filter(|path| path_has_suffix(path, class_path)); + if let Some(path) = exact_matches.next() { + return exact_matches.next().is_none().then(|| path.clone()); + } + + let mut file_name_matches = self.paths.iter().filter(|path| { + Path::new(path).file_name().and_then(|value| value.to_str()) == Some(simple_file_name) + }); + let path = file_name_matches.next()?; + file_name_matches.next().is_none().then(|| path.clone()) + } +} + +fn path_has_suffix(path: &str, suffix: &str) -> bool { + path == suffix + || path + .strip_suffix(suffix) + .is_some_and(|prefix| prefix.ends_with('/')) +} + +/// The reactor resolves inheritance through ``, so the parent's full +/// coordinates have to survive parsing -- they are what proves which reactor POM +/// a module inherits from. The parent's values also become the module's own +/// `groupId`/`version` when it declares neither, so this asserts both readings. +#[test] +fn descriptor_keeps_the_parent_coordinates() { + let dir = std::env::temp_dir().join(format!("lithe-descriptor-parent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pom = dir.join("pom.xml"); + std::fs::write( + &pom, + "com.examplethe-parent1the-child", + ) + .unwrap(); + + let parsed = descriptor(&pom).unwrap().expect("descriptor"); + assert_eq!(parsed.artifact_id.as_deref(), Some("the-child")); + assert_eq!( + parsed.parent_key(), + Some(( + "com.example".to_string(), + "the-parent".to_string(), + "1".to_string() + )) + ); + // The module declares neither value itself, so its effective coordinates are + // the parent's pair plus its own `artifactId`. + assert_eq!( + parsed.coordinates(), + Some(( + "com.example".to_string(), + "the-child".to_string(), + "1".to_string() + )) + ); + + std::fs::remove_dir_all(&dir).unwrap(); +} +#[cfg(test)] +mod test_source_index_tests { + use super::MavenTestSourceIndex; + + #[test] + fn source_index_prefers_the_unique_package_path() { + let index = MavenTestSourceIndex { + paths: vec![ + "src/test/java/AppTest.java".to_string(), + "service/src/test/java/com/example/AppTest.java".to_string(), + ], + complete: true, + }; + + assert_eq!( + index.resolve("com/example/AppTest.java", "AppTest.java"), + Some("service/src/test/java/com/example/AppTest.java".to_string()) + ); + } + + #[test] + fn source_index_rejects_ambiguous_or_incomplete_results() { + let duplicate_index = MavenTestSourceIndex { + paths: vec![ + "service-one/src/test/java/com/example/AppTest.java".to_string(), + "service-two/src/test/java/com/example/AppTest.java".to_string(), + ], + complete: true, + }; + let incomplete_index = MavenTestSourceIndex { + paths: vec!["service/src/test/java/com/example/AppTest.java".to_string()], + complete: false, + }; + + assert_eq!( + duplicate_index.resolve("com/example/AppTest.java", "AppTest.java"), + None + ); + assert_eq!( + incomplete_index.resolve("com/example/AppTest.java", "AppTest.java"), + None + ); + } + + #[test] + fn source_index_requires_a_path_boundary_and_unique_filename_fallback() { + let mut index = MavenTestSourceIndex { + paths: vec!["src/test/java/notcom/example/AppTest.java".to_string()], + complete: true, + }; + assert_eq!( + index.resolve("com/example/AppTest.java", "AppTest.java"), + Some("src/test/java/notcom/example/AppTest.java".to_string()) + ); + index.paths.push("other/AppTest.java".to_string()); + // The partial package suffix must not bypass ambiguous filename fallback. + assert_eq!( + index.resolve("com/example/AppTest.java", "AppTest.java"), + None + ); + } +} + +fn should_skip_test_source_directory(path: &Path) -> bool { + matches!( + path.file_name().and_then(|value| value.to_str()), + Some(".git" | ".gradle" | "node_modules" | "target" | "dist") + ) +} + +fn workspace_relative_path(root: &Path, path: &Path) -> Option { + let canonical_root = fs::canonicalize(root).ok()?; + let canonical_path = fs::canonicalize(path).ok()?; + let relative = canonical_path.strip_prefix(canonical_root).ok()?; + Some(relative.to_string_lossy().replace('\\', "/")) +} + +/// Reads a scanned POM only when links still resolve inside its reactor. +fn contained_descriptor(root: &Path, path: &Path) -> Result, CoreError> { + let (Ok(root), Ok(path)) = (fs::canonicalize(root), fs::canonicalize(path)) else { + return Ok(None); + }; + if !path.starts_with(root) { + return Ok(None); + } + descriptor(&path) +} + +fn module( + root: &Path, + base: &Path, + raw_path: &str, + visited: &mut Vec, +) -> Option { + let relative = normalize_relative_path(raw_path)?; + let module_path = base.join(&relative).clean(); + if visited.iter().any(|path| path == &module_path) || !module_path.starts_with(root) { + return None; + } + visited.push(module_path.clone()); + let descriptor = contained_descriptor(root, &module_path.join("pom.xml")) + .ok() + .flatten(); + let child_modules = descriptor + .as_ref() + .map(|value| { + value + .module_paths + .iter() + .filter_map(|path| module(root, &module_path, path, visited)) + .collect() + }) + .unwrap_or_default(); + visited.pop(); + + Some(MavenModuleResponse { + relative_path: module_path + .strip_prefix(root) + .ok()? + .to_string_lossy() + .replace('\\', "/"), + group_id: descriptor.as_ref().and_then(|value| value.group_id.clone()), + artifact_id: descriptor + .as_ref() + .and_then(|value| value.artifact_id.clone()) + .unwrap_or_else(|| { + module_path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("module") + .to_string() + }), + version: descriptor.as_ref().and_then(|value| value.version.clone()), + packaging: descriptor + .as_ref() + .map(|value| value.packaging.clone()) + .unwrap_or_else(|| "jar".to_string()), + source_roots: source_roots(descriptor.as_ref()), + modules: child_modules, + }) +} + +fn source_roots(descriptor: Option<&Descriptor>) -> Vec { + let Some(descriptor) = descriptor else { + return Vec::new(); + }; + + let mut roots = BTreeSet::new(); + let build_directory = descriptor.build_directory.as_deref().unwrap_or("target"); + let has_compiled_output = descriptor.packaging != "pom"; + if has_compiled_output { + add_source_root( + &mut roots, + MavenSourceRootKind::MainJava, + descriptor + .source_directory + .as_deref() + .unwrap_or("src/main/java"), + Some(build_directory), + ); + if descriptor.resource_directories.is_empty() { + add_source_root( + &mut roots, + MavenSourceRootKind::MainResources, + "src/main/resources", + Some(build_directory), + ); + } else { + for path in &descriptor.resource_directories { + add_source_root( + &mut roots, + MavenSourceRootKind::MainResources, + path, + Some(build_directory), + ); + } + } + add_source_root( + &mut roots, + MavenSourceRootKind::TestJava, + descriptor + .test_source_directory + .as_deref() + .unwrap_or("src/test/java"), + Some(build_directory), + ); + if descriptor.test_resource_directories.is_empty() { + add_source_root( + &mut roots, + MavenSourceRootKind::TestResources, + "src/test/resources", + Some(build_directory), + ); + } else { + for path in &descriptor.test_resource_directories { + add_source_root( + &mut roots, + MavenSourceRootKind::TestResources, + path, + Some(build_directory), + ); + } + } + } + + if has_compiled_output { + let default_generated_main = format!("{build_directory}/generated-sources"); + let generated_main = if descriptor.generated_source_directories.is_empty() { + vec![default_generated_main.as_str()] + } else { + descriptor + .generated_source_directories + .iter() + .map(String::as_str) + .collect() + }; + for path in generated_main { + add_source_root( + &mut roots, + MavenSourceRootKind::GeneratedMain, + path, + Some(build_directory), + ); + } + + let default_generated_test = format!("{build_directory}/generated-test-sources"); + let generated_test = if descriptor.generated_test_source_directories.is_empty() { + vec![default_generated_test.as_str()] + } else { + descriptor + .generated_test_source_directories + .iter() + .map(String::as_str) + .collect() + }; + for path in generated_test { + add_source_root( + &mut roots, + MavenSourceRootKind::GeneratedTest, + path, + Some(build_directory), + ); + } + } + + roots + .into_iter() + .map(|(rank, path)| MavenSourceRootResponse { + path, + kind: source_root_kind(rank), + }) + .collect() +} + +fn add_source_root( + roots: &mut BTreeSet<(u8, String)>, + kind: MavenSourceRootKind, + raw_path: &str, + build_directory: Option<&str>, +) { + if let Some(path) = normalize_maven_source_path(raw_path, build_directory) { + roots.insert((source_root_kind_rank(kind), path)); + } +} + +fn source_root_kind_rank(kind: MavenSourceRootKind) -> u8 { + match kind { + MavenSourceRootKind::MainJava => 0, + MavenSourceRootKind::MainResources => 1, + MavenSourceRootKind::TestJava => 2, + MavenSourceRootKind::TestResources => 3, + MavenSourceRootKind::GeneratedMain => 4, + MavenSourceRootKind::GeneratedTest => 5, + } +} + +fn source_root_kind(rank: u8) -> MavenSourceRootKind { + match rank { + 0 => MavenSourceRootKind::MainJava, + 1 => MavenSourceRootKind::MainResources, + 2 => MavenSourceRootKind::TestJava, + 3 => MavenSourceRootKind::TestResources, + 4 => MavenSourceRootKind::GeneratedMain, + _ => MavenSourceRootKind::GeneratedTest, + } +} + +fn normalize_maven_source_path(raw_path: &str, build_directory: Option<&str>) -> Option { + let mut value = raw_path.trim().replace('\\', "/"); + value = value + .replace("${project.basedir}", ".") + .replace("${basedir}", "."); + if value.contains("${project.build.directory}") { + let build_directory = normalize_build_directory(build_directory)?; + value = value.replace("${project.build.directory}", &build_directory); + } + normalize_maven_path_value(&value) +} + +fn normalize_build_directory(raw_path: Option<&str>) -> Option { + let mut value = raw_path.unwrap_or("target").trim().replace('\\', "/"); + value = value + .replace("${project.basedir}", ".") + .replace("${basedir}", "."); + normalize_maven_path_value(&value) +} + +fn normalize_maven_path_value(raw_path: &str) -> Option { + let mut value = raw_path.trim().replace('\\', "/"); + if value.contains("${") || value.contains('}') { + return None; + } + while let Some(stripped) = value.strip_prefix("./") { + value = stripped.to_string(); + } + let path = Path::new(&value); + let is_windows_absolute = value.as_bytes().get(1).is_some_and(|byte| *byte == b':'); + if value.is_empty() + || path.is_absolute() + || is_windows_absolute + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return None; + } + let normalized = value.trim_matches('/').to_string(); + if normalized.is_empty() + || normalized + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return None; + } + Some(normalized) +} + +fn descriptor(path: &Path) -> Result, CoreError> { + let Ok(data) = fs::read(path) else { + return Ok(None); + }; + let mut reader = Reader::from_reader(data.as_slice()); + reader.config_mut().trim_text(true); + let mut buffer = Vec::new(); + let mut stack: Vec<(String, String)> = Vec::new(); + let mut value = Descriptor { + packaging: "jar".to_string(), + ..Descriptor::default() + }; + let mut profile_id = None; + let mut profile_active_by_default = false; + let mut pending_build_plugin = None; + + loop { + match reader.read_event_into(&mut buffer) { + Ok(Event::Start(event)) => { + let name = local_name(event.name().as_ref()); + let starts_build_plugin = name == "plugin" + && stack.len() == 3 + && stack[0].0 == "project" + && stack[1].0 == "build" + && stack[2].0 == "plugins"; + stack.push((name.clone(), String::new())); + if starts_build_plugin { + pending_build_plugin = Some(PendingBuildPlugin::default()); + } + if name == "profile" { + profile_id = None; + profile_active_by_default = false; + } + } + Ok(Event::Empty(event)) => { + let name = local_name(event.name().as_ref()); + if name == "module" { + value.module_paths.push(String::new()); + } + } + Ok(Event::Text(event)) => { + if let Some((_, text)) = stack.last_mut() { + let decoded = event.unescape().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not decode pom.xml") + .with_details(error.to_string()) + })?; + text.push_str(&decoded); + } + } + Ok(Event::End(event)) => { + let name = local_name(event.name().as_ref()); + let Some((_, raw_text)) = stack.pop() else { + return Err(CoreError::new(ErrorCode::ParseFailed, "Malformed pom.xml")); + }; + let text = raw_text.trim().to_string(); + let path = stack + .iter() + .map(|(part, _)| part.as_str()) + .chain(std::iter::once(name.as_str())) + .collect::>() + .join("/"); + match path.as_str() { + "project/groupId" => value.group_id = non_empty(text.clone()), + "project/artifactId" => value.artifact_id = non_empty(text.clone()), + "project/version" => value.version = non_empty(text.clone()), + // `` coordinates are recorded separately from the + // module's own. Maven identifies a parent by all three, so a + // shared `artifactId` alone cannot prove the relationship, and + // they also become the module's own `groupId`/`version` when it + // declares neither -- the effective coordinates Maven resolves. + "project/parent/groupId" => { + value.parent_group_id = non_empty(text.clone()); + if value.group_id.is_none() { + value.group_id = value.parent_group_id.clone(); + } + } + "project/parent/artifactId" => { + value.parent_artifact_id = non_empty(text.clone()) + } + "project/parent/version" => { + value.parent_version = non_empty(text.clone()); + if value.version.is_none() { + value.version = value.parent_version.clone(); + } + } + "project/packaging" => { + value.packaging = + non_empty(text.clone()).unwrap_or_else(|| "jar".to_string()) + } + "project/build/directory" => { + value.build_directory = non_empty(text.clone()) + } + "project/build/sourceDirectory" => { + value.source_directory = non_empty(text.clone()) + } + "project/build/testSourceDirectory" => { + value.test_source_directory = non_empty(text.clone()) + } + "project/build/resources/resource/directory" => { + if let Some(directory) = non_empty(text.clone()) { + value.resource_directories.push(directory); + } + } + "project/build/testResources/testResource/directory" => { + if let Some(directory) = non_empty(text.clone()) { + value.test_resource_directories.push(directory); + } + } + "project/build/plugins/plugin/configuration/generatedSourcesDirectory" + | "project/build/plugins/plugin/executions/execution/configuration/generatedSourcesDirectory" => { + if let Some(directory) = non_empty(text.clone()) { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin + .compiler_generated_source_directories + .push(directory); + } + } + } + "project/build/plugins/plugin/configuration/generatedTestSourcesDirectory" + | "project/build/plugins/plugin/executions/execution/configuration/generatedTestSourcesDirectory" => { + if let Some(directory) = non_empty(text.clone()) { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin + .compiler_generated_test_source_directories + .push(directory); + } + } + } + "project/build/plugins/plugin/configuration/sources/source" + | "project/build/plugins/plugin/executions/execution/configuration/sources/source" => { + if let Some(directory) = non_empty(text.clone()) { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.build_helper_source_directories.push(directory); + } + } + } + "project/build/plugins/plugin/configuration/testSources/testSource" + | "project/build/plugins/plugin/executions/execution/configuration/testSources/testSource" => { + if let Some(directory) = non_empty(text.clone()) { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.build_helper_test_source_directories.push(directory); + } + } + } + "project/build/plugins/plugin/configuration/reportsDirectory" + | "project/build/plugins/plugin/executions/execution/configuration/reportsDirectory" => { + if let Some(directory) = non_empty(text.clone()) { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.reports_directories.push(directory); + } + } + } + "project/modules/module" => { + if let Some(module) = non_empty(text.clone()) { + value.module_paths.push(module); + } + } + // Only `` counts. A plugin under + // `` pins a version for children without + // applying it, and one under `` never runs. + "project/build/plugins/plugin/groupId" => { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.group_id = non_empty(text.clone()); + } + } + "project/build/plugins/plugin/artifactId" => { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.artifact_id = non_empty(text.clone()); + } + } + "project/build/plugins/plugin/inherited" => { + if let Some(plugin) = pending_build_plugin.as_mut() { + plugin.inherited = Some(text.eq_ignore_ascii_case("true")); + } + } + "project/build/plugins/plugin" => { + if let Some(plugin) = pending_build_plugin.take() { + if let Some(artifact_id) = plugin.artifact_id { + let group_id = plugin + .group_id + .unwrap_or_else(|| DEFAULT_PLUGIN_GROUP.to_string()); + match (group_id.as_str(), artifact_id.as_str()) { + ("org.apache.maven.plugins", "maven-compiler-plugin") => { + value.generated_source_directories.extend( + plugin.compiler_generated_source_directories, + ); + value.generated_test_source_directories.extend( + plugin.compiler_generated_test_source_directories, + ); + } + ("org.codehaus.mojo", "build-helper-maven-plugin") => { + value.generated_source_directories + .extend(plugin.build_helper_source_directories); + value.generated_test_source_directories + .extend(plugin.build_helper_test_source_directories); + } + ("org.apache.maven.plugins", "maven-surefire-plugin") + | ("org.apache.maven.plugins", "maven-failsafe-plugin") => { + value + .test_report_directories + .extend(plugin.reports_directories); + } + _ => {} + } + value.plugins.push(BuildPlugin { + group_id, + inherited: plugin.inherited.unwrap_or(true), + artifact_id, + }); + } + } + } + "project/profiles/profile/id" => profile_id = non_empty(text.clone()), + "project/profiles/profile/activation/activeByDefault" => { + profile_active_by_default = text.eq_ignore_ascii_case("true") + } + "project/profiles/profile" => { + if let Some(id) = profile_id.take() { + if !value.profiles.iter().any(|profile| profile.id == id) { + value.profiles.push(MavenProfileResponse { + id, + is_active_by_default: profile_active_by_default, + }); + } + } + } + _ => {} + } + } + Ok(Event::Eof) => break, + Err(error) => { + return Err( + CoreError::new(ErrorCode::ParseFailed, "Could not parse pom.xml") + .with_details(error.to_string()), + ) + } + _ => {} + } + buffer.clear(); + } + if stack.is_empty() { + Ok(Some(value)) + } else { + Err(CoreError::new(ErrorCode::ParseFailed, "Malformed pom.xml")) + } +} + +fn existing_root(value: &str) -> Result { + let path = PathBuf::from(value); + let metadata = fs::metadata(&path) + .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + if !metadata.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace root must be a directory", + )); + } + fs::canonicalize(path).map_err(CoreError::from) +} + +fn normalize_relative_path(value: &str) -> Option { + let path = Path::new(value.trim()); + if path.as_os_str().is_empty() || path.is_absolute() { + return None; + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return None; + } + let value = path.to_string_lossy().replace('\\', "/"); + (!value.is_empty()).then_some(value.trim_matches('/').to_string()) +} + +fn local_name(value: &[u8]) -> String { + String::from_utf8_lossy(value) + .rsplit(':') + .next() + .unwrap_or_default() + .to_string() +} + +fn non_empty(value: String) -> Option { + (!value.is_empty()).then_some(value) +} + +fn has_wrapper(root: &Path) -> bool { + let unix = root.join("mvnw"); + let windows = root.join("mvnw.cmd"); + fs::is_file(windows).unwrap_or(false) + || fs::metadata(unix) + .map(|metadata| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + metadata.is_file() + } + }) + .unwrap_or(false) +} + +trait CleanPath { + fn clean(self) -> PathBuf; +} + +impl CleanPath for PathBuf { + fn clean(self) -> PathBuf { + let mut result = PathBuf::new(); + for component in self.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + other => result.push(other.as_os_str()), + } + } + result + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs b/windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs new file mode 100644 index 000000000..8a50d5003 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs @@ -0,0 +1,572 @@ +//! Per-test outcomes read from Maven Surefire and Failsafe XML reports. +//! +//! The text reporter parsed by `maven.testResults` names only failing tests, +//! so it cannot tell which of a class's methods passed or were skipped. The +//! XML reports Surefire and Failsafe write for every executed class can: each +//! `` records its outcome. Editors use these outcomes for per-method +//! pass/fail markers. +//! +//! Reports outlive the run that wrote them, so a report is read only when it +//! belongs to a requested class and was written at or after the run started. +//! File count, file size, and test-case count are all bounded. +//! +//! Note: the ownership decision is recorded in .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md + +use crate::contracts::MavenTestCaseResponse; +use crate::local_fs as fs; +use lithe_core::{CoreError, ErrorCode}; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, UNIX_EPOCH}; + +/// Largest single report Core reads; larger files are skipped, not truncated. +const MAX_REPORT_BYTES: u64 = 32 * 1024 * 1024; +/// Most report files read for one run. +const MAX_REPORT_FILES: usize = 512; +/// Most aggregated test cases returned for one run. +const MAX_TEST_CASES: usize = 10_000; +/// Longest failure message kept per test case, in characters. +const MAX_MESSAGE_CHARACTERS: usize = 2_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Which reports belong to the run whose output is being parsed. +pub struct MavenTestReportsRequest { + /// Workspace-relative directory of the Maven module that ran the tests; + /// `None` or `.` means the workspace root unless `source_path` is set. + #[serde(default)] + pub module: Option, + /// Workspace-relative test source file, for callers that ran Maven from + /// the reactor root without selecting a module: the module is then the + /// nearest directory above the file that contains a `pom.xml`. + #[serde(default)] + pub source_path: Option, + /// Binary names of the test classes the run selected, such as + /// `demo.OrderServiceTest`. Nested classes (`Outer$Inner`) are included + /// automatically because Surefire reports them in their own files. When + /// empty, every report in the module written by the run is read. + #[serde(default)] + pub classes: Vec, + /// Wall-clock start of the run in milliseconds since the Unix epoch. + /// Reports last modified before this instant belong to an earlier run. + pub not_before_millis: u64, +} + +/// Outcome precedence used when one method has several invocations, such as +/// parameterized or repeated tests: any error or failure marks the method. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Outcome { + Skipped, + Passed, + Failed, + Error, +} + +impl Outcome { + fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Passed => "passed", + Self::Failed => "failed", + Self::Error => "error", + } + } +} + +/// One `` as it appears in a report, before aggregation. +struct ReportedCase { + class_name: String, + method: String, + duration_millis: Option, + outcome: Outcome, + message: Option, +} + +/// Aggregated outcome for one method across its invocations. +struct AggregatedCase { + /// None after any invocation lacks a trustworthy time or the total overflows the host integer range. + duration_millis: Option, + outcome: Outcome, + message: Option, + invocations: usize, +} + +/// Reads the per-method outcomes of the classes a run selected. +/// +/// `module_root` is the canonical module directory and `report_directories` +/// are module-relative directories to search, already resolved from the POM. +/// Missing directories or reports yield no cases for that class, which the +/// platform shows as "no recorded result" rather than as a pass. +pub(crate) fn read_test_cases( + module_root: &Path, + report_directories: &[String], + request: &MavenTestReportsRequest, +) -> Result, CoreError> { + let classes = request + .classes + .iter() + .map(|class_name| class_name.trim()) + .filter(|class_name| !class_name.is_empty()) + .map(|class_name| { + is_binary_class_name(class_name) + .then(|| class_name.to_string()) + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Java test class name") + .with_details(class_name.to_string()) + }) + }) + .collect::, _>>()?; + + let mut report_files = Vec::new(); + for directory in report_directories { + lithe_core::check_cancellation()?; + let directory = module_root.join(directory); + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + // Freshness is checked before the file bound so reports left by + // earlier runs cannot crowd out the ones this run wrote. + let mut names = entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| is_requested_report(name, &classes)) + .filter(|name| is_current_report(&directory.join(name), request.not_before_millis)) + .collect::>(); + // Directory order is file-system specific; sorting keeps the bound + // and the aggregation deterministic. + names.sort(); + report_files.extend(names.into_iter().map(|name| directory.join(name))); + } + report_files.dedup(); + report_files.truncate(MAX_REPORT_FILES); + + let mut aggregated: BTreeMap<(String, String), AggregatedCase> = BTreeMap::new(); + for path in report_files { + lithe_core::check_cancellation()?; + let Ok(data) = fs::read(&path) else { + continue; + }; + // A report Maven is still writing, or one damaged on disk, only loses + // its own markers; the rest of the run is still reported. + let Some(cases) = parse_report(&data) else { + continue; + }; + for case in cases { + if !belongs_to_requested_class(&case.class_name, &classes) { + continue; + } + let key = (case.class_name, case.method); + if !aggregated.contains_key(&key) && aggregated.len() >= MAX_TEST_CASES { + continue; + } + let entry = aggregated.entry(key).or_insert(AggregatedCase { + outcome: case.outcome, + message: None, + invocations: 0, + duration_millis: Some(0), + }); + entry.invocations += 1; + entry.duration_millis = entry + .duration_millis + .zip(case.duration_millis) + .and_then(|(sum, duration)| sum.checked_add(duration)) + .filter(|duration| *duration <= i64::MAX as u64); + if case.outcome >= entry.outcome { + // The first message of the most severe outcome explains the marker. + if case.outcome > entry.outcome || entry.message.is_none() { + entry.message = case.message.or(entry.message.take()); + } + entry.outcome = case.outcome; + } + } + } + + Ok(aggregated + .into_iter() + .map(|((class_name, method), case)| MavenTestCaseResponse { + class_name, + method, + status: case.outcome.as_str().to_string(), + message: matches!(case.outcome, Outcome::Failed | Outcome::Error) + .then_some(case.message) + .flatten(), + invocations: case.invocations, + duration_millis: case.duration_millis, + }) + .collect()) +} + +/// Accepts only Java binary names, which also keeps them from forming paths. +fn is_binary_class_name(value: &str) -> bool { + value.split('.').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_' || first == '$') + && characters.all(|character| { + character.is_alphanumeric() || character == '_' || character == '$' + }) + }) +} + +fn is_requested_report(file_name: &str, classes: &[String]) -> bool { + let Some(class_name) = file_name + .strip_prefix("TEST-") + .and_then(|rest| rest.strip_suffix(".xml")) + else { + return false; + }; + belongs_to_requested_class(class_name, classes) +} + +fn belongs_to_requested_class(class_name: &str, classes: &[String]) -> bool { + // No class list means the run's own reports are identified by time alone. + if classes.is_empty() { + return true; + } + classes.iter().any(|requested| { + class_name == requested + || class_name + .strip_prefix(requested.as_str()) + .is_some_and(|rest| rest.starts_with('$')) + }) +} + +fn is_current_report(path: &Path, not_before_millis: u64) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() || metadata.len() > MAX_REPORT_BYTES { + return false; + } + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .is_some_and(|modified| modified.as_millis() >= u128::from(not_before_millis)) +} + +/// Parses one report, or returns `None` when it is not well-formed XML. +fn parse_report(data: &[u8]) -> Option> { + let mut reader = Reader::from_reader(data); + reader.config_mut().trim_text(true); + let mut buffer = Vec::new(); + let mut cases = Vec::new(); + let mut suite_name: Option = None; + // Only direct `` children decide an outcome; `` + // and similar elements are ignored. + let mut current: Option = None; + let mut depth_in_case = 0_usize; + loop { + let event = reader.read_event_into(&mut buffer).ok()?; + match event { + Event::Start(element) => { + if current.is_some() { + depth_in_case += 1; + if depth_in_case == 1 { + apply_outcome_element(current.as_mut()?, &element); + } + } else if element.local_name().as_ref() == b"testsuite" { + suite_name = attribute(&element, b"name"); + } else if element.local_name().as_ref() == b"testcase" { + current = reported_case(&element, suite_name.as_deref()); + depth_in_case = 0; + // A malformed testcase is skipped with its children. + if current.is_none() { + reader + .read_to_end_into(element.name(), &mut Vec::new()) + .ok()?; + } + } + } + Event::Empty(element) => { + if let Some(case) = current.as_mut() { + if depth_in_case == 0 { + apply_outcome_element(case, &element); + } + } else if element.local_name().as_ref() == b"testcase" { + if let Some(case) = reported_case(&element, suite_name.as_deref()) { + cases.push(case); + } + } + } + Event::End(element) => { + if current.is_some() { + if depth_in_case == 0 && element.local_name().as_ref() == b"testcase" { + cases.push(current.take()?); + } else { + depth_in_case = depth_in_case.saturating_sub(1); + } + } + } + Event::Eof => break, + _ => {} + } + buffer.clear(); + } + current.is_none().then_some(cases) +} + +fn reported_case(element: &BytesStart<'_>, suite_name: Option<&str>) -> Option { + let class_name = attribute(element, b"classname") + .or_else(|| suite_name.map(str::to_string)) + .filter(|name| !name.is_empty())?; + let method = method_name(&attribute(element, b"name")?)?; + Some(ReportedCase { + class_name, + method, + duration_millis: attribute(element, b"time") + .and_then(|time| time.trim().parse::().ok()) + .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|duration| *duration <= i64::MAX as u64), + outcome: Outcome::Passed, + message: None, + }) +} + +/// Reduces a reported test name to the Java method it ran. +/// +/// Surefire reports JUnit 4 and JUnit 5 methods as `name`, and parameterized +/// or repeated invocations as `name(Type)[1]` or `name[1]`. Names that start +/// with a display name instead of a method (`[1] input`) cannot be attributed +/// to a method and are dropped. +fn method_name(reported: &str) -> Option { + let end = reported.find(['(', '[']).unwrap_or(reported.len()); + let method = reported[..end].trim(); + let mut characters = method.chars(); + let is_identifier = characters + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_' || first == '$') + && characters + .all(|character| character.is_alphanumeric() || character == '_' || character == '$'); + is_identifier.then(|| method.to_string()) +} + +fn apply_outcome_element(case: &mut ReportedCase, element: &BytesStart<'_>) { + let outcome = match element.local_name().as_ref() { + b"failure" => Outcome::Failed, + b"error" => Outcome::Error, + b"skipped" => Outcome::Skipped, + // `flakyFailure`/`flakyError` record attempts that a later rerun + // passed; `rerunFailure`/`rerunError` accompany a final failure + // element. Neither changes the recorded outcome on its own. + _ => return, + }; + // A failure outranks a skip marker in the same testcase. + if case.outcome == Outcome::Passed || outcome > case.outcome { + case.outcome = outcome; + case.message = attribute(element, b"message") + .map(|message| { + message + .trim() + .chars() + .take(MAX_MESSAGE_CHARACTERS) + .collect::() + }) + .filter(|message| !message.is_empty()); + } +} + +fn attribute(element: &BytesStart<'_>, name: &[u8]) -> Option { + element + .attributes() + .flatten() + .find(|attribute| attribute.key.local_name().as_ref() == name) + .and_then(|attribute| attribute.unescape_value().ok()) + .map(|value| value.trim().to_string()) +} + +/// Report directories to search when the POM configures none. +pub(crate) fn default_report_directories(build_directory: &str) -> Vec { + vec![ + format!("{build_directory}/surefire-reports"), + format!("{build_directory}/failsafe-reports"), + ] +} + +/// Resolves the module directory named by a request inside the workspace. +pub(crate) fn module_root( + workspace_root: &Path, + request: &MavenTestReportsRequest, +) -> Result { + let module = request + .module + .as_deref() + .map(str::trim) + .filter(|module| !module.is_empty() && *module != "."); + let Some(module) = module else { + return match request.source_path.as_deref().map(str::trim) { + Some(source_path) if !source_path.is_empty() => { + nearest_module(workspace_root, source_path) + } + _ => Ok(workspace_root.to_path_buf()), + }; + }; + let candidate = + fs::canonicalize(workspace_root.join(module.replace('\\', "/"))).map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven test module does not exist", + ) + .with_details(module.to_string()) + })?; + if !candidate.starts_with(workspace_root) || !fs::is_dir(&candidate).map_err(CoreError::from)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven test module must be inside the workspace", + ) + .with_details(module.to_string())); + } + Ok(candidate) +} + +/// The closest directory at or above `source_path`'s parent that holds a +/// `pom.xml`, never leaving the workspace; the workspace root otherwise. +fn nearest_module(workspace_root: &Path, source_path: &str) -> Result { + let source = + fs::canonicalize(workspace_root.join(source_path.replace('\\', "/"))).map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven test source does not exist", + ) + .with_details(source_path.to_string()) + })?; + if !source.starts_with(workspace_root) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven test source must be inside the workspace", + ) + .with_details(source_path.to_string())); + } + let mut directory = source.parent(); + while let Some(candidate) = directory.filter(|candidate| candidate.starts_with(workspace_root)) + { + if fs::is_file(candidate.join("pom.xml")).map_err(CoreError::from)? { + return Ok(candidate.to_path_buf()); + } + directory = candidate.parent(); + } + Ok(workspace_root.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cases(xml: &str) -> Vec<(String, String, Outcome, Option)> { + parse_report(xml.as_bytes()) + .expect("well-formed report") + .into_iter() + .map(|case| (case.class_name, case.method, case.outcome, case.message)) + .collect() + } + + #[test] + fn reads_every_outcome_surefire_records() { + let parsed = cases( + r#" + + + + + stack + log + + + + + +"#, + ); + assert_eq!( + parsed, + vec![ + ( + "demo.OrderTest".into(), + "creates".into(), + Outcome::Passed, + None + ), + ( + "demo.OrderTest".into(), + "refunds".into(), + Outcome::Failed, + Some("expected: <4> but was: <5>".into()) + ), + ( + "demo.OrderTest".into(), + "cancels".into(), + Outcome::Error, + Some("boom".into()) + ), + ( + "demo.OrderTest".into(), + "later".into(), + Outcome::Skipped, + None + ), + ] + ); + } + + // Surefire's rerun feature keeps earlier attempts as child elements; a + // test that eventually passed must not be marked as failed. + #[test] + fn flaky_attempts_that_later_passed_stay_passed() { + let parsed = cases( + r#" + + x + +"#, + ); + assert_eq!(parsed[0].2, Outcome::Passed); + } + + #[test] + fn invocation_names_reduce_to_their_method() { + assert_eq!( + method_name("parameterized(String)[2]").as_deref(), + Some("parameterized") + ); + assert_eq!(method_name("repeated[3]").as_deref(), Some("repeated")); + assert_eq!(method_name("plain").as_deref(), Some("plain")); + assert_eq!(method_name("[1] display name"), None); + assert_eq!(method_name(""), None); + } + + #[test] + fn class_names_that_could_form_paths_are_rejected() { + assert!(is_binary_class_name("demo.Outer$Inner")); + assert!(!is_binary_class_name("../demo.OrderTest")); + assert!(!is_binary_class_name("demo/OrderTest")); + assert!(!is_binary_class_name("demo..OrderTest")); + } + + #[test] + fn nested_class_reports_belong_to_their_outer_class_only() { + let classes = vec!["demo.OrderTest".to_string()]; + assert!(is_requested_report("TEST-demo.OrderTest.xml", &classes)); + assert!(is_requested_report( + "TEST-demo.OrderTest$Refunds.xml", + &classes + )); + assert!(!is_requested_report( + "TEST-demo.OrderTestHelper.xml", + &classes + )); + assert!(!is_requested_report("demo.OrderTest.txt", &classes)); + } + + #[test] + fn truncated_report_is_not_trusted() { + assert!(parse_report( + br#""# + ) + .is_none()); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/mybatis.rs b/windows/tauri/crates/lithe-legacy-java/src/mybatis.rs new file mode 100644 index 000000000..81dc12e53 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/mybatis.rs @@ -0,0 +1,497 @@ +//! Deterministic MyBatis mapper-interface and XML statement indexing. +//! +//! Copied from lithe-core's `languages/mybatis.rs`, which macOS still calls +//! through `mybatis.index`. The Windows host keeps answering that command from +//! this copy until it can install the Java plugin. + +use crate::contracts::{MybatisIndexResponse, MybatisStatementResponse}; +use lithe_core::{CoreError, ErrorCode}; +use regex::Regex; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; +use tree_sitter::{Node, Parser}; + +static XML_COMMENT: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)").expect("literal pattern is valid")); +static MAPPER_TAG: LazyLock = + LazyLock::new(|| Regex::new(r#"(?is)]*)>"#).expect("literal pattern is valid")); +static STATEMENT_TAG: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?is)<(select|insert|update|delete)\b([^>]*)>"#) + .expect("literal pattern is valid") +}); +static ATTRIBUTE_NAMESPACE: LazyLock = LazyLock::new(|| { + Regex::new(r#"namespace\s*=\s*["']([^"']+)["']"#).expect("literal pattern is valid") +}); +static ATTRIBUTE_ID: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bid\s*=\s*["']([^"']+)["']"#).expect("literal pattern is valid") +}); + +/// Regular Java and mapper XML files stay well below this; larger dumps are skipped. +const MAX_SOURCE_BYTES: u64 = 2 * 1024 * 1024; + +#[cfg(test)] +thread_local! { + static DISK_READS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +pub(crate) fn take_mybatis_disk_reads() -> Vec { + DISK_READS.with(|paths| paths.take()) +} + +#[cfg(test)] +fn record_disk_read(path: &str) { + DISK_READS.with(|paths| paths.borrow_mut().push(path.to_string())); +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths used to pair mapper interfaces with XML statements. +pub struct MybatisIndexRequest { + /// Absolute workspace root. Relative paths are rejected. + pub root: String, + /// Workspace-relative Java and XML files to read. + #[serde(default)] + pub paths: Vec, + /// Unsaved buffer text keyed by workspace-relative path. + #[serde(default)] + pub text_overrides: HashMap, +} + +#[derive(Clone)] +struct JavaMethod { + name: String, + line: usize, + column: usize, + end_column: usize, + end_line: usize, +} + +#[derive(Clone)] +struct JavaType { + qualified_name: String, + path: String, + methods: Vec, +} + +struct XmlStatement { + statement_id: String, + kind: String, + line: usize, + column: usize, + end_column: usize, +} + +struct XmlMapper { + namespace: String, + path: String, + statements: Vec, +} + +/// Builds one cross-file MyBatis mapper index without starting a language server. +pub fn mybatis_index(request: MybatisIndexRequest) -> Result { + let root = existing_directory(&request.root)?; + let paths = request + .paths + .into_iter() + .filter_map(|path| normalize_relative(&path)) + .collect::>(); + + let mut parser = Parser::new(); + let java_parser = parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .is_ok(); + + // XML mappers name every Java type worth parsing. A mapper interface lives + // in a file named after its top-level type, which is one segment of some + // namespace (`a.b.Outer.Inner` names `Outer.java`). Parsing every Java + // source instead dominated the index on large projects, and hosts rebuild + // the index whenever a Java buffer changes. + let mut xml_mappers = Vec::new(); + let mut java_paths = Vec::new(); + for path in &paths { + if !is_mybatis_source_path(path) { + continue; + } + if is_java_path(path) { + java_paths.push(path); + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + if let Some(mapper) = xml_mapper(&slash_path(path), &content) { + xml_mappers.push(mapper); + } + } + let mapper_type_names = xml_mappers + .iter() + .flat_map(|mapper| mapper.namespace.split('.')) + .map(str::to_owned) + .collect::>(); + + let mut java_types = Vec::new(); + if java_parser { + for path in java_paths { + let names_mapper_type = path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| mapper_type_names.contains(stem)); + if !names_mapper_type { + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + java_types.extend(java_mapper_types(&mut parser, &slash_path(path), &content)); + } + } + + java_types.sort_by(|left, right| { + left.qualified_name + .cmp(&right.qualified_name) + .then_with(|| left.path.cmp(&right.path)) + }); + xml_mappers.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.path.cmp(&right.path)) + }); + + let mut statements = Vec::new(); + for mapper in &xml_mappers { + let Some(java_type) = java_types + .iter() + .find(|candidate| candidate.qualified_name == mapper.namespace) + else { + continue; + }; + for xml_statement in &mapper.statements { + let Some(method) = java_type + .methods + .iter() + .find(|candidate| candidate.name == xml_statement.statement_id) + else { + continue; + }; + statements.push(MybatisStatementResponse { + id: format!( + "{}#{}:{}:{}", + mapper.namespace, xml_statement.statement_id, mapper.path, xml_statement.line + ), + namespace: mapper.namespace.clone(), + statement_id: xml_statement.statement_id.clone(), + kind: xml_statement.kind.clone(), + java_path: java_type.path.clone(), + java_line: method.line, + java_column: method.column, + java_end_line: method.end_line, + java_end_column: method.end_column, + xml_path: mapper.path.clone(), + xml_line: xml_statement.line, + xml_column: xml_statement.column, + xml_end_column: xml_statement.end_column, + }); + } + } + statements.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.statement_id.cmp(&right.statement_id)) + .then_with(|| left.xml_path.cmp(&right.xml_path)) + .then_with(|| left.xml_line.cmp(&right.xml_line)) + .then_with(|| left.java_path.cmp(&right.java_path)) + .then_with(|| left.java_line.cmp(&right.java_line)) + }); + Ok(MybatisIndexResponse { statements }) +} + +fn java_mapper_types(parser: &mut Parser, path: &str, source: &str) -> Vec { + let Some(tree) = parser.parse(source, None) else { + return Vec::new(); + }; + let bytes = source.as_bytes(); + let package = package_name(tree.root_node(), bytes); + let mut types = Vec::new(); + collect_types(tree.root_node(), path, &package, source, bytes, &mut types); + types +} + +fn collect_types( + root: Node<'_>, + path: &str, + package: &str, + source: &str, + bytes: &[u8], + types: &mut Vec, +) { + // Java expression trees can be thousands of nodes deep even in small files. + // Keep traversal state on the heap instead of consuming the host thread's stack. + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if matches!(node.kind(), "class_declaration" | "interface_declaration") { + if let Some(java_type) = java_type(node, path, package, source, bytes) { + types.push(java_type); + } + } + // Reverse the push order to preserve the recursive walk's source preorder. + for index in (0..node.child_count()).rev() { + if let Some(child) = node.child(index) { + pending.push(child); + } + } + } +} + +fn java_type( + node: Node<'_>, + path: &str, + package: &str, + source: &str, + bytes: &[u8], +) -> Option { + let qualified_name = qualified_type_name(node, package, bytes)?; + let mut methods = Vec::new(); + if let Some(body) = node.child_by_field_name("body") { + collect_abstract_methods(body, source, bytes, &mut methods); + } + Some(JavaType { + qualified_name, + path: path.to_string(), + methods, + }) +} + +fn collect_abstract_methods( + body: Node<'_>, + source: &str, + bytes: &[u8], + methods: &mut Vec, +) { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() != "method_declaration" { + continue; + } + // Methods with a body, including default methods, are not XML-mapped. + if child.child_by_field_name("body").is_some() { + continue; + } + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + let Ok(name) = name_node.utf8_text(bytes) else { + continue; + }; + if methods.iter().any(|existing| existing.name == name) { + continue; + } + let (line, column) = line_column(source, name_node.start_byte()); + let (_, end_column) = line_column(source, name_node.end_byte()); + let (end_line, _) = line_column(source, child.end_byte().saturating_sub(1)); + methods.push(JavaMethod { + name: name.to_string(), + line, + column, + end_column, + end_line, + }); + } +} + +fn package_name(root: Node<'_>, bytes: &[u8]) -> String { + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if child.kind() != "package_declaration" { + continue; + } + let mut inner = child.walk(); + for part in child.children(&mut inner) { + if matches!(part.kind(), "identifier" | "scoped_identifier") { + return part.utf8_text(bytes).unwrap_or_default().to_string(); + } + } + } + String::new() +} + +fn qualified_type_name(mut node: Node<'_>, package: &str, bytes: &[u8]) -> Option { + let mut parts = Vec::new(); + loop { + if matches!(node.kind(), "class_declaration" | "interface_declaration") { + let name = node.child_by_field_name("name")?.utf8_text(bytes).ok()?; + parts.push(name.to_string()); + } + match node.parent() { + Some(parent) => node = parent, + None => break, + } + } + parts.reverse(); + if parts.is_empty() { + return None; + } + let joined = parts.join("."); + Some(if package.is_empty() { + joined + } else { + format!("{package}.{joined}") + }) +} + +fn xml_mapper(path: &str, source: &str) -> Option { + let comments = XML_COMMENT + .find_iter(source) + .map(|matched| matched.range()) + .collect::>(); + let mapper = MAPPER_TAG.captures_iter(source).find(|capture| { + capture + .get(0) + .is_some_and(|matched| !in_ranges(&comments, matched.start())) + })?; + let attributes = mapper.get(1)?.as_str(); + let namespace = ATTRIBUTE_NAMESPACE + .captures(attributes) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_string()) + .filter(|value| !value.is_empty())?; + let mapper_end = mapper.get(0)?.end(); + let mut statements = Vec::new(); + for capture in STATEMENT_TAG.captures_iter(source) { + let Some(full) = capture.get(0) else { continue }; + if full.start() < mapper_end || in_ranges(&comments, full.start()) { + continue; + } + let kind = capture + .get(1) + .map(|value| value.as_str().to_ascii_lowercase())?; + let attributes = capture + .get(2) + .map(|value| value.as_str()) + .unwrap_or_default(); + let Some(id_capture) = ATTRIBUTE_ID.captures(attributes) else { + continue; + }; + let statement_id = id_capture.get(1)?.as_str().trim().to_string(); + if statement_id.is_empty() { + continue; + } + let id_start_in_attributes = id_capture.get(1)?.start(); + let attribute_start = capture + .get(2) + .map(|value| value.start()) + .unwrap_or(full.start()); + let id_start = attribute_start + id_start_in_attributes; + let (line, column) = line_column(source, id_start); + let end_column = column + statement_id.encode_utf16().count(); + statements.push(XmlStatement { + statement_id, + kind, + line, + column, + end_column, + }); + } + statements.sort_by(|left, right| { + left.statement_id + .cmp(&right.statement_id) + .then_with(|| left.line.cmp(&right.line)) + }); + Some(XmlMapper { + namespace, + path: path.to_string(), + statements, + }) +} + +fn is_java_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("java")) +} + +fn is_mybatis_source_path(path: &Path) -> bool { + match path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("java") => true, + Some("xml") => !path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")), + _ => false, + } +} + +fn source_content(root: &Path, path: &Path, overrides: &HashMap) -> Option { + let relative = slash_path(path); + if let Some(text) = overrides.get(&relative) { + return Some(text.clone()); + } + let absolute = root.join(path); + let metadata = fs::metadata(&absolute).ok()?; + if !metadata.is_file() || metadata.len() > MAX_SOURCE_BYTES { + return None; + } + #[cfg(test)] + record_disk_read(&relative); + fs::read_to_string(absolute).ok() +} + +fn existing_directory(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || !path.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "MyBatis index root must be an existing absolute directory", + )); + } + Ok(path) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value); + if path.is_absolute() || value.contains('\0') { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + _ => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn slash_path(path: &Path) -> String { + path.components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +fn in_ranges(ranges: &[std::ops::Range], offset: usize) -> bool { + ranges.iter().any(|range| range.contains(&offset)) +} + +fn line_column(source: &str, byte_offset: usize) -> (usize, usize) { + let prefix = source.get(..byte_offset).unwrap_or(source); + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let last_line_start = prefix.rfind('\n').map(|index| index + 1).unwrap_or(0); + let column = source + .get(last_line_start..byte_offset) + .map(|value| value.encode_utf16().count() + 1) + .unwrap_or(1); + (line, column) +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/run_sources.rs b/windows/tauri/crates/lithe-legacy-java/src/run_sources.rs new file mode 100644 index 000000000..5a505cd43 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/run_sources.rs @@ -0,0 +1,204 @@ +//! Java source listing that follows the run-discovery scan policy. +//! +//! Moved out of lithe-core's `execution/workspace_scan.rs`, where only the +//! Windows host called it. The skipped-directory list stays in Core and is +//! re-exported here, so run discovery and this listing never disagree about +//! which directories hold project sources. + +use lithe_core::CoreError; +use std::collections::BTreeSet; +use std::io; +use std::path::{Path, PathBuf}; + +pub use lithe_core::execution::is_run_scan_excluded_directory; + +/// One directory entry: its path, whether it is a directory, whether it is a link. +type Entry = (PathBuf, bool, bool); + +/// Returns Java paths using the same scan policy as run generation. Failure below +/// the root is tolerated; the subsequent Core fingerprint supplies diagnostics. +/// Stops before opening another directory once the limit is reached, retaining +/// only Java paths from the deterministic traversal rather than a full snapshot. +pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, CoreError> { + list_java_sources_with_reader(root, limit, &read_directory) +} + +fn read_directory(path: &Path) -> io::Result> { + std::fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok((entry.path(), kind.is_dir(), kind.is_symlink())) + }) + .collect() +} + +fn list_java_sources_with_reader( + root: &Path, + limit: usize, + read: &impl Fn(&Path) -> io::Result>, +) -> Result, CoreError> { + let mut paths = Vec::new(); + if limit == 0 { + return Ok(paths); + } + let mut pending = vec![root.to_owned()]; + 'search: while let Some(directory) = pending.pop() { + lithe_core::check_cancellation()?; + let entries = match read(&directory) { + Ok(entries) => entries, + Err(error) if directory == root => return Err(CoreError::from(error)), + // Below the root an unreadable directory only means fewer sources. + Err(_) => continue, + }; + let (files, children) = split_entries(entries)?; + // Children are visited in ascending order: the stack pops the last one. + pending.extend(children); + for name in files { + if name.to_ascii_lowercase().ends_with(".java") { + paths.push(relative(root, &directory.join(name))); + if paths.len() == limit { + break 'search; + } + } + } + } + paths.sort(); + Ok(paths) +} + +/// Separates a directory's file names from the child directories worth visiting. +/// Links are never followed, and names that are not valid UTF-8 are skipped. +fn split_entries(entries: Vec) -> Result<(BTreeSet, Vec), CoreError> { + let mut files = BTreeSet::new(); + let mut children = Vec::new(); + for (child, directory, symlink) in entries { + lithe_core::check_cancellation()?; + let Some(name) = child.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if symlink { + continue; + } + if directory { + if !is_run_scan_excluded_directory(name) { + children.push(child); + } + } else { + files.insert(name.to_owned()); + } + } + children.sort_by(|left, right| right.cmp(left)); + Ok((files, children)) +} + +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn java_source_limit_stops_directory_reads_and_keeps_deterministic_results() { + let root = Path::new("fixture"); + for limit in [1, 8_000] { + let visited = std::cell::RefCell::new(Vec::new()); + let paths = list_java_sources_with_reader(root, limit, &|path| { + visited.borrow_mut().push(path.to_owned()); + if path == root { + Ok(vec![ + (root.join("z-unvisited"), true, false), + (root.join("b-sources"), true, false), + (root.join("a-blocked"), true, false), + (root.join(".pytest_cache"), true, false), + (root.join("link"), true, true), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else if path.ends_with("b-sources") { + // More than the cap in one directory must neither exceed the + // result limit nor open a later directory. Reverse native + // enumeration order to protect deterministic selection. + Ok((0..=limit) + .rev() + .map(|index| (path.join(format!("Main{index:05}.java")), false, false)) + .chain([(path.join("nested"), true, false)]) + .collect()) + } else { + panic!("source budget must prevent reading {}", path.display()); + } + }) + .unwrap(); + assert_eq!( + paths, + (0..limit) + .map(|index| format!("b-sources/Main{index:05}.java")) + .collect::>() + ); + assert_eq!( + *visited.borrow(), + [ + root.to_owned(), + root.join("a-blocked"), + root.join("b-sources") + ] + ); + } + } + + #[test] + fn java_source_zero_limit_does_not_read_the_workspace() { + let paths = list_java_sources_with_reader(Path::new("fixture"), 0, &|_| { + panic!("zero budget must not read directories") + }) + .unwrap(); + assert!(paths.is_empty()); + } + + #[test] + fn unreadable_child_is_skipped_but_the_root_is_required() { + let root = Path::new("fixture"); + let read = |path: &Path| { + if path == root { + Ok(vec![ + (root.join("a-blocked"), true, false), + (root.join("b-sources"), true, false), + (root.join("z-later"), true, false), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(vec![(path.join("Main.java"), false, false)]) + } + }; + assert_eq!( + list_java_sources_with_reader(root, 10, &read).unwrap(), + ["b-sources/Main.java", "z-later/Main.java"] + ); + assert!( + list_java_sources_with_reader(root, 10, &|_| Err(io::Error::from( + io::ErrorKind::PermissionDenied + ))) + .is_err() + ); + } + + #[test] + fn directory_links_do_not_escape_or_reenter_the_scan() { + let root = Path::new("fixture"); + let paths = list_java_sources_with_reader(root, 10, &|path| { + assert_eq!(path, root, "links must never be traversed"); + Ok(vec![ + (root.join("cycle"), true, true), + (root.join("Main.java"), false, false), + ]) + }) + .unwrap(); + assert_eq!(paths, ["Main.java"]); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/spring.rs b/windows/tauri/crates/lithe-legacy-java/src/spring.rs new file mode 100644 index 000000000..55a5fedcb --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/spring.rs @@ -0,0 +1,2448 @@ +//! Deterministic Spring Boot configuration and source semantic indexing. +//! +//! Copied from lithe-core's `languages/spring.rs`, which macOS still calls +//! through `spring.index`. The Windows host keeps answering that command from +//! this copy until it can install the Java plugin. + +use crate::contracts::{ + SpringBeanResponse, SpringConfigurationValueResponse, SpringDiagnosticResponse, + SpringEndpointResponse, SpringIndexResponse, SpringInjectionResponse, + SpringPropertyReferenceResponse, SpringPropertyResponse, +}; +use lithe_core::check_cancellation; +use lithe_core::{CoreError, ErrorCode}; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Condvar, LazyLock, Mutex, OnceLock}; +use std::time::Duration; +use zip::ZipArchive; + +const MAX_METADATA_ARCHIVES: usize = 20_000; + +/// A waiter rechecks cancellation at this interval while another request owns +/// the repository scan. +const REPOSITORY_WAIT_SLICE: Duration = Duration::from_millis(50); + +static REPOSITORY_METADATA_CACHE: OnceLock>>> = + OnceLock::new(); + +/// One repository's metadata cache entry. +/// +/// The first request that misses the cache owns the archive walk; concurrent +/// requests wait on `ready` instead of scanning the same JARs again. +struct RepositoryMetadataSlot { + state: Mutex, + ready: Condvar, +} + +impl RepositoryMetadataSlot { + fn latest_scan(&self) -> u64 { + self.state + .lock() + .expect("repository metadata state should not be poisoned") + .scan() + } +} + +/// Progress of one repository scan and the snapshot it publishes. +/// +/// Every variant carries the number of the latest scan that started, counted +/// from 1. A refresh request records that number when it arrives and accepts +/// only a snapshot from a later scan: a walk that began before the refresh may +/// have listed the repository before new archives were downloaded. +enum RepositoryMetadataState { + /// A request currently owns the scan. + Scanning(u64), + /// A completed snapshot shared by every reader until a refresh claims it. + Ready { + scan: u64, + properties: Arc>, + }, + /// The owner was cancelled or interrupted, so the next reader retries. + Failed(u64), +} + +impl RepositoryMetadataState { + fn scan(&self) -> u64 { + match self { + Self::Scanning(scan) | Self::Failed(scan) | Self::Ready { scan, .. } => *scan, + } + } +} + +/// Outcome of joining a repository scan. +#[derive(Debug)] +enum RepositoryClaim { + /// A snapshot this request may reuse. + Shared(Vec), + /// This request now owns the scan with the given number. + Owned(u64), +} + +/// Publishes `Failed` if the scan owner unwinds without completing the state +/// transition, so waiters never block on a scan that can no longer finish. +struct RepositoryScanCompletion<'a> { + slot: &'a RepositoryMetadataSlot, + published: bool, +} + +impl Drop for RepositoryScanCompletion<'_> { + fn drop(&mut self) { + if self.published { + return; + } + if let Ok(mut state) = self.slot.state.lock() { + if let RepositoryMetadataState::Scanning(scan) = *state { + *state = RepositoryMetadataState::Failed(scan); + } + } + self.slot.ready.notify_all(); + } +} + +/// One Java parameter declaration: optional annotations, an optional `final`, +/// the type, and the parameter name. Record components and constructor +/// parameters share this grammar, so they must not drift into two patterns. +static JAVA_PARAMETER_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + .expect("literal pattern is valid") +}); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths and an optional trusted dependency repository to index. +pub struct SpringIndexRequest { + pub root: String, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub metadata_repository: Option, + /// Trusted dependency repositories whose Spring metadata may be indexed. + #[serde(default)] + pub metadata_repositories: Vec, + /// Forces a dependency metadata rescan. Interactive document updates keep + /// this false so they reuse the process-local repository snapshot. + #[serde(default)] + pub refresh_dependency_metadata: bool, + #[serde(default)] + pub text_overrides: HashMap, +} + +/// Builds one cross-file Spring index without starting an additional server. +pub fn spring_index(request: SpringIndexRequest) -> Result { + check_cancellation()?; + let root = existing_directory(&request.root)?; + let paths = request + .paths + .into_iter() + .filter_map(|path| normalize_relative(&path)) + .collect::>(); + let mut properties = built_in_properties(); + for path in &paths { + check_cancellation()?; + if is_metadata_path(path) { + if let Some(content) = source_content(&root, path, &request.text_overrides) { + append_metadata(&content, None, &mut properties); + } + } + } + let mut repositories = request.metadata_repositories; + if let Some(repository) = request.metadata_repository { + repositories.push(repository); + } + repositories.sort(); + repositories.dedup(); + for repository in repositories { + properties.extend(repository_metadata( + Path::new(&repository), + request.refresh_dependency_metadata, + )?); + } + + let mut java_sources = Vec::new(); + for path in &paths { + check_cancellation()?; + if path.extension().and_then(|value| value.to_str()) == Some("java") { + if let Some(source) = source_content(&root, path, &request.text_overrides) { + java_sources.push((slash_path(path), source)); + } + } + } + append_configuration_properties(&java_sources, &mut properties)?; + deduplicate_properties(&mut properties); + + let mut values = Vec::new(); + for path in &paths { + check_cancellation()?; + let relative = slash_path(path); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !is_application_configuration(name) { + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + if name.ends_with(".properties") { + values.extend(parse_properties(&relative, &content)); + } else { + values.extend(parse_yaml(&relative, &content)); + } + } + attach_property_targets(&properties, &mut values); + mark_profile_overrides(&mut values); + let mut diagnostics = configuration_diagnostics(&properties, &values); + let property_references = property_reference_index(&java_sources)?; + let (beans, injections, injection_diagnostics) = bean_index(&java_sources)?; + diagnostics.extend(injection_diagnostics); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + .then_with(|| left.message.cmp(&right.message)) + }); + let endpoints = endpoint_index(&java_sources)?; + + Ok(SpringIndexResponse { + properties, + values, + property_references, + diagnostics, + beans, + injections, + endpoints, + }) +} + +fn property_reference_index( + sources: &[(String, String)], +) -> Result, CoreError> { + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Value\s*\(\s*[\"']\$\{\s*([^}:\s]+)(?::[^}]*)?\s*\}[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + let mut references = Vec::new(); + for (path, source) in sources { + check_cancellation()?; + for (index, line) in source.lines().enumerate() { + for capture in ANNOTATION.captures_iter(line) { + let Some(key) = capture.get(1) else { continue }; + references.push(SpringPropertyReferenceResponse { + key: canonical_property_name(key.as_str()), + path: path.clone(), + line: index + 1, + column: key.start() + 1, + }); + } + } + } + references.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + Ok(references) +} + +fn source_content(root: &Path, path: &Path, overrides: &HashMap) -> Option { + overrides + .get(&slash_path(path)) + .cloned() + .or_else(|| fs::read_to_string(root.join(path)).ok()) +} + +fn existing_directory(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || !path.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Spring index root must be an existing absolute directory", + )); + } + Ok(path) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value); + if path.is_absolute() || value.contains('\0') { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + _ => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn slash_path(path: &Path) -> String { + path.components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +fn is_metadata_path(path: &Path) -> bool { + matches!( + path.file_name().and_then(|value| value.to_str()), + Some("spring-configuration-metadata.json") + | Some("additional-spring-configuration-metadata.json") + ) +} + +fn repository_metadata( + repository: &Path, + refresh: bool, +) -> Result, CoreError> { + repository_metadata_with(repository, refresh, scan_repository_metadata) +} + +/// Loads one repository snapshot while sharing a single in-progress scan. +/// +/// `scan` is injectable so the coordination behavior can be tested without a +/// large archive fixture. +fn repository_metadata_with( + repository: &Path, + refresh: bool, + scan: impl FnOnce(&Path) -> Result, CoreError>, +) -> Result, CoreError> { + check_cancellation()?; + if !repository.is_absolute() || !repository.is_dir() { + return Ok(Vec::new()); + } + let key = repository + .canonicalize() + .unwrap_or_else(|_| repository.to_path_buf()); + let (slot, owned_scan) = { + let mut cache = REPOSITORY_METADATA_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("repository metadata cache should not be poisoned"); + match cache.get(&key) { + Some(slot) => (Arc::clone(slot), None), + None => { + let slot = Arc::new(RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Scanning(1)), + ready: Condvar::new(), + }); + cache.insert(key.clone(), Arc::clone(&slot)); + (slot, Some(1)) + } + } + }; + let scan_number = match owned_scan { + Some(scan_number) => scan_number, + None => { + let arrival = slot.latest_scan(); + match claim_repository_scan(&slot, refresh, arrival)? { + RepositoryClaim::Shared(properties) => return Ok(properties), + RepositoryClaim::Owned(scan_number) => scan_number, + } + } + }; + + let mut completion = RepositoryScanCompletion { + slot: &slot, + published: false, + }; + let result = scan(&key); + { + let mut state = slot + .state + .lock() + .expect("repository metadata state should not be poisoned"); + *state = match &result { + Ok(properties) => RepositoryMetadataState::Ready { + scan: scan_number, + properties: Arc::new(properties.clone()), + }, + Err(_) => RepositoryMetadataState::Failed(scan_number), + }; + completion.published = true; + } + slot.ready.notify_all(); + result +} + +/// Waits for the current owner or claims the scan for this request. +/// +/// Returns a shared snapshot when it is ready and no refresh was requested. +/// `arrival` is the latest scan number seen when the request arrived. A +/// refresh accepts only a snapshot from a scan numbered after it, so a refresh +/// that lands mid-scan waits for that walk and then starts its own; refreshes +/// that land during the same walk still share one follow-up scan. +fn claim_repository_scan( + slot: &RepositoryMetadataSlot, + refresh: bool, + arrival: u64, +) -> Result { + let mut state = slot + .state + .lock() + .expect("repository metadata state should not be poisoned"); + loop { + match &*state { + RepositoryMetadataState::Ready { scan, properties } if !refresh || *scan > arrival => { + return Ok(RepositoryClaim::Shared((**properties).clone())); + } + RepositoryMetadataState::Ready { .. } | RepositoryMetadataState::Failed(_) => { + let scan = state.scan() + 1; + *state = RepositoryMetadataState::Scanning(scan); + return Ok(RepositoryClaim::Owned(scan)); + } + RepositoryMetadataState::Scanning(_) => { + let (next, _) = slot + .ready + .wait_timeout(state, REPOSITORY_WAIT_SLICE) + .expect("repository metadata wait should not be poisoned"); + state = next; + // A cancelled request must release its worker thread rather + // than wait for a scan whose result it will discard. + check_cancellation()?; + } + } + } +} + +fn scan_repository_metadata(repository: &Path) -> Result, CoreError> { + let mut properties = Vec::new(); + let mut pending = vec![repository.to_path_buf()]; + let mut archive_count = 0usize; + while let Some(directory) = pending.pop() { + check_cancellation()?; + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("jar") { + archive_count += 1; + if archive_count > MAX_METADATA_ARCHIVES { + return Ok(properties); + } + append_archive_metadata(&path, &mut properties)?; + } + } + } + deduplicate_properties(&mut properties); + Ok(properties) +} + +fn append_archive_metadata( + path: &Path, + properties: &mut Vec, +) -> Result<(), CoreError> { + check_cancellation()?; + let Ok(file) = File::open(path) else { + return Ok(()); + }; + let Ok(mut archive) = ZipArchive::new(file) else { + return Ok(()); + }; + for name in [ + "META-INF/spring-configuration-metadata.json", + "META-INF/additional-spring-configuration-metadata.json", + ] { + let Ok(mut entry) = archive.by_name(name) else { + continue; + }; + let mut content = String::new(); + if entry.read_to_string(&mut content).is_ok() { + append_metadata(&content, None, properties); + } + } + Ok(()) +} + +fn append_metadata( + content: &str, + source_path: Option<&str>, + properties: &mut Vec, +) { + let Ok(document) = serde_json::from_str::(content) else { + return; + }; + let Some(items) = document.get("properties").and_then(Value::as_array) else { + return; + }; + for item in items { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + properties.push(SpringPropertyResponse { + name: name.to_string(), + type_name: item.get("type").and_then(Value::as_str).map(String::from), + description: item + .get("description") + .and_then(Value::as_str) + .map(String::from), + default_value: item.get("defaultValue").map(json_scalar), + source_path: source_path.map(String::from), + source_line: None, + source_column: None, + }); + } +} + +fn json_scalar(value: &Value) -> String { + value + .as_str() + .map(String::from) + .unwrap_or_else(|| value.to_string()) +} + +fn built_in_properties() -> Vec { + [ + ( + "server.port", + "java.lang.Integer", + "HTTP server port.", + "8080", + ), + ( + "spring.application.name", + "java.lang.String", + "Application name.", + "", + ), + ( + "spring.profiles.active", + "java.util.List", + "Active profiles.", + "", + ), + ( + "spring.config.activate.on-profile", + "java.lang.String", + "Profile expression for this document.", + "", + ), + ( + "spring.datasource.url", + "java.lang.String", + "JDBC URL of the database.", + "", + ), + ( + "spring.datasource.username", + "java.lang.String", + "Database login username.", + "", + ), + ( + "spring.datasource.password", + "java.lang.String", + "Database login password.", + "", + ), + ( + "spring.jpa.hibernate.ddl-auto", + "java.lang.String", + "Hibernate schema generation mode.", + "none", + ), + ( + "logging.level.root", + "java.lang.String", + "Root logger level.", + "info", + ), + ( + "management.endpoints.web.exposure.include", + "java.util.Set", + "Exposed actuator endpoints.", + "", + ), + ] + .into_iter() + .map( + |(name, type_name, description, default_value)| SpringPropertyResponse { + name: name.to_string(), + type_name: Some(type_name.to_string()), + description: Some(description.to_string()), + default_value: (!default_value.is_empty()).then(|| default_value.to_string()), + source_path: None, + source_line: None, + source_column: None, + }, + ) + .collect() +} + +fn append_configuration_properties( + sources: &[(String, String)], + properties: &mut Vec, +) -> Result<(), CoreError> { + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?s)@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?[\"']([^\"']+)[\"'][^)]*\).*?\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)"#, + ) + .expect("literal pattern is valid") + }); + let mut types = HashMap::new(); + for (path, source) in sources { + check_cancellation()?; + for value in parse_configuration_types(path, source) { + types.insert(value.name.clone(), value); + } + } + for (_, source) in sources { + check_cancellation()?; + for capture in ANNOTATION.captures_iter(source) { + let Some(prefix) = capture.get(1) else { + continue; + }; + let Some(type_name) = capture.get(2) else { + continue; + }; + append_configuration_type( + canonical_property_name(prefix.as_str()), + type_name.as_str(), + &types, + &mut HashSet::new(), + properties, + ); + } + } + Ok(()) +} + +#[derive(Clone)] +struct ConfigurationField { + name: String, + type_name: String, + path: String, + line: usize, + column: usize, + default_value: Option, +} + +struct ConfigurationType { + name: String, + fields: Vec, + body_depth: isize, +} + +fn parse_configuration_types(path: &str, source: &str) -> Vec { + static DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\(([^)]*)\))?") + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?, ]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:=\s*([^;]+))?;", + ) + .expect("literal pattern is valid") + }); + let mut types = Vec::::new(); + let mut stack = Vec::::new(); + let mut depth = 0isize; + for (index, line) in source.lines().enumerate() { + if let Some(capture) = DECLARATION.captures(line) { + let name = capture.get(1).unwrap(); + let body_depth = depth + brace_delta(line); + let mut value = ConfigurationType { + name: name.as_str().to_string(), + fields: Vec::new(), + body_depth, + }; + if let Some(components) = capture.get(2) { + value.fields.extend(parse_record_components( + path, + index + 1, + line, + components.as_str(), + )); + } + types.push(value); + stack.push(types.len() - 1); + } else if let Some(type_index) = stack.last().copied() { + if depth == types[type_index].body_depth { + if let Some(capture) = FIELD.captures(line) { + let name = capture.get(2).unwrap(); + types[type_index].fields.push(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1).unwrap().as_str().trim().to_string(), + path: path.to_string(), + line: index + 1, + column: name.start() + 1, + default_value: capture.get(3).map(|value| { + value.as_str().trim().trim_matches(['\'', '"']).to_string() + }), + }); + } + } + } + depth += brace_delta(line); + while stack + .last() + .is_some_and(|type_index| depth < types[*type_index].body_depth) + { + stack.pop(); + } + } + types +} + +fn parse_record_components( + path: &str, + line_number: usize, + line: &str, + components: &str, +) -> Vec { + split_parameters(components) + .into_iter() + .filter_map(|value| { + let capture = JAVA_PARAMETER_DECLARATION.captures(value.trim())?; + let name = capture.get(2)?; + Some(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1)?.as_str().trim().to_string(), + path: path.to_string(), + line: line_number, + column: line.find(name.as_str()).unwrap_or(0) + 1, + default_value: None, + }) + }) + .collect() +} + +fn brace_delta(line: &str) -> isize { + line.chars().fold(0, |value, character| match character { + '{' => value + 1, + '}' => value - 1, + _ => value, + }) +} + +fn append_configuration_type( + prefix: String, + type_name: &str, + types: &HashMap, + visiting: &mut HashSet, + properties: &mut Vec, +) { + let type_name = simple_type(type_name); + if !visiting.insert(type_name.clone()) { + return; + } + let Some(value) = types.get(&type_name) else { + visiting.remove(&type_name); + return; + }; + for field in &value.fields { + let name = format!("{}.{}", prefix, kebab_case(&field.name)); + let nested_type = simple_type(&field.type_name); + if types.contains_key(&nested_type) { + append_configuration_type(name, &nested_type, types, visiting, properties); + } else { + properties.push(SpringPropertyResponse { + name, + type_name: Some(field.type_name.clone()), + description: Some(format!("Binds to `{}`.", field.name)), + default_value: field.default_value.clone(), + source_path: Some(field.path.clone()), + source_line: Some(field.line), + source_column: Some(field.column), + }); + } + } + visiting.remove(&type_name); +} + +fn kebab_case(value: &str) -> String { + let mut result = String::new(); + for character in value.chars() { + if character.is_uppercase() { + if !result.is_empty() { + result.push('-'); + } + result.extend(character.to_lowercase()); + } else if character == '_' { + result.push('-'); + } else { + result.push(character); + } + } + result +} + +fn canonical_property_name(value: &str) -> String { + value + .split('.') + .map(|part| kebab_case(part.trim()).to_ascii_lowercase()) + .collect::>() + .join(".") +} + +fn deduplicate_properties(properties: &mut Vec) { + properties.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.source_path.is_none().cmp(&right.source_path.is_none())) + }); + properties.dedup_by(|right, left| right.name == left.name); +} + +fn is_application_configuration(name: &str) -> bool { + name == "application.properties" + || (name.starts_with("application") + && (name.ends_with(".yml") || name.ends_with(".yaml") || name.ends_with(".properties"))) +} + +fn profile_from_path(path: &str) -> Option { + let name = Path::new(path).file_name()?.to_str()?; + let stem = name + .strip_suffix(".properties") + .or_else(|| name.strip_suffix(".yaml")) + .or_else(|| name.strip_suffix(".yml"))?; + stem.strip_prefix("application-") + .filter(|value| !value.is_empty()) + .map(String::from) +} + +fn parse_properties(path: &str, content: &str) -> Vec { + let profile = profile_from_path(path); + logical_property_lines(content) + .into_iter() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') { + return None; + } + let separator = property_separator(trimmed)?; + let key = trimmed[..separator].trim(); + if key.is_empty() { + return None; + } + Some(SpringConfigurationValueResponse { + key: canonical_property_name(&unescape_property(key)), + value: unescape_property(trimmed[separator + 1..].trim()), + path: path.to_string(), + line: index, + column: line.find(key).unwrap_or(0) + 1, + profile: profile.clone(), + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }) + }) + .collect() +} + +fn logical_property_lines(content: &str) -> Vec<(usize, String)> { + let mut values = Vec::new(); + let mut pending: Option<(usize, String)> = None; + for (index, line) in content.lines().enumerate() { + let start_line = index + 1; + let mut part = line.to_string(); + let continued = part + .chars() + .rev() + .take_while(|value| *value == '\\') + .count() + % 2 + == 1; + if continued { + part.pop(); + } + if let Some((_, value)) = pending.as_mut() { + value.push_str(part.trim_start()); + } else { + pending = Some((start_line, part)); + } + if !continued { + if let Some(value) = pending.take() { + values.push(value); + } + } + } + if let Some(value) = pending { + values.push(value); + } + values +} + +fn property_separator(value: &str) -> Option { + let mut escaped = false; + for (index, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + } else if character == '=' || character == ':' || character.is_whitespace() { + return Some(index); + } + } + None +} + +fn unescape_property(value: &str) -> String { + value + .replace("\\:", ":") + .replace("\\=", "=") + .replace("\\ ", " ") + .replace("\\\\", "\\") +} + +fn parse_yaml(path: &str, content: &str) -> Vec { + let file_profile = profile_from_path(path); + let lines = content.lines().collect::>(); + let mut values = Vec::new(); + let mut document_start = 0usize; + for index in 0..=lines.len() { + if index == lines.len() || lines[index].trim() == "---" { + values.extend(parse_yaml_document( + path, + &lines[document_start..index], + document_start, + file_profile.clone(), + )); + document_start = index + 1; + } + } + values +} + +fn parse_yaml_document( + path: &str, + lines: &[&str], + line_offset: usize, + file_profile: Option, +) -> Vec { + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut values = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(item) = trimmed + .strip_prefix('-') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let key = stack + .iter() + .map(|(_, part)| part.as_str()) + .collect::>() + .join("."); + if !key.is_empty() { + values.push(SpringConfigurationValueResponse { + key: canonical_property_name(&key), + value: item.trim_matches(['\'', '"']).to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find('-').unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + continue; + } + let Some(separator) = trimmed.find(':') else { + continue; + }; + let key = trimmed[..separator].trim().trim_matches(['\'', '"']); + if key.is_empty() { + continue; + } + let indent = line + .chars() + .take_while(|value| value.is_whitespace()) + .count(); + while stack.last().is_some_and(|(level, _)| *level >= indent) { + stack.pop(); + } + let value = trimmed[separator + 1..].trim().trim_matches(['\'', '"']); + let mut parts = stack + .iter() + .map(|(_, part)| part.clone()) + .collect::>(); + parts.push(key.to_string()); + let full_key = canonical_property_name(&parts.join(".")); + if value.is_empty() { + stack.push((indent, key.to_string())); + continue; + } + values.push(SpringConfigurationValueResponse { + key: full_key, + value: value.to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find(key).unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + let profile = file_profile.or_else(|| { + values + .iter() + .find(|value| value.key == "spring.config.activate.on-profile") + .map(|value| value.value.clone()) + }); + for value in &mut values { + value.profile = profile.clone(); + } + values +} + +fn attach_property_targets( + properties: &[SpringPropertyResponse], + values: &mut [SpringConfigurationValueResponse], +) { + let by_name = properties + .iter() + .map(|property| (canonical_property_name(&property.name), property)) + .collect::>(); + for value in values { + if let Some(property) = by_name.get(&canonical_property_name(&value.key)) { + value.target_path = property.source_path.clone(); + value.target_line = property.source_line; + value.target_column = property.source_column; + } + } +} + +fn mark_profile_overrides(values: &mut [SpringConfigurationValueResponse]) { + let base_keys = values + .iter() + .filter(|value| value.profile.is_none()) + .map(|value| value.key.clone()) + .collect::>(); + for value in values { + value.overrides_base_value = value.profile.is_some() && base_keys.contains(&value.key); + } +} + +fn configuration_diagnostics( + properties: &[SpringPropertyResponse], + values: &[SpringConfigurationValueResponse], +) -> Vec { + let known = properties + .iter() + .map(|property| canonical_property_name(&property.name)) + .collect::>(); + let types = properties + .iter() + .filter_map(|property| { + property + .type_name + .as_deref() + .map(|type_name| (canonical_property_name(&property.name), type_name)) + }) + .collect::>(); + let mut diagnostics = Vec::new(); + for value in values { + let canonical_key = canonical_property_name(&value.key); + if !known.contains(&canonical_key) { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "warning".to_string(), + message: format!("Unknown Spring configuration property `{}`", value.key), + }); + continue; + } + let Some(type_name) = types.get(&canonical_key) else { + continue; + }; + let valid = if type_name.contains("Boolean") || *type_name == "boolean" { + matches!(value.value.as_str(), "true" | "false") + } else if type_name.contains("Integer") || *type_name == "int" || *type_name == "long" { + value.value.parse::().is_ok() + } else { + true + }; + if !valid { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "error".to_string(), + message: format!("`{}` is not a valid value for `{}`", value.value, type_name), + }); + } + } + diagnostics +} + +#[derive(Clone)] +struct IndexedBean { + response: SpringBeanResponse, + names: HashSet, + assignable_types: HashSet, + primary: bool, +} + +struct RawInjection { + path: String, + line: usize, + column: usize, + type_name: String, + qualifier: Option, +} + +fn bean_index( + sources: &[(String, String)], +) -> Result< + ( + Vec, + Vec, + Vec, + ), + CoreError, +> { + static TYPE_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(class|interface|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)([^\{]*)") + .expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", + ) + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)", + ) + .expect("literal pattern is valid") + }); + let mut supertypes = HashMap::>::new(); + for (_, source) in sources { + check_cancellation()?; + for capture in TYPE_DECLARATION.captures_iter(source) { + let Some(name) = capture.get(2) else { continue }; + let tail = capture + .get(3) + .map(|value| value.as_str()) + .unwrap_or_default(); + supertypes.insert(name.as_str().to_string(), declared_supertypes(tail)); + } + } + + let mut indexed_beans = Vec::new(); + let mut raw_injections = Vec::new(); + for (path, source) in sources { + check_cancellation()?; + let lines = source.lines().collect::>(); + let source_type = TYPE_DECLARATION + .captures(source) + .and_then(|capture| capture.get(2)) + .map(|value| value.as_str().to_string()); + // The constructor pattern depends on the declaring type, so it cannot be + // a file-independent constant, but it is identical for every line of one + // source and must not be rebuilt inside the line loop below. + let constructor_pattern = source_type.as_deref().and_then(constructor_regex); + let constructor_count = constructor_pattern + .as_ref() + .map_or(0, |pattern| pattern.captures_iter(source).count()); + for (index, line) in lines.iter().enumerate() { + let context = annotation_context(&lines, index); + if let Some(capture) = TYPE_DECLARATION.captures(line) { + if has_component_annotation(&context) { + let name = capture.get(2).unwrap(); + let default_name = lower_camel(name.as_str()); + let bean_name = component_name(&context).unwrap_or(default_name); + let mut names = HashSet::from([bean_name.clone()]); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: name.as_str().to_string(), + path: path.clone(), + line: index + 1, + column: name.start() + 1, + kind: "component".to_string(), + }, + names, + assignable_types: assignable_types(name.as_str(), &supertypes), + primary: SpringAnnotation::Primary.is_present(&context), + }); + } + } + if SpringAnnotation::Bean.is_present(&context) { + if let Some(capture) = METHOD.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let declaration_name = capture.get(2).unwrap(); + let aliases = bean_names(&context); + let bean_name = aliases + .first() + .cloned() + .unwrap_or_else(|| declaration_name.as_str().to_string()); + let mut names = aliases.into_iter().collect::>(); + names.insert(bean_name.clone()); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: type_name.clone(), + path: path.clone(), + line: index + 1, + column: declaration_name.start() + 1, + kind: "beanMethod".to_string(), + }, + names, + assignable_types: assignable_types(&type_name, &supertypes), + primary: SpringAnnotation::Primary.is_present(&context), + }); + } + } + if is_injection_context(&context) { + if let Some(capture) = FIELD.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let name = capture.get(2).unwrap(); + raw_injections.push(RawInjection { + path: path.clone(), + line: index + 1, + column: name.start() + 1, + type_name, + qualifier: injection_qualifier(&context), + }); + } + } + let Some(pattern) = constructor_pattern.as_ref() else { + continue; + }; + let Some(opening) = pattern.find(line).map(|value| value.end() - 1) else { + continue; + }; + if !is_injection_context(&context) && constructor_count != 1 { + continue; + } + let Some(closing) = line.rfind(')').filter(|value| *value > opening) else { + continue; + }; + raw_injections.extend(parse_constructor_injections( + path, + index + 1, + line, + &line[opening + 1..closing], + )); + } + } + + indexed_beans.sort_by(|left, right| { + left.response + .name + .cmp(&right.response.name) + .then_with(|| left.response.path.cmp(&right.response.path)) + }); + let mut diagnostics = Vec::new(); + let mut injections = raw_injections + .into_iter() + .map(|injection| { + let mut candidates = indexed_beans + .iter() + .filter(|bean| bean.assignable_types.contains(&injection.type_name)) + .collect::>(); + if let Some(qualifier) = injection.qualifier.as_deref() { + candidates.retain(|bean| bean.names.contains(qualifier)); + } else { + let primary = candidates + .iter() + .copied() + .filter(|bean| bean.primary) + .collect::>(); + if !primary.is_empty() { + candidates = primary; + } + } + if candidates.is_empty() { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "No Spring bean satisfies injection type `{}`{}", + injection.type_name, + injection + .qualifier + .as_deref() + .map(|value| format!(" with qualifier `{value}`")) + .unwrap_or_default() + ), + }); + } else if candidates.len() > 1 { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "Multiple Spring beans satisfy injection type `{}`", + injection.type_name + ), + }); + } + SpringInjectionResponse { + path: injection.path, + line: injection.line, + column: injection.column, + type_name: injection.type_name, + qualifier: injection.qualifier, + bean_ids: candidates + .into_iter() + .map(|bean| bean.response.id.clone()) + .collect(), + } + }) + .collect::>(); + injections.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + let beans = indexed_beans + .into_iter() + .map(|bean| bean.response) + .collect(); + Ok((beans, injections, diagnostics)) +} + +fn annotation_context(lines: &[&str], index: usize) -> String { + let mut values = vec![lines[index].trim().to_string()]; + for previous in lines[..index].iter().rev().take(8) { + let trimmed = previous.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with('@') + || trimmed.starts_with("value") + || trimmed.starts_with("name") + || trimmed == ")" + || trimmed == "}" + { + values.insert(0, trimmed.to_string()); + } else { + break; + } + } + values.join(" ") +} + +/// Matches `@Name` only when the name is not a prefix of a longer annotation, +/// so `@Bean` does not match `@BeanFactory`. +fn annotation_boundary_pattern(name: &str) -> Regex { + Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) + .expect("an escaped annotation name is a valid pattern") +} + +/// Declares the Spring annotations this module recognizes exactly once, and +/// derives the type, the spelling, the full list, and the compiled pattern from +/// that one declaration. +/// +/// Detection runs several times for every line of every Java source, so each +/// pattern is compiled once for the process. `pattern` matches on `self` instead +/// of looking the annotation up in a table, which leaves the compiler to reject +/// a case that was added without a pattern. +macro_rules! spring_annotations { + ($($variant:ident => $name:literal),+ $(,)?) => { + #[derive(Clone, Copy)] + pub(crate) enum SpringAnnotation { + $($variant),+ + } + + impl SpringAnnotation { + /// Every recognized annotation, in declaration order. Production + /// code reaches a pattern through a case rather than this list. + #[cfg(test)] + pub(crate) const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[cfg(test)] + pub(crate) fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + pub(crate) fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + } + }; +} + +spring_annotations! { + Autowired => "Autowired", + Bean => "Bean", + Component => "Component", + Configuration => "Configuration", + Controller => "Controller", + Inject => "Inject", + Primary => "Primary", + Repository => "Repository", + Resource => "Resource", + RestController => "RestController", + Service => "Service", +} + +/// Declares the Spring web mapping annotations exactly once, and derives the +/// type, spelling, boundary pattern, fixed HTTP method, and test list from that +/// one declaration. `RequestMapping` carries `None` so methods come from its +/// `RequestMethod.*` arguments instead. +macro_rules! spring_mapping_annotations { + ($($variant:ident => $name:literal, $method:expr),+ $(,)?) => { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum SpringMappingAnnotation { + $($variant),+ + } + + impl SpringMappingAnnotation { + /// Every recognized mapping annotation. Production selection walks + /// this list and keeps the earliest source match, not declaration + /// order by itself. + const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[cfg(test)] + fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + + /// Fixed verb for method mappings; `None` for [`Self::RequestMapping`]. + fn fixed_http_method(self) -> Option<&'static str> { + match self { + $(Self::$variant => $method),+ + } + } + + #[cfg(test)] + fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } + } + }; +} + +spring_mapping_annotations! { + GetMapping => "GetMapping", Some("GET"), + PostMapping => "PostMapping", Some("POST"), + PutMapping => "PutMapping", Some("PUT"), + DeleteMapping => "DeleteMapping", Some("DELETE"), + PatchMapping => "PatchMapping", Some("PATCH"), + RequestMapping => "RequestMapping", None, +} + +impl SpringAnnotation { + /// Annotations that declare a Spring component on a type declaration. + /// Detection and naming share this closed set so they cannot drift; when + /// several are present, the leftmost source match wins rather than this + /// array's declaration order. + const COMPONENTS: [Self; 6] = [ + Self::Component, + Self::Service, + Self::Repository, + Self::Controller, + Self::RestController, + Self::Configuration, + ]; + + /// Annotations that mark a field or constructor parameter for injection. + const INJECTIONS: [Self; 3] = [Self::Autowired, Self::Inject, Self::Resource]; + + pub(crate) fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } +} + +fn has_component_annotation(context: &str) -> bool { + SpringAnnotation::COMPONENTS + .iter() + .any(|annotation| annotation.is_present(context)) +} + +/// Returns the explicit name of the earliest component annotation in source +/// order. The value is read only from that annotation's own argument list so a +/// later neighbor such as `@Component("c")` cannot pollute `@Service("s")`. +/// An empty string such as `@Component("")` is not a name; callers then use +/// the default type name. +fn component_name(context: &str) -> Option { + let start = earliest_component_annotation_start(context)?; + quoted_values(isolate_annotation_at(context, start)) + .into_iter() + .find(|value| !value.is_empty()) +} + +/// Locates every exact component annotation with the cached boundary patterns +/// and keeps the leftmost source start. Array order in [`SpringAnnotation::COMPONENTS`] +/// is not a priority. +fn earliest_component_annotation_start(context: &str) -> Option { + SpringAnnotation::COMPONENTS + .iter() + .filter_map(|annotation| { + annotation + .pattern() + .find(context) + .map(|found| found.start()) + }) + .min() +} + +fn qualifier_names(context: &str) -> Vec { + static PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + PATTERN + .captures_iter(context) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() +} + +/// Returns declared `@Bean` aliases from the exact `@Bean` annotation only. +/// A prefix decoy such as `@BeanFactory("decoy")` cannot supply the name. +fn bean_names(context: &str) -> Vec { + let Some(found) = SpringAnnotation::Bean.pattern().find(context) else { + return Vec::new(); + }; + quoted_values(isolate_annotation_at(context, found.start())) + .into_iter() + .filter(|value| !value.is_empty()) + .collect() +} + +fn quoted_values(value: &str) -> Vec { + static PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"[\"']([^\"']*)[\"']"#).expect("literal pattern is valid")); + PATTERN + .captures_iter(value) + .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) + .collect() +} + +fn declared_supertypes(tail: &str) -> Vec { + // Indexed by the fixed keyword order below, which the result order depends on. + static PATTERNS: LazyLock<[Regex; 2]> = LazyLock::new(|| { + ["extends", "implements"].map(|keyword| { + Regex::new(&format!( + r"\b{}\s+([^\{{]+?)(?:\b(?:extends|implements)\b|$)", + keyword + )) + .expect("literal keyword produces a valid pattern") + }) + }); + let mut values = Vec::new(); + for pattern in PATTERNS.iter() { + if let Some(capture) = pattern.captures(tail) { + values.extend( + capture[1] + .split(',') + .map(simple_type) + .filter(|value| !value.is_empty()), + ); + } + } + values +} + +fn assignable_types(type_name: &str, supertypes: &HashMap>) -> HashSet { + let mut values = HashSet::new(); + let mut pending = vec![simple_type(type_name)]; + while let Some(value) = pending.pop() { + if !values.insert(value.clone()) { + continue; + } + if let Some(parents) = supertypes.get(&value) { + pending.extend(parents.iter().cloned()); + } + } + values +} + +fn is_injection_context(context: &str) -> bool { + SpringAnnotation::INJECTIONS + .iter() + .any(|annotation| annotation.is_present(context)) +} + +fn injection_qualifier(context: &str) -> Option { + qualifier_names(context).into_iter().next().or_else(|| { + static RESOURCE: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#) + .expect("literal pattern is valid") + }); + RESOURCE + .captures(context) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + }) +} + +fn constructor_regex(type_name: &str) -> Option { + Regex::new(&format!( + r"(?:public|protected|private)?\s*{}\s*\(", + regex::escape(type_name) + )) + .ok() +} + +fn parse_constructor_injections( + path: &str, + line_number: usize, + line: &str, + parameters: &str, +) -> Vec { + split_parameters(parameters) + .into_iter() + .filter_map(|parameter| { + let capture = JAVA_PARAMETER_DECLARATION.captures(parameter.trim())?; + let variable = capture.get(2)?; + Some(RawInjection { + path: path.to_string(), + line: line_number, + column: line.find(variable.as_str()).unwrap_or(0) + 1, + type_name: simple_type(capture.get(1)?.as_str()), + qualifier: injection_qualifier(parameter), + }) + }) + .collect() +} + +fn split_parameters(value: &str) -> Vec<&str> { + let mut values = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (index, character) in value.char_indices() { + match character { + '<' | '(' | '{' | '[' => depth += 1, + '>' | ')' | '}' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + values.push(&value[start..index]); + start = index + 1; + } + _ => {} + } + } + if start < value.len() { + values.push(&value[start..]); + } + values +} + +fn endpoint_index(sources: &[(String, String)]) -> Result, CoreError> { + static CLASS: LazyLock = LazyLock::new(|| { + Regex::new(r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)").expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(") + .expect("literal pattern is valid") + }); + let mut endpoints = Vec::new(); + for (path, source) in sources { + check_cancellation()?; + if !SpringAnnotation::Controller.is_present(source) + && !SpringAnnotation::RestController.is_present(source) + { + continue; + } + let controller = CLASS + .captures(source) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "Controller".to_string()); + let lines = source.lines().collect::>(); + let mut base_routes = vec![String::new()]; + let mut index = 0usize; + while index < lines.len() { + if !lines[index].trim_start().starts_with('@') { + index += 1; + continue; + } + let (annotation, annotation_end) = annotation_block(&lines, index); + let Some((mapping_annotation, methods, routes)) = mapping(&annotation) else { + index = annotation_end + 1; + continue; + }; + let declaration_index = next_declaration_index(&lines, annotation_end + 1); + let declaration = declaration_index + .and_then(|value| lines.get(value).copied()) + .unwrap_or_default(); + // Class-level base routes come only from an exact @RequestMapping, not + // a longer custom name that merely starts with that spelling. + if mapping_annotation == SpringMappingAnnotation::RequestMapping + && CLASS.is_match(declaration) + { + base_routes = routes; + index = annotation_end + 1; + continue; + } + let method_name = METHOD + .captures(declaration) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .unwrap_or("handler"); + for base_route in &base_routes { + for route in &routes { + let joined = join_route(base_route, route); + endpoints.push(SpringEndpointResponse { + id: format!("{}:{}:{}:{}", path, index + 1, methods.join(","), joined), + http_methods: methods.clone(), + route: joined, + controller: controller.clone(), + method: method_name.to_string(), + path: path.clone(), + line: index + 1, + column: lines[index].find('@').unwrap_or(0) + 1, + }); + } + } + index = annotation_end + 1; + } + } + endpoints.sort_by(|left, right| { + left.route + .cmp(&right.route) + .then_with(|| left.http_methods.cmp(&right.http_methods)) + }); + Ok(endpoints) +} + +/// Finds the earliest exact Mapping annotation in `annotation_text` and returns +/// its HTTP methods and routes. Custom names that only share a standard prefix +/// (for example `@GetMappingCustom`) do not match. +/// +/// When several Mapping annotations appear in one context, the leftmost source +/// span wins so selection does not depend on declaration order in +/// [`SpringMappingAnnotation::ALL`]. +fn mapping(annotation_text: &str) -> Option<(SpringMappingAnnotation, Vec, Vec)> { + let (annotation, isolated) = find_mapping_annotation(annotation_text)?; + let methods = match annotation.fixed_http_method() { + Some(method) => vec![method.to_string()], + None => request_mapping_methods(isolated), + }; + Some((annotation, methods, annotation_routes(isolated))) +} + +/// Returns the earliest exact Mapping annotation and the isolated annotation +/// text used for route and `RequestMethod` parsing. +fn find_mapping_annotation(text: &str) -> Option<(SpringMappingAnnotation, &str)> { + let mut best: Option<(usize, SpringMappingAnnotation)> = None; + for &annotation in SpringMappingAnnotation::ALL { + if let Some(found) = annotation.pattern().find(text) { + let start = found.start(); + match best { + Some((best_start, _)) if start >= best_start => {} + _ => best = Some((start, annotation)), + } + } + } + let (start, annotation) = best?; + Some((annotation, isolate_annotation_at(text, start))) +} + +/// Slices one annotation starting at `start` (`@Name` followed by an optional +/// argument list) so name or route extraction cannot read string literals from +/// a neighboring decoy. +/// +/// A later annotation's `(` is not this annotation's argument list. After the +/// name, only whitespace may appear before `(`. Parentheses inside quoted +/// strings, including escaped quotes, do not change the argument-list depth. +fn isolate_annotation_at(text: &str, start: usize) -> &str { + let rest = &text[start..]; + let name_end = rest + .char_indices() + .skip(1) + .find(|(_, character)| { + !(character.is_ascii_alphanumeric() || *character == '_' || *character == '$') + }) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let after_name = rest.get(name_end..).unwrap_or(""); + let whitespace_len = after_name + .chars() + .take_while(|character| character.is_whitespace()) + .map(char::len_utf8) + .sum::(); + let after_whitespace = after_name.get(whitespace_len..).unwrap_or(""); + if !after_whitespace.starts_with('(') { + return &rest[..name_end]; + } + let open_index = name_end + whitespace_len; + let mut depth = 0isize; + let mut in_string = None; + let mut escaped = false; + for (index, character) in rest[open_index..].char_indices() { + if let Some(quote) = in_string { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == quote { + in_string = None; + } + continue; + } + match character { + '"' | '\'' => in_string = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return &rest[..open_index + index + 1]; + } + } + _ => {} + } + } + rest +} + +fn request_mapping_methods(annotation: &str) -> Vec { + static METHOD_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)") + .expect("literal pattern is valid") + }); + let mut methods = METHOD_PATTERN + .captures_iter(annotation) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect::>(); + if methods.is_empty() { + methods.push("ANY".to_string()); + } + methods.sort(); + methods.dedup(); + methods +} + +fn annotation_routes(annotation: &str) -> Vec { + static NAMED: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])"#) + .expect("literal pattern is valid") + }); + let expression = NAMED + .captures(annotation) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .or_else(|| { + let start = annotation.find('(')? + 1; + let end = annotation.rfind(')')?; + let value = annotation[start..end].trim(); + (value.starts_with(['\'', '"', '{'])).then_some(value) + }); + let mut routes = expression.map(quoted_values).unwrap_or_default(); + if routes.is_empty() { + routes.push(String::new()); + } + routes.sort(); + routes.dedup(); + routes +} + +fn annotation_block(lines: &[&str], start: usize) -> (String, usize) { + let mut value = String::new(); + let mut depth = 0isize; + let mut saw_parenthesis = false; + let mut end = start; + for (index, line) in lines.iter().enumerate().skip(start) { + if !value.is_empty() { + value.push(' '); + } + value.push_str(line.trim()); + for character in line.chars() { + if character == '(' { + depth += 1; + saw_parenthesis = true; + } else if character == ')' { + depth -= 1; + } + } + end = index; + if !saw_parenthesis || depth <= 0 { + break; + } + } + (value, end) +} + +fn next_declaration_index(lines: &[&str], start: usize) -> Option { + lines + .iter() + .enumerate() + .skip(start) + .take(12) + .find_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('@')).then_some(index) + }) +} + +fn join_route(base: &str, route: &str) -> String { + let value = format!("{}/{}", base.trim_matches('/'), route.trim_matches('/')); + let trimmed = value.trim_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + format!("/{trimmed}") + } +} + +fn simple_type(value: &str) -> String { + value + .split('<') + .next() + .unwrap_or(value) + .rsplit('.') + .next() + .unwrap_or(value) + .trim() + .to_string() +} + +fn lower_camel(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_lowercase().collect::() + characters.as_str()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// `spring_annotations!` derives the type, the spelling, `ALL`, and the + /// compiled pattern from one declaration, so a case cannot exist without a + /// pattern. This covers the boundary each entry has to keep. + #[test] + fn every_supported_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name} public class Demo")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}(\"value\")")), + "{name} should match when it carries arguments" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Extended public class Demo")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringAnnotation::ALL.len(), + "every case needs a distinct annotation name" + ); + } + + /// Component detection and component naming must recognize the same + /// annotations, so both read COMPONENTS instead of repeating the list. + #[test] + fn component_detection_and_naming_recognize_the_same_annotations() { + for annotation in SpringAnnotation::COMPONENTS { + let name = annotation.name(); + assert!( + has_component_annotation(&format!("@{name}\npublic class Demo")), + "{name} should be detected as a component annotation" + ); + assert_eq!( + component_name(&format!("@{name}(\"custom\")\npublic class Demo")).as_deref(), + Some("custom"), + "{name} should expose its declared bean name" + ); + } + + assert!(!has_component_annotation("@Bean\npublic Clock clock()")); + assert_eq!(component_name("@Qualifier(\"custom\")"), None); + } + + /// `@BeanFactory` shares a prefix with `@Bean`, so a naive `@Bean` search + /// would take the decoy name. Naming must reuse the cached boundary match. + #[test] + fn bean_names_uses_the_exact_bean_annotation_not_a_prefix() { + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean("real")"#), + vec!["real".to_string()] + ); + assert_eq!(bean_names(r#"@BeanFactory("decoy")"#), Vec::::new()); + assert_eq!(bean_names(r#"@Bean("real")"#), vec!["real".to_string()]); + assert_eq!(bean_names("@Bean"), Vec::::new()); + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean"#), + Vec::::new() + ); + } + + /// A `(` inside a string is not the annotation argument list. Counting it + /// would swallow the real closer and pull a later neighbor into the aliases. + #[test] + fn isolate_annotation_at_ignores_parentheses_inside_strings() { + assert_eq!( + isolate_annotation_at(r#"@Bean("foo(") @Bean("bar")"#, 0), + r#"@Bean("foo(")"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean('foo(') @Service("s")"#, 0), + r#"@Bean('foo(')"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean("foo\")") @Bean("bar")"#, 0), + r#"@Bean("foo\")")"# + ); + assert_eq!( + bean_names(r#"@Bean("foo(") @Bean("bar")"#), + vec!["foo(".to_string()] + ); + assert_eq!( + component_name(r#"@Service("s(") @Component("c")"#).as_deref(), + Some("s(") + ); + let mapping_with_paren = mapping(r#"@GetMapping("/foo(") @PostMapping("/bar")"#) + .expect("GetMapping with a parenthesis in its route should still isolate"); + assert_eq!(mapping_with_paren.0, SpringMappingAnnotation::GetMapping); + assert_eq!(mapping_with_paren.2, vec!["/foo(".to_string()]); + } + + /// A wide capture can start at the first closing quote and swallow + /// `) @Component(`. Isolation plus source-position selection must keep + /// the first annotation's own value, independent of COMPONENTS order. + #[test] + fn component_name_selects_the_earliest_annotation_and_isolates_its_value() { + assert_eq!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some("s") + ); + assert_eq!( + component_name(r#"@Component("c") @Service("s")"#).as_deref(), + Some("c") + ); + assert_ne!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some(") @Component(") + ); + assert_eq!(component_name("@Service @Component"), None); + assert_eq!(component_name(r#"@Service @Component("c")"#), None); + assert_eq!(component_name("@Service"), None); + assert_eq!(component_name("@ServiceLocator(\"x\")"), None); + } + + /// `quoted_values` accepts empty captures. An empty annotation value is not + /// an explicit bean name, so naming must return None and let bean_index use + /// the default type or method name. + #[test] + fn empty_annotation_values_are_not_explicit_bean_names() { + assert_eq!(component_name(r#"@Component("")"#), None); + assert_eq!(component_name(r#"@Service('')"#), None); + assert_eq!( + component_name(r#"@Component("") @Service("s")"#), + None, + "an empty leftmost name must not take a later neighbor" + ); + assert_eq!(bean_names(r#"@Bean("")"#), Vec::::new()); + } + + /// Record components and constructor parameters share one pattern, so a + /// declaration parsed by one path must be parsed identically by the other. + #[test] + fn record_components_and_constructor_parameters_share_one_declaration_pattern() { + let parameters = "@Qualifier(\"stripe\") final PaymentService payments"; + let fields = parse_record_components("Demo.java", 1, parameters, parameters); + let injections = parse_constructor_injections("Demo.java", 1, parameters, parameters); + + assert_eq!(fields.len(), 1); + assert_eq!(injections.len(), 1); + assert_eq!(fields[0].name, "payments"); + assert_eq!(fields[0].type_name, "PaymentService"); + assert_eq!(injections[0].type_name, "PaymentService"); + } + + /// Mapping detection must use the same closed set for spelling, boundary + /// matching, and HTTP methods so a prefix decoy cannot become an endpoint. + #[test] + fn every_mapping_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringMappingAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name}(\"/x\")")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Custom(\"/x\")")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringMappingAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringMappingAnnotation::ALL.len(), + "every mapping case needs a distinct annotation name" + ); + } + + #[test] + fn mapping_rejects_custom_annotations_that_only_share_a_standard_prefix() { + assert!(mapping("@GetMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PostMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PutMappingCustom(\"/x\")").is_none()); + assert!(mapping("@DeleteMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PatchMappingCustom(\"/x\")").is_none()); + assert!(mapping("@RequestMappingFoo(\"/base\")").is_none()); + } + + #[test] + fn mapping_keeps_standard_annotations_and_request_method_arrays() { + let get = mapping("@GetMapping(\"/x\")").expect("GetMapping should match"); + assert_eq!(get.0, SpringMappingAnnotation::GetMapping); + assert_eq!(get.1, vec!["GET".to_string()]); + assert_eq!(get.2, vec!["/x".to_string()]); + + let post = mapping("@PostMapping(path = \"/y\")").expect("PostMapping should match"); + assert_eq!(post.1, vec!["POST".to_string()]); + assert_eq!(post.2, vec!["/y".to_string()]); + + let put = mapping("@PutMapping(\"/z\")").expect("PutMapping should match"); + assert_eq!(put.1, vec!["PUT".to_string()]); + + let delete = mapping("@DeleteMapping(\"/d\")").expect("DeleteMapping should match"); + assert_eq!(delete.1, vec!["DELETE".to_string()]); + + let patch = mapping("@PatchMapping(\"/p\")").expect("PatchMapping should match"); + assert_eq!(patch.1, vec!["PATCH".to_string()]); + + let any = mapping("@RequestMapping(\"/base\")").expect("RequestMapping should match"); + assert_eq!(any.0, SpringMappingAnnotation::RequestMapping); + assert_eq!(any.1, vec!["ANY".to_string()]); + assert_eq!(any.2, vec!["/base".to_string()]); + + let multi = mapping( + "@RequestMapping(path = \"/multi\", method = { RequestMethod.POST, RequestMethod.GET, RequestMethod.GET })", + ) + .expect("RequestMapping with methods should match"); + assert_eq!(multi.1, vec!["GET".to_string(), "POST".to_string()]); + assert_eq!(multi.2, vec!["/multi".to_string()]); + } + + #[test] + fn mapping_selects_the_earliest_annotation_and_isolates_its_routes() { + let selected = mapping("@GetMappingCustom(\"/decoy\") @PostMapping(\"/real\")") + .expect("exact PostMapping should still match"); + assert_eq!(selected.0, SpringMappingAnnotation::PostMapping); + assert_eq!(selected.1, vec!["POST".to_string()]); + assert_eq!(selected.2, vec!["/real".to_string()]); + + let leftmost = mapping("@PutMapping(\"/first\") @GetMapping(\"/second\")") + .expect("leftmost Mapping should win"); + assert_eq!(leftmost.0, SpringMappingAnnotation::PutMapping); + assert_eq!(leftmost.2, vec!["/first".to_string()]); + } + + #[test] + fn mapping_does_not_take_routes_from_a_following_unrelated_annotation() { + let bare = + mapping("@GetMapping @Custom(\"/decoy\")").expect("bare GetMapping should still match"); + assert_eq!(bare.0, SpringMappingAnnotation::GetMapping); + assert_eq!(bare.1, vec!["GET".to_string()]); + assert_eq!(bare.2, vec![String::new()]); + + let spaced = mapping("@GetMapping (\"/x\") @Custom(\"/decoy\")") + .expect("GetMapping with its own argument list should keep that route"); + assert_eq!(spaced.2, vec!["/x".to_string()]); + } + + fn temporary_repository_root(label: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-core-spring-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + root + } + + /// A large Maven workspace can have several index requests in flight. Only + /// one may walk the dependency repository; the others must observe its + /// snapshot instead of reopening every archive. + #[test] + fn concurrent_repository_readers_share_one_scan() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{mpsc, Arc}; + use std::thread; + + let root = temporary_repository_root("single-flight"); + let scans = Arc::new(AtomicUsize::new(0)); + let (scan_started, started) = mpsc::channel(); + let (release, released) = mpsc::channel::<()>(); + + let owner_root = root.clone(); + let owner_scans = Arc::clone(&scans); + let owner = thread::spawn(move || { + let _scope = lithe_core::CancellationScope::begin(None, Some(5_000)); + repository_metadata_with(&owner_root, false, |_| { + owner_scans.fetch_add(1, Ordering::SeqCst); + scan_started.send(()).expect("scan start should notify"); + released + .recv_timeout(Duration::from_secs(5)) + .expect("scan release should arrive"); + Ok(vec![]) + }) + }); + started + .recv_timeout(Duration::from_secs(5)) + .expect("owner scan should start"); + + let waiter_root = root.clone(); + let waiter_scans = Arc::clone(&scans); + let waiter = thread::spawn(move || { + let _scope = lithe_core::CancellationScope::begin(None, Some(5_000)); + repository_metadata_with(&waiter_root, false, |_| { + waiter_scans.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }) + }); + // Release the owner only after the waiter has passed the cache lookup + // and holds the slot, so the waiter must join the running scan. + wait_for_slot_holders(&root, 4); + release.send(()).expect("owner scan should be releasable"); + + owner + .join() + .expect("owner thread should finish") + .expect("owner scan should succeed"); + waiter + .join() + .expect("waiter thread should finish") + .expect("waiter should reuse the owner snapshot"); + assert_eq!( + scans.load(Ordering::SeqCst), + 1, + "concurrent readers must not repeat the repository walk" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } + + /// Refreshes that arrive during the same walk share one follow-up scan, but + /// a refresh never accepts a snapshot from a walk that started before it. + #[test] + fn refresh_requests_accept_only_scans_started_after_they_arrived() { + let slot = RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Ready { + scan: 2, + properties: Arc::new(Vec::new()), + }), + ready: Condvar::new(), + }; + + assert!(matches!( + claim_repository_scan(&slot, true, 1) + .expect("a refresh that arrived before scan 2 should not wait"), + RepositoryClaim::Shared(_) + )); + assert!( + matches!( + claim_repository_scan(&slot, true, 2) + .expect("a refresh that arrived after scan 2 started should claim"), + RepositoryClaim::Owned(3) + ), + "a refresh must not reuse a walk that started before it" + ); + assert!(matches!( + *slot.state.lock().unwrap(), + RepositoryMetadataState::Scanning(3) + )); + } + + /// A refresh that lands while an ordinary scan is walking the repository + /// may miss archives downloaded before the refresh. It must wait for that + /// walk and then scan again instead of reusing its snapshot. + #[test] + fn a_refresh_during_a_running_scan_rescans_after_it() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::thread; + + let root = temporary_repository_root("refresh-mid-scan"); + let (scan_started, started) = mpsc::channel(); + let (release, released) = mpsc::channel::<()>(); + + let owner_root = root.clone(); + let owner = thread::spawn(move || { + let _scope = lithe_core::CancellationScope::begin(None, Some(5_000)); + repository_metadata_with(&owner_root, false, |_| { + scan_started.send(()).expect("scan start should notify"); + released + .recv_timeout(Duration::from_secs(5)) + .expect("scan release should arrive"); + Ok(vec![]) + }) + }); + started + .recv_timeout(Duration::from_secs(5)) + .expect("owner scan should start"); + + let refresh_root = root.clone(); + let refresh_scans = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&refresh_scans); + let refresher = thread::spawn(move || { + let _scope = lithe_core::CancellationScope::begin(None, Some(5_000)); + repository_metadata_with(&refresh_root, true, |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }) + }); + wait_for_slot_holders(&root, 4); + release.send(()).expect("owner scan should be releasable"); + + owner + .join() + .expect("owner thread should finish") + .expect("owner scan should succeed"); + refresher + .join() + .expect("refresh thread should finish") + .expect("refresh scan should succeed"); + assert_eq!( + refresh_scans.load(Ordering::SeqCst), + 1, + "a refresh must not reuse a walk that started before it" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } + + /// Blocks until `holders` references to the repository slot exist: the + /// cache, the running owner, this test, and each request that joined it. + fn wait_for_slot_holders(root: &Path, holders: usize) { + let key = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let slot = Arc::clone( + REPOSITORY_METADATA_CACHE + .get() + .expect("the owner should have created the cache") + .lock() + .unwrap() + .get(&key) + .expect("the owner should have created the slot"), + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while Arc::strong_count(&slot) < holders { + assert!( + std::time::Instant::now() < deadline, + "a second request should join the running scan" + ); + std::thread::yield_now(); + } + } + + /// A waiter belongs to one request. Cancelling that request must release + /// its worker instead of blocking until the owner finishes. + #[test] + fn a_cancelled_waiter_stops_waiting_for_the_repository_owner() { + let slot = RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Scanning(1)), + ready: Condvar::new(), + }; + let operation = "spring-repository-waiter-cancel"; + let _scope = lithe_core::CancellationScope::begin(Some(operation.to_string()), None); + assert!(lithe_core::cancel_operation(operation)); + + let error = claim_repository_scan(&slot, false, 1) + .expect_err("a cancelled waiter must stop waiting"); + assert!(matches!(error.code, ErrorCode::Cancelled)); + } + + /// Cancellation must reach the archive walk itself, not only the command + /// entry point, so a timed-out workspace scan releases its worker. + #[test] + fn a_cancelled_request_stops_before_walking_repository_archives() { + let root = temporary_repository_root("cancelled-scan"); + fs::write(root.join("dependency.jar"), b"not a zip archive") + .expect("archive fixture should be writable"); + let operation = "spring-repository-scan-cancel"; + let _scope = lithe_core::CancellationScope::begin(Some(operation.to_string()), None); + assert!(lithe_core::cancel_operation(operation)); + + assert!(matches!( + repository_metadata(&root, true) + .expect_err("a cancelled request must not scan") + .code, + ErrorCode::Cancelled + )); + assert!(matches!( + scan_repository_metadata(&root) + .expect_err("a cancelled scan must stop before reading archives") + .code, + ErrorCode::Cancelled + )); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/java.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/java.rs new file mode 100644 index 000000000..f25f1da2a --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/java.rs @@ -0,0 +1,298 @@ +//! `java.*` commands that still answer from the Windows host. Copied from Core's +//! `tests/languages.rs`; Core keeps its own copy until the macOS migration lands. + +use super::support::execute_json; +use serde_json::Value; + +#[test] +fn java_workspace_policy_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/lsp/java-workspace-policy-v1.json" + )) + .expect("Java workspace-policy fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("Java workspace-policy fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.workspacePolicy", + "payload": { + "workspacePaths": case["workspacePaths"], + "changedPaths": case["changedPaths"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Java workspace-policy response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn jdt_workspace_fingerprint_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json" + )) + .expect("JDT workspace-fingerprint fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("JDT workspace-fingerprint fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.jdtWorkspaceFingerprint", + "payload": { + "buildFiles": case["buildFiles"], + "directMavenModules": case["directMavenModules"], + "gradleWrapperDistribution": case["gradleWrapperDistribution"], + "jdtlsVersion": case["jdtlsVersion"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("JDT workspace-fingerprint response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!( + response["data"]["workspaceFingerprint"], case["workspaceFingerprint"], + "case {}", + case["name"] + ); + } +} + +#[test] +fn jdt_cache_retention_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/lsp/jdt-cache-retention-v1.json" + )) + .expect("JDT cache-retention fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("JDT cache-retention fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.jdtCacheRetention", + "payload": { + "nowUnixSeconds": case["nowUnixSeconds"], + "activeWorkspaceKey": case["activeWorkspaceKey"], + "entries": case["entries"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("JDT cache-retention response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn java_run_markers_match_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/java/run-markers-v1.json" + )) + .expect("Java Run-marker fixture should be valid JSON"); + for case in fixture["cases"] + .as_array() + .expect("Java Run-marker fixture should contain cases") + { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": case["name"], + "command": "java.runMarkers", + "payload": case["request"] + }) + .to_string(), + )) + .expect("Java Run-marker response should be JSON"); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn java_source_definition_finds_types_and_members() { + let definition = serde_json::json!({ + "id": "java-definition", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() {}\n}", + "declarationName": "App", + "memberName": "run" + } + }); + let definition_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&definition).expect("definition request should encode"), + )) + .expect("definition response should be JSON"); + assert_eq!(definition_response["data"]["line"], 1); + let member_lookup = |member: &str, declarations_only: bool| -> Value { + let request = serde_json::json!({ + "id": "java-member", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() { save(); total = 1; }\n String 名字, total;\n void save() {}\n}", + "declarationName": "App", + "memberName": member, + "declarationsOnly": declarations_only + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("member request should encode"), + )) + .expect("member response should be JSON"); + response["data"].clone() + }; + // The call and the assignment on line 1 come first but declare nothing. + assert_eq!(member_lookup("save", true)["line"], 3); + assert_eq!(member_lookup("total", true)["line"], 2); + assert_eq!(member_lookup("total", true)["utf16Column"], 15); + assert!(member_lookup("missing", true).is_null()); + assert_eq!(member_lookup("missing", false)["line"], 0); +} + +/// Resolves the member at the first occurrence of `marker` in `source`, the +/// marker being the text up to and including the start of the name. +fn referenced_member_line(source: &str, marker: &str, member: &str) -> Value { + let offset = source.find(marker).expect("marker should exist") + marker.len() - member.len(); + let before = &source[..offset]; + let line = before.matches('\n').count(); + let column = before[before.rfind('\n').map_or(0, |index| index + 1)..] + .encode_utf16() + .count(); + let request = serde_json::json!({ + "id": "java-reference", + "command": "java.sourceDefinition", + "payload": { + "source": source, + "declarationName": "Main", + "memberName": member, + "declarationsOnly": true, + "reference": { "line": line, "utf16Column": column } + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("reference request should encode"), + )) + .expect("reference response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["line"].clone() +} + +#[test] +fn java_member_definition_stays_inside_the_named_type() { + let lookup = |declaration: &str| -> Value { + let request = serde_json::json!({ + "id": "java-member", + "command": "java.sourceDefinition", + "payload": { + "source": "class Main {\n class Nested {\n void save() {}\n }\n void save() {}\n}\nclass Other {\n void only() {}\n}", + "declarationName": declaration, + "memberName": "save", + "declarationsOnly": true + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("member request should encode"), + )) + .expect("member response should be JSON"); + response["data"]["line"].clone() + }; + assert_eq!(lookup("Main"), 4); + assert_eq!(lookup("Nested"), 2); + assert!(lookup("Other").is_null()); + assert!(lookup("Missing").is_null()); +} + +#[test] +fn java_member_reference_binds_to_the_type_it_is_used_in() { + let source = "class Main {\n class Nested {\n void save() {}\n void use() { save(); }\n }\n void save() {}\n void run() { this.save(); }\n}"; + assert_eq!(referenced_member_line(source, "use() { save", "save"), 2); + assert_eq!(referenced_member_line(source, "this.save", "save"), 5); + + let outer = "class Main {\n void save() {}\n class Nested {\n void use() { save(); this.save(); }\n }\n}"; + assert_eq!(referenced_member_line(outer, "use() { save", "save"), 1); + assert!(referenced_member_line(outer, "this.save", "save").is_null()); +} + +#[test] +fn java_member_reference_keeps_methods_and_fields_apart() { + let source = "class Main {\n int save;\n void save() {}\n void run() { save(); int x = this.save; }\n}"; + assert_eq!(referenced_member_line(source, "run() { save", "save"), 2); + assert_eq!(referenced_member_line(source, "this.save", "save"), 1); +} + +#[test] +fn java_member_reference_answers_nothing_syntax_cannot_prove() { + let unknown_receiver = + "class Main {\n void save() {}\n void run(Main other) { other.save(); }\n}"; + assert!(referenced_member_line(unknown_receiver, "other.save", "save").is_null()); + let method_reference = "class Main {\n void save() {}\n Runnable run = this::save;\n}"; + assert!(referenced_member_line(method_reference, "this::save", "save").is_null()); + + // `Base` may declare `save`, and it would win over `Main.save`. + let inherited = "class Main {\n void save() {}\n class Nested extends Base {\n void use() { save(); }\n }\n}"; + assert!(referenced_member_line(inherited, "use() { save", "save").is_null()); + let anonymous = "class Main {\n void save() {}\n Object task = new Object() {\n void use() { save(); }\n };\n}"; + assert!(referenced_member_line(anonymous, "use() { save", "save").is_null()); + + for (shadowed, marker) in [ + ("class Main {\n int value;\n int run() { int value = 1; return value; }\n}", "return value"), + ("class Main {\n int value;\n int run(int value) { return value; }\n}", "return value"), + ("class Main {\n int value;\n void run() { for (int value : items) { use(value); } }\n}", "use(value"), + ("class Main {\n int value;\n void run() { try {} catch (Exception value) { use(value); } }\n}", "use(value"), + ("class Main {\n int value;\n Fn run = value -> use(value);\n}", "use(value"), + ("class Main {\n int value;\n Fn run = (value, other) -> use(value);\n}", "use(value"), + ("class Main {\n int value;\n void run(Object o) { if (o instanceof String value) { use(value); } }\n}", "use(value"), + ] { + assert!( + referenced_member_line(shadowed, marker, "value").is_null(), + "{shadowed}" + ); + } + // `this.` skips the local, and a local in a nested type's method does not count. + let qualified = "class Main {\n int value;\n int run(int value) { return this.value; }\n}"; + assert_eq!(referenced_member_line(qualified, "this.value", "value"), 1); + let plain = "class Main {\n int value;\n int run() { return value; }\n}"; + assert_eq!(referenced_member_line(plain, "return value", "value"), 1); +} + +#[test] +fn java_member_reference_covers_enum_and_record_scopes() { + let enumeration = "enum Main {\n ONE;\n int total;\n int run() { return total; }\n}"; + assert_eq!( + referenced_member_line(enumeration, "return total", "total"), + 2 + ); + // A record component is a field, so the outer field must not answer. + let record = + "class Main {\n int x;\n record Point(int x) {\n int run() { return x; }\n }\n}"; + assert_eq!(referenced_member_line(record, "return x", "x"), 2); +} + +#[test] +fn java_member_reference_ignores_a_position_outside_the_source() { + let source = "class Main {\n void save() {}\n void run() { save(); }\n}"; + let request = serde_json::json!({ + "id": "java-reference", + "command": "java.sourceDefinition", + "payload": { + "source": source, + "declarationName": "Main", + "memberName": "save", + "declarationsOnly": true, + "reference": { "line": 9, "utf16Column": 0 } + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("reference request should encode"), + )) + .expect("reference response should be JSON"); + assert!(response["data"].is_null()); +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs new file mode 100644 index 000000000..7fb3bb77e --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs @@ -0,0 +1,1350 @@ +//! `maven.*` commands: project scan, launch and dependency plans, dependency +//! trees, diagnostics and test results, plus the settings writer. Moved or copied +//! from Core's `tests/languages.rs`. + +use super::support::{execute_json, temporary_root}; +use crate::settings_with_local_repository; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +#[test] +fn maven_scan_returns_recursive_shared_project_model() { + let source_roots_fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/source-roots-v1.json" + )) + .expect("Maven source-roots fixture should be valid JSON"); + let root = temporary_root("maven"); + fs::create_dir_all(root.join("module-a/module-b")).expect("modules should be creatable"); + fs::write( + root.join("pom.xml"), + r#"com.exampledemo1pommodule-adevtrue"#, + ) + .expect("root pom should be writable"); + fs::write( + root.join("module-a/pom.xml"), + r#"onemodule-b"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("module-a/module-b/pom.xml"), + r#"two"#, + ) + .expect("nested pom should be writable"); + fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); + + let request = serde_json::json!({ + "id": "maven", + "command": "maven.scan", + "payload": {"root": root, "paths": ["module-a/pom.xml", "pom.xml"]} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["relativePath"], "."); + assert_eq!(response["data"]["artifactId"], "demo"); + assert_eq!(response["data"]["packaging"], "pom"); + assert_eq!(response["data"]["profiles"][0]["id"], "dev"); + assert_eq!(response["data"]["hasWrapper"], true); + assert_eq!(response["data"]["sourceRoots"].as_array().unwrap().len(), 0); + assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); + assert_eq!( + response["data"]["modules"][0]["sourceRoots"], + source_roots_fixture["cases"][0]["sourceRoots"] + ); + assert_eq!( + response["data"]["modules"][0]["modules"][0]["relativePath"], + "module-a/module-b" + ); + let diagnostics = serde_json::json!({ + "id": "maven-diagnostics", + "command": "maven.diagnostics", + "payload": { + "root": root, + "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" + } + }); + let diagnostics_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), + )) + .expect("diagnostics response should be JSON"); + assert_eq!(diagnostics_response["ok"], true); + assert_eq!( + diagnostics_response["data"]["issues"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + diagnostics_response["data"]["issues"][0]["severity"], + "error" + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn maven_scan_recognizes_configured_and_generated_source_roots_per_module() { + let source_roots_fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/source-roots-v1.json" + )) + .expect("Maven source-roots fixture should be valid JSON"); + let root = temporary_root("maven-source-roots"); + fs::create_dir_all(root.join("module")).expect("module should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demopommodule"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("module/pom.xml"), + r#"configured${project.basedir}/custom/javacustom/resourcescustom/resources../outsidecustom\test-javacustom/test-resourcesmaven-compiler-plugin${project.build.directory}/generated-sources/annotationstarget/generated-test-sources"#, + ) + .expect("configured pom should be writable"); + + let request = serde_json::json!({ + "id": "maven-source-roots", + "command": "maven.scan", + "payload": {"root": root, "paths": ["module/pom.xml"]} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["modules"][0]["sourceRoots"], + source_roots_fixture["cases"][1]["sourceRoots"] + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn maven_scan_expands_custom_build_directory_and_execution_scoped_sources() { + let source_roots_fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/source-roots-v1.json" + )) + .expect("Maven source-roots fixture should be valid JSON"); + let root = temporary_root("maven-source-root-regressions"); + fs::create_dir_all(root.join("custom-build")).expect("custom module should be creatable"); + fs::create_dir_all(root.join("execution-scoped")) + .expect("execution module should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demopomcustom-buildexecution-scoped"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("custom-build/pom.xml"), + r#"custom-buildbuild-outputmaven-compiler-plugin${project.build.directory}/generated-sources/annotations${project.build.directory}/generated-test-sources/fixtures"#, + ) + .expect("custom build pom should be writable"); + fs::write( + root.join("execution-scoped/pom.xml"), + r#"execution-scopedorg.codehaus.mojoadd-generatedtarget/generated-sources/openapitarget/generated-test-sources/fixturesbuild-helper-maven-plugin"#, + ) + .expect("execution-scoped pom should be writable"); + + let request = serde_json::json!({ + "id": "maven-source-root-regressions", + "command": "maven.scan", + "payload": {"root": root, "paths": ["custom-build/pom.xml"]} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["modules"][0]["sourceRoots"], + source_roots_fixture["cases"][2]["sourceRoots"] + ); + assert_eq!( + response["data"]["modules"][1]["sourceRoots"], + source_roots_fixture["cases"][3]["sourceRoots"] + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn maven_scan_ignores_unrelated_plugin_sources_and_invalid_build_directory() { + let source_roots_fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/source-roots-v1.json" + )) + .expect("Maven source-roots fixture should be valid JSON"); + let root = temporary_root("maven-source-root-boundaries"); + fs::create_dir_all(root.join("unrelated-plugin")) + .expect("unrelated plugin module should be creatable"); + fs::create_dir_all(root.join("invalid-build")) + .expect("invalid build module should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demopomunrelated-plugininvalid-build"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("unrelated-plugin/pom.xml"), + r#"unrelated-plugintarget/generated-sources/not-a-source-rootunrelated-plugin"#, + ) + .expect("unrelated plugin pom should be writable"); + fs::write( + root.join("invalid-build/pom.xml"), + r#"invalid-build${unresolved.output}"#, + ) + .expect("invalid build pom should be writable"); + + let request = serde_json::json!({ + "id": "maven-source-root-boundaries", + "command": "maven.scan", + "payload": {"root": root, "paths": ["pom.xml"]} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["modules"][0]["sourceRoots"], + source_roots_fixture["cases"][0]["sourceRoots"] + ); + let mut expected_without_generated = source_roots_fixture["cases"][0]["sourceRoots"] + .as_array() + .expect("standard source roots should be an array") + .clone(); + expected_without_generated.truncate(4); + assert_eq!( + response["data"]["modules"][1]["sourceRoots"], + Value::Array(expected_without_generated) + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn maven_launch_plan_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/launch-plan-v1.json" + )) + .expect("Maven launch-plan fixture should be valid JSON"); + let root = temporary_root("maven-launch-plan"); + fs::create_dir_all(root.join("projects/demo/service-api")) + .expect("nested Maven reactor should be creatable"); + fs::write( + root.join("projects/demo/pom.xml"), + r#"demopomservice-api"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("projects/demo/service-api/pom.xml"), + r#"service-api"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("pom.xml"), + r#"root"#, + ) + .expect("root pom should be writable"); + fs::create_dir_all(root.join(".mvn")).expect("Maven config directory should be creatable"); + fs::write(root.join(".mvn/maven.config"), "-DfromConfig=true\n") + .expect("Maven config should be writable"); + + for case in fixture["cases"] + .as_array() + .expect("Maven launch-plan fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "maven.launchPlan", + "payload": { + "root": root, + "context": case["context"], + "module": case["module"], + "goals": case["goals"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven launch-plan response should be JSON"); + + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + assert!(!response["data"]["arguments"] + .as_array() + .expect("arguments should be an array") + .iter() + .any(|argument| argument == "-DfromConfig=true")); + } + fs::remove_dir_all(root).expect("Maven launch-plan fixture should be removable"); +} + +/// Temporary directory that is removed even when an assertion fails. +struct TreeDirectory(PathBuf); + +impl TreeDirectory { + fn new(label: &str) -> Self { + let path = temporary_root(label); + fs::create_dir_all(&path).expect("temporary directory should be creatable"); + Self(path) + } + + fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, contents).expect("dependency-tree file should be writable"); + path + } +} + +impl Drop for TreeDirectory { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not remove dependency-tree fixture: {error}"); + } + } +} + +fn maven_dependency_plan(root: &Path, module: Value, output_file: Value) -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-dependency-plan", + "command": "maven.dependencyPlan", + "payload": { + "root": root, + "context": { + "version": 1, + "reactorPath": ".", + "profiles": ["dev"] + }, + "module": module, + "outputFile": output_file + } + }) + .to_string(), + )) + .expect("Maven dependency-plan response should be JSON") +} + +fn maven_dependencies(module_path: &str, output_file: &Path) -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-dependencies", + "command": "maven.dependencies", + "payload": {"modulePath": module_path, "outputFile": output_file} + }) + .to_string(), + )) + .expect("Maven dependency response should be JSON") +} + +/// One tree line per node, as the pinned plugin writes a flat direct list. +fn flat_tree(nodes: usize) -> String { + let mut tree = String::from("com.example:service:jar:1.0.0\n"); + for index in 0..nodes { + tree.push_str(&format!( + "+- org.example.application.platform.dependencies:library-{index}:jar:1.0.0:compile (version managed from 0.9.0)\n" + )); + } + tree +} + +#[test] +fn maven_dependency_plan_writes_one_module_tree_to_the_platform_file() { + let workspace = TreeDirectory::new("maven-dependency-plan"); + let root = &workspace.0; + fs::create_dir_all(root.join("service")).expect("Maven module should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demopomservice"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("service/pom.xml"), + r#"service"#, + ) + .expect("module pom should be writable"); + let output_file = root.join("scratch/tree.txt"); + let tree_arguments = |output_file: &Path| { + vec![ + Value::from("org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"), + Value::from("-Dverbose=true"), + Value::from("-DoutputType=text"), + Value::from("-Dtokens=standard"), + Value::from(format!("-DoutputFile={}", output_file.display())), + Value::from("-DoutputEncoding=UTF-8"), + Value::from("-DappendOutput=false"), + Value::from("-Dstyle.color=never"), + Value::from("-Duser.language=en"), + Value::from("-Duser.country=US"), + ] + }; + + let module = + maven_dependency_plan(root, Value::from("service"), serde_json::json!(output_file)); + assert_eq!(module["ok"], true, "{module}"); + let mut expected = vec![ + Value::from("-B"), + Value::from("-ntp"), + Value::from("-P"), + Value::from("dev"), + Value::from("-pl"), + Value::from("service"), + ]; + expected.extend(tree_arguments(&output_file)); + assert_eq!(module["data"]["arguments"], Value::Array(expected)); + + // Without `-N` every reactor project would overwrite the same file, and + // the root query would return whichever module Maven visited last. + for root_module in [Value::Null, Value::from(".")] { + let reactor = + maven_dependency_plan(root, root_module.clone(), serde_json::json!(output_file)); + assert_eq!(reactor["ok"], true, "{root_module}: {reactor}"); + let mut expected = vec![ + Value::from("-B"), + Value::from("-ntp"), + Value::from("-P"), + Value::from("dev"), + Value::from("-N"), + ]; + expected.extend(tree_arguments(&output_file)); + assert_eq!( + reactor["data"]["arguments"], + Value::Array(expected), + "{root_module}" + ); + } +} + +#[test] +fn maven_dependency_plan_rejects_output_paths_maven_would_misread() { + let workspace = TreeDirectory::new("maven-dependency-plan-output"); + let root = &workspace.0; + fs::write( + root.join("pom.xml"), + r#"demo"#, + ) + .expect("pom should be writable"); + let absolute = root.join("tree.txt").display().to_string(); + for (name, output_file) in [ + ("missing", Value::Null), + ("empty", Value::from("")), + ("relative", Value::from("scratch/tree.txt")), + ("padded", Value::from(format!(" {absolute}"))), + ( + "control", + Value::from(format!("{absolute}\n-Dverbose=false")), + ), + ] { + let response = maven_dependency_plan(root, Value::Null, output_file); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "case {name}"); + } +} + +#[test] +fn maven_dependencies_match_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/dependency-tree-v2.json" + )) + .expect("Maven dependency-tree fixture should be valid JSON"); + let directory = TreeDirectory::new("maven-dependencies-fixture"); + let tree = fixture["treeFile"] + .as_str() + .expect("fixture tree should be text"); + let module_path = fixture["modulePath"] + .as_str() + .expect("fixture module should be text"); + + // The plugin writes with the JVM line separator, so Windows produces CRLF. + for (name, contents) in [ + ("lf", tree.to_string()), + ("crlf", tree.replace('\n', "\r\n")), + ] { + let file = directory.write(&format!("{name}.txt"), contents); + let response = maven_dependencies(module_path, &file); + assert_eq!(response["ok"], true, "case {name}: {response}"); + assert_eq!(response["data"], fixture["expected"], "case {name}"); + } +} + +#[test] +fn maven_dependencies_accept_a_large_tree_that_exceeded_the_console_budget() { + // Issue #890: 6,000 nodes are about 700 KB of text, above the former + // 500,000-character console budget but well inside the node limit. + let directory = TreeDirectory::new("maven-dependencies-large"); + let tree = flat_tree(6_000); + assert!(tree.len() > 500_000); + let response = maven_dependencies(".", &directory.write("tree.txt", tree)); + assert_eq!(response["ok"], true, "{}", response["error"]); + let dependencies = response["data"]["dependencies"] + .as_array() + .expect("dependencies should be an array"); + assert_eq!(dependencies.len(), 6_000); + assert!(dependencies + .iter() + .all(|dependency| dependency["premanagedVersion"] == "0.9.0")); + + let limit = maven_dependencies(".", &directory.write("limit.txt", flat_tree(10_000))); + assert_eq!(limit["ok"], true, "{}", limit["error"]); +} + +#[test] +fn maven_dependencies_reject_trees_beyond_their_bounds() { + let directory = TreeDirectory::new("maven-dependencies-bounds"); + let oversized = directory.0.join("oversized.txt"); + // A sparse file reaches the byte limit without writing tens of megabytes. + fs::File::create(&oversized) + .and_then(|file| file.set_len((10_000 + 1) * 4 * 1024 + 1)) + .expect("oversized tree should be creatable"); + let long_line = format!( + "com.example:service:jar:1.0.0\n+- org.example:{}:jar:1:compile\n", + "a".repeat(4 * 1024) + ); + let too_deep = format!( + "com.example:service:jar:1.0.0\n{}\\- example:deep:jar:1:compile\n", + "| ".repeat(64) + ); + + for (name, file, details) in [ + ("bytes", oversized, "maximumBytes="), + ( + "nodes", + directory.write("nodes.txt", flat_tree(10_001)), + "maximumNodes=10000", + ), + ("line", directory.write("line.txt", long_line), "line=2"), + ( + "depth", + directory.write("depth.txt", too_deep), + "maximumDepth=64", + ), + ] { + let response = maven_dependencies(".", &file); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "parse_failed", "case {name}"); + assert!( + response["error"]["details"] + .as_str() + .is_some_and(|value| value.starts_with(details)), + "case {name}: {response}" + ); + } +} + +#[test] +fn maven_dependencies_report_unexpected_file_contents_instead_of_a_partial_tree() { + // Each case would previously have been skipped line by line and shown as + // a shorter tree. A project POM can override the plugin's output type or + // tokens, so the file format is validated rather than assumed. + let directory = TreeDirectory::new("maven-dependencies-format"); + for (name, contents) in [ + ("empty", String::new()), + ( + "dot", + "digraph \"com.example:service:jar:1.0.0\" {\n\t\"com.example:service:jar:1.0.0\" -> \"org.example:lib:jar:1.0:compile\" ;\n }\n".to_string(), + ), + ( + "extended-tokens", + "com.example:service:jar:1.0.0\n\u{251C}\u{2500} org.example:lib:jar:1.0:compile\n".to_string(), + ), + ( + "console-log", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile\n[INFO] BUILD SUCCESS\n".to_string(), + ), + ( + "unknown-annotation", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile (version selected from range [1.0,2.0))\n".to_string(), + ), + ( + "omitted-without-reason", + "com.example:service:jar:1.0.0\n+- (org.example:lib:jar:1.0:compile - version managed from 0.9)\n".to_string(), + ), + ( + "included-with-omission", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile (omitted for duplicate)\n".to_string(), + ), + ( + "invalid-utf8", + "com.example:service:jar:1.0.0\n+- org.example:lib-\u{FFFD}:jar:1.0:compile\n".to_string(), + ), + ] { + let mut bytes = contents.into_bytes(); + if name == "invalid-utf8" { + let replacement = "\u{FFFD}".as_bytes(); + let index = bytes + .windows(replacement.len()) + .position(|window| window == replacement) + .expect("placeholder should be present"); + bytes.splice(index..index + replacement.len(), [0xFF]); + } + let response = maven_dependencies(".", &directory.write(&format!("{name}.txt"), bytes)); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "parse_failed", "case {name}"); + } + + let root_only = maven_dependencies( + ".", + &directory.write("root-only.txt", "com.example:service:jar:1.0.0\n"), + ); + assert_eq!(root_only["ok"], true, "{root_only}"); + assert_eq!(root_only["data"]["dependencies"], serde_json::json!([])); +} + +#[test] +fn maven_dependencies_report_a_missing_tree_file_as_a_process_failure() { + let directory = TreeDirectory::new("maven-dependencies-missing"); + let response = maven_dependencies(".", &directory.0.join("never-written.txt")); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "process_failed"); + + let relative = maven_dependencies(".", Path::new("tree.txt")); + assert_eq!(relative["error"]["code"], "invalid_request", "{relative}"); +} + +#[test] +fn maven_test_results_match_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../../shared/fixtures/maven/test-results-v1.json" + )) + .expect("Maven test-results fixture should be valid JSON"); + for case in fixture["cases"] + .as_array() + .expect("Maven test-results fixture should contain cases") + { + let root = temporary_root("maven-test-results"); + fs::create_dir_all(&root).expect("Maven test workspace should be creatable"); + for source in case["sourceFiles"] + .as_array() + .expect("source files should be an array") + { + let path = root.join(source.as_str().expect("source path should be text")); + fs::create_dir_all(path.parent().expect("source should have a parent")) + .expect("source directory should be creatable"); + fs::write(path, "class CalculatorTest {}").expect("source should be writable"); + } + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": case["name"], + "command": "maven.testResults", + "payload": {"root": root, "output": case["output"]} + }) + .to_string(), + )) + .expect("Maven test-results response should be JSON"); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + fs::remove_dir_all(root).expect("Maven test fixture should be removable"); + } +} + +// Editors mark each test method with its outcome, which only the XML reports +// record for passing and skipped methods. Reports from an earlier run and +// reports of classes the run did not select must not leak into the result. +#[test] +fn maven_test_results_read_current_reports_of_the_selected_classes() { + let root = temporary_root("maven-test-reports"); + let module = root.join("service"); + fs::create_dir_all(module.join("build/custom-reports")).expect("report directory"); + fs::create_dir_all(module.join("build/failsafe-reports")).expect("failsafe directory"); + fs::write( + module.join("pom.xml"), + r#"build +maven-surefire-plugin +${project.build.directory}/custom-reports +"#, + ) + .expect("module pom"); + let stale = module.join("build/custom-reports/TEST-demo.StaleTest.xml"); + fs::write( + &stale, + r#""#, + ) + .expect("stale report"); + // Fixed timestamps keep the freshness check independent of the wall clock. + let run_started_seconds = 1_700_000_000_u64; + let not_before_millis = run_started_seconds * 1_000; + let set_modified = |path: &std::path::Path, seconds: u64| { + fs::File::options() + .write(true) + .open(path) + .and_then(|file| { + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds)) + }) + .expect("report timestamp"); + }; + set_modified(&stale, run_started_seconds - 600); + fs::write( + module.join("build/custom-reports/TEST-demo.OrderTest.xml"), + r#" + + + + +"#, + ) + .expect("current report"); + fs::write( + module.join("build/failsafe-reports/TEST-demo.OrderTest$Refunds.xml"), + r#""#, + ) + .expect("nested report"); + fs::write( + module.join("build/custom-reports/TEST-demo.OtherTest.xml"), + r#""#, + ) + .expect("unselected report"); + for report in [ + "build/custom-reports/TEST-demo.OrderTest.xml", + "build/failsafe-reports/TEST-demo.OrderTest$Refunds.xml", + "build/custom-reports/TEST-demo.OtherTest.xml", + ] { + set_modified(&module.join(report), run_started_seconds + 5); + } + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-reports", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 1\n", + "reports": { + "module": "service", + "classes": ["demo.OrderTest", "demo.StaleTest"], + "notBeforeMillis": not_before_millis + } + } + }) + .to_string(), + )) + .expect("report response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["testCases"], + serde_json::json!([ + { "className": "demo.OrderTest", "method": "creates", "status": "passed", "message": null, "invocations": 1 }, + { "className": "demo.OrderTest", "method": "later", "status": "skipped", "message": null, "invocations": 1 }, + { "className": "demo.OrderTest", "method": "priced", "status": "failed", "message": "expected 3", "invocations": 2 }, + { "className": "demo.OrderTest$Refunds", "method": "refunds", "status": "passed", "message": null, "invocations": 1 } + ]) + ); + fs::remove_dir_all(root).expect("report fixture should be removable"); +} + +// A run started from the reactor root does not name a module or class; the +// test file locates the module and the run's start time selects its reports. +#[test] +fn maven_test_results_find_the_module_from_the_test_source() { + let root = temporary_root("maven-test-reports-source"); + let module = root.join("service"); + let source = module.join("src/test/java/demo/OrderTest.java"); + fs::create_dir_all(source.parent().expect("source parent")).expect("source directory"); + fs::create_dir_all(module.join("target/surefire-reports")).expect("report directory"); + fs::write(root.join("pom.xml"), "").expect("root pom"); + fs::write(module.join("pom.xml"), "").expect("module pom"); + fs::write(&source, "class OrderTest {}").expect("source"); + let report = module.join("target/surefire-reports/TEST-demo.OrderTest.xml"); + fs::write( + &report, + r#""#, + ) + .expect("report"); + fs::File::options() + .write(true) + .open(&report) + .and_then(|file| { + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_010)) + }) + .expect("report timestamp"); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-reports-source", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "", + "reports": { + "sourcePath": "service/src/test/java/demo/OrderTest.java", + "notBeforeMillis": 1_700_000_000_000_u64 + } + } + }) + .to_string(), + )) + .expect("report response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["testCases"], + serde_json::json!([ + { "className": "demo.OrderTest", "method": "creates", "status": "passed", "message": null, "invocations": 1 } + ]) + ); + fs::remove_dir_all(root).expect("report fixture should be removable"); +} + +// Many stale reports sorting ahead of the run's own report must not push it +// past the report-file bound when the request names no classes. +#[test] +fn maven_test_results_keep_current_reports_beyond_many_stale_ones() { + let root = temporary_root("maven-test-reports-bound"); + let reports = root.join("target/surefire-reports"); + fs::create_dir_all(&reports).expect("report directory"); + let set_modified = |path: &std::path::Path, seconds: u64| { + fs::File::options() + .write(true) + .open(path) + .and_then(|file| { + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds)) + }) + .expect("report timestamp"); + }; + for index in 0..600 { + let path = reports.join(format!("TEST-a.Stale{index:03}Test.xml")); + fs::write(&path, "").expect("stale report"); + set_modified(&path, 1_600_000_000); + } + let current = reports.join("TEST-z.CurrentTest.xml"); + fs::write( + ¤t, + r#""#, + ) + .expect("current report"); + set_modified(¤t, 1_700_000_010); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-reports-bound", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "", + "reports": { "notBeforeMillis": 1_700_000_000_000_u64 } + } + }) + .to_string(), + )) + .expect("report response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["testCases"][0]["className"], + "z.CurrentTest" + ); + fs::remove_dir_all(root).expect("report fixture should be removable"); +} + +#[test] +fn maven_test_results_reject_report_requests_outside_the_workspace() { + let root = temporary_root("maven-test-reports-bounds"); + fs::create_dir_all(&root).expect("workspace"); + for (module, classes) in [ + ("..", serde_json::json!(["demo.OrderTest"])), + (".", serde_json::json!(["../demo.OrderTest"])), + ] { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-reports-bounds", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "", + "reports": { "module": module, "classes": classes, "notBeforeMillis": 0 } + } + }) + .to_string(), + )) + .expect("bounds response should be JSON"); + assert_eq!(response["ok"], false, "{module}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{module}"); + } + fs::remove_dir_all(root).expect("bounds fixture should be removable"); +} + +#[test] +fn maven_test_results_aggregate_class_summaries_without_results_footer() { + let root = temporary_root("maven-test-results-aggregate"); + fs::create_dir_all(&root).expect("Maven aggregate workspace should be creatable"); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-results-aggregate", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "[INFO] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.12 s - in FirstTest\n[INFO] Tests run: 3, Failures: 0, Errors: 1, Skipped: 1, Time elapsed: 0.08 s <<< FAILURE! -- in SecondTest\n" + } + }) + .to_string(), + )) + .expect("aggregate Maven test-results response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["testsRun"], 5); + assert_eq!(response["data"]["failures"], 1); + assert_eq!(response["data"]["errors"], 1); + assert_eq!(response["data"]["skipped"], 1); + assert_eq!(response["data"]["passed"], 2); + assert_eq!(response["data"]["success"], false); + fs::remove_dir_all(root).expect("Maven aggregate fixture should be removable"); +} + +#[test] +fn maven_test_results_aggregate_all_module_footers_and_ignore_reactor_lines() { + let root = temporary_root("maven-test-results-footers"); + fs::create_dir_all(&root).expect("Maven footer workspace should be creatable"); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-results-footers", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "[INFO] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0\n[INFO] Tests run: 3, Failures: 0, Errors: 1, Skipped: 1\n[INFO] Reactor Summary for reactor 1.0-SNAPSHOT:\n[INFO] base ................................ SUCCESS\n[INFO] app ................................ FAILURE\n" + } + }) + .to_string(), + )) + .expect("footer Maven test-results response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["testsRun"], 5); + assert_eq!(response["data"]["failures"], 1); + assert_eq!(response["data"]["errors"], 1); + assert_eq!(response["data"]["skipped"], 1); + assert_eq!(response["data"]["passed"], 2); + assert_eq!(response["data"]["failureDetails"], serde_json::json!([])); + fs::remove_dir_all(root).expect("Maven footer fixture should be removable"); +} + +#[test] +fn maven_test_results_reject_oversized_output_and_bound_failure_details() { + let root = temporary_root("maven-test-results-bounds"); + fs::create_dir_all(&root).expect("Maven bounds workspace should be creatable"); + let oversized = serde_json::from_str::(&execute_json( + &serde_json::json!({ + "id": "oversized", + "command": "maven.testResults", + "payload": {"root": root, "output": "x".repeat(500_001)} + }) + .to_string(), + )) + .expect("oversized test-results response should be JSON"); + assert_eq!(oversized["ok"], false); + assert_eq!(oversized["error"]["code"], "parse_failed"); + fs::remove_dir_all(root).expect("Maven bounds fixture should be removable"); +} + +#[test] +fn maven_launch_plan_rejects_unknown_modules_and_invalid_invocation_tokens() { + let root = temporary_root("maven-launch-invalid"); + fs::create_dir_all(&root).expect("invalid Maven fixture should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demo"#, + ) + .expect("pom should be writable"); + for (name, module, goals) in [ + ("unknown-module", Some("missing"), vec!["verify"]), + ("missing-goal", None, vec!["-q", "-DskipTests"]), + ("invalid-goal", None, vec!["verify;", "-q"]), + ( + "control-character", + None, + vec!["verify", "-Dvalue=line\nbreak"], + ), + ] { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": name, + "command": "maven.launchPlan", + "payload": { + "root": root, + "context": {"version": 1, "reactorPath": "."}, + "module": module, + "goals": goals + } + }) + .to_string(), + )) + .expect("invalid Maven response should be JSON"); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } + fs::remove_dir_all(root).expect("invalid Maven fixture should be removable"); +} + +#[test] +fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { + let root = temporary_root("nested-maven"); + fs::create_dir_all(root.join("apps-a/module-a")).expect("first project should be creatable"); + fs::create_dir_all(root.join("apps-z")).expect("second project should be creatable"); + fs::write( + root.join("apps-a/pom.xml"), + r#"selectedpommodule-a"#, + ) + .expect("first pom should be writable"); + fs::write( + root.join("apps-a/module-a/pom.xml"), + r#"child"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("apps-z/pom.xml"), + r#"other"#, + ) + .expect("second pom should be writable"); + + let request = serde_json::json!({ + "id": "nested-maven", + "command": "maven.scan", + "payload": { + "root": root, + "paths": [ + "apps-z/pom.xml", + "apps-a/module-a/pom.xml", + "apps-a/pom.xml" + ] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["relativePath"], "apps-a"); + assert_eq!(response["data"]["artifactId"], "selected"); + assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn maven_scan_skips_a_malformed_root_descriptor_for_a_valid_nested_project() { + let root = temporary_root("nested-maven-malformed-root"); + fs::create_dir_all(root.join("projects/demo")).expect("nested project should be creatable"); + fs::write(root.join("pom.xml"), "broken") + .expect("malformed root pom should be writable"); + fs::write( + root.join("projects/demo/pom.xml"), + r#"selected"#, + ) + .expect("nested pom should be writable"); + + let request = serde_json::json!({ + "id": "nested-maven-malformed-root", + "command": "maven.scan", + "payload": { + "root": root, + "paths": ["pom.xml", "projects/demo/pom.xml"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["relativePath"], "projects/demo"); + assert_eq!(response["data"]["artifactId"], "selected"); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +const SETTINGS_WITH_MIRROR: &str = r#" + + + C:\Users\example\.m2\repository + + + aliyunmaven + * + https://maven.aliyun.com/repository/public + + + +"#; + +#[test] +fn generated_maven_settings_replace_the_repository_and_keep_every_mirror() { + // Losing the mirror would send project import to the default remote + // repositories while Maven builds keep using the configured one. + let generated = settings_with_local_repository(SETTINGS_WITH_MIRROR, r"F:\repository") + .expect("settings should be rewritten"); + + assert!(generated.contains(r"F:\repository")); + assert!(!generated.contains(r"C:\Users\example\.m2\repository")); + assert!(generated.contains("aliyunmaven")); + assert!(generated.contains("https://maven.aliyun.com/repository/public")); + assert!(generated.contains("")); +} + +#[test] +fn generated_maven_settings_insert_a_missing_repository_without_losing_mirrors() { + let source = r#" + + aliyunmaven* + +"#; + + let generated = settings_with_local_repository(source, "/opt/repository") + .expect("settings should be rewritten"); + + assert!(generated.contains("/opt/repository")); + assert!(generated.contains("aliyunmaven")); +} + +#[test] +fn generated_maven_settings_expand_an_empty_repository_element() { + let generated = settings_with_local_repository( + "", + "/opt/repository", + ) + .expect("settings should be rewritten"); + + assert_eq!( + generated, + "/opt/repository" + ); +} + +#[test] +fn generated_maven_settings_escape_the_repository_path() { + // An unescaped `&` would produce a document Maven cannot parse. + let generated = settings_with_local_repository("", "/opt/a&b") + .expect("settings should be rewritten"); + + assert!(generated.contains("/opt/a&b")); +} + +#[test] +fn generated_maven_settings_leave_profile_scoped_repositories_alone() { + // Only the top-level element selects the repository Maven resolves through. + let source = r#" + /old + + /profile-scoped + +"#; + + let generated = + settings_with_local_repository(source, "/new").expect("settings should be rewritten"); + + assert!(generated.contains("/new")); + assert!(generated.contains("/profile-scoped")); + assert!(!generated.contains("/old")); +} + +#[test] +fn generated_maven_settings_discard_child_elements_of_the_replaced_repository() { + // `` is a text-only element, so a nested child is already + // invalid. The rewrite must still produce well-formed output rather than + // emitting a start tag whose end tag was dropped with the old content. + let generated = settings_with_local_repository( + "x/old", + "/new", + ) + .expect("settings should be rewritten"); + + assert_eq!( + generated, + "/new" + ); +} + +/// The generated-source extraction identifies a plugin by `groupId:artifactId` +/// as well: explicit official coordinates of the compiler and build-helper +/// plugins contribute their directories, while same-named plugins under another +/// group and ones whose group is still an expression contribute nothing. +#[test] +fn maven_scan_requires_the_official_coordinate_for_source_root_plugins() { + let root = temporary_root("maven-scan-plugin-coordinates"); + for module in [ + "official", + "custom", + "expression", + "bh-official", + "bh-custom", + ] { + fs::create_dir_all(root.join(module)).unwrap(); + } + fs::write( + root.join("pom.xml"), + "demopomofficialcustomexpressionbh-officialbh-custom", + ) + .unwrap(); + for (module, group, directory) in [ + ( + "official", + "org.apache.maven.plugins", + "target/generated-sources/official", + ), + ("custom", "com.example", "target/generated-sources/custom"), + ( + "expression", + "${plugin.group}", + "target/generated-sources/expression", + ), + ] { + fs::write( + root.join(module).join("pom.xml"), + format!("{module}{group}maven-compiler-plugin{directory}"), + ) + .unwrap(); + } + for (module, group) in [ + ("bh-official", "org.codehaus.mojo"), + ("bh-custom", "com.example"), + ] { + fs::write( + root.join(module).join("pom.xml"), + format!("{module}{group}add-generatedtarget/generated-sources/bhbuild-helper-maven-plugin"), + ) + .unwrap(); + } + + let request = serde_json::json!({ + "id": "maven-scan-plugin-coordinates", + "command": "maven.scan", + "payload": {"root": root, "paths": ["official/pom.xml"]} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + + let modules = response["data"]["modules"].as_array().unwrap(); + assert_eq!(modules.len(), 5, "{response}"); + let paths = |index: usize| -> Vec { + modules[index]["sourceRoots"] + .as_array() + .unwrap() + .iter() + .filter_map(|value| value["path"].as_str().map(str::to_string)) + .collect() + }; + assert!( + paths(0).contains(&"target/generated-sources/official".to_string()), + "{response}" + ); + assert!( + paths(3).contains(&"target/generated-sources/bh".to_string()), + "{response}" + ); + for index in [1_usize, 2, 4] { + assert!( + !paths(index) + .iter() + .any(|path| path.starts_with("target/generated-sources/")), + "module {index} must not contribute generated roots: {response}" + ); + } + + fs::remove_dir_all(root).unwrap(); +} + +/// Reports are read from the directories of plugins identified by +/// `groupId:artifactId`: each official Surefire/Failsafe coordinate contributes +/// its own custom `reportsDirectory`, while a same-named plugin under another +/// group contributes nothing, so its reports stay unread. +#[test] +fn maven_test_results_require_the_official_coordinate_for_report_plugins() { + let root = temporary_root("maven-test-report-coordinates"); + for module in ["official-report", "custom-report"] { + fs::create_dir_all(root.join(module).join("build/surefire-dir")).unwrap(); + fs::create_dir_all(root.join(module).join("build/failsafe-dir")).unwrap(); + } + fs::write( + root.join("pom.xml"), + "platformpomofficial-reportcustom-report", + ) + .unwrap(); + for (module, group) in [ + ("official-report", "org.apache.maven.plugins"), + ("custom-report", "com.example"), + ] { + fs::write( + root.join(module).join("pom.xml"), + format!("{module}build{group}maven-surefire-plugin${{project.build.directory}}/surefire-dir{group}maven-failsafe-plugin${{project.build.directory}}/failsafe-dir"), + ) + .unwrap(); + } + let set_modified = |path: &std::path::Path, seconds: u64| { + fs::File::options() + .write(true) + .open(path) + .and_then(|file| { + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds)) + }) + .expect("report timestamp"); + }; + for (module, class_name) in [ + ("official-report", "demo.OfficialSurefireTest"), + ("official-report", "demo.OfficialFailsafeTest"), + ("custom-report", "demo.CustomSurefireTest"), + ("custom-report", "demo.CustomFailsafeTest"), + ] { + let directory = if class_name.contains("Failsafe") { + "failsafe-dir" + } else { + "surefire-dir" + }; + let report = root + .join(module) + .join(format!("build/{directory}/TEST-{class_name}.xml")); + fs::write( + &report, + format!(""), + ) + .unwrap(); + set_modified(&report, 1_700_000_010); + } + + // The official pair contributes both custom directories, so both reports + // are read; the same-named plugins under the custom group contribute none. + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-report-coordinates", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "", + "reports": { + "module": "official-report", + "classes": ["demo.OfficialSurefireTest", "demo.OfficialFailsafeTest"], + "notBeforeMillis": 1_700_000_000_000_u64 + } + } + }) + .to_string(), + )) + .expect("report response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + let cases = response["data"]["testCases"].as_array().unwrap(); + assert_eq!(cases.len(), 2, "{response}"); + for class_name in ["demo.OfficialSurefireTest", "demo.OfficialFailsafeTest"] { + assert!( + cases.iter().any(|value| value["className"] == class_name), + "{class_name} missing: {response}" + ); + } + + let negative: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "maven-test-report-coordinates", + "command": "maven.testResults", + "payload": { + "root": root, + "output": "", + "reports": { + "module": "custom-report", + "classes": ["demo.CustomSurefireTest", "demo.CustomFailsafeTest"], + "notBeforeMillis": 1_700_000_000_000_u64 + } + } + }) + .to_string(), + )) + .expect("report response should be JSON"); + assert_eq!(negative["ok"], true, "{negative}"); + assert_eq!( + negative["data"]["testCases"], + serde_json::json!([]), + "{negative}" + ); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs new file mode 100644 index 000000000..0053c2b3d --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs @@ -0,0 +1,9 @@ +//! Command-level tests, moved or copied from Core's `tests/` together with the +//! commands they exercise. Each test sends a request through [`crate::try_execute`] +//! and checks the JSON, so they pin the contract the Windows frontend relies on. + +mod java; +mod maven; +mod mybatis; +mod spring; +mod support; diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs new file mode 100644 index 000000000..115986415 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs @@ -0,0 +1,501 @@ +//! `mybatis.index`. Copied from Core's `tests/mybatis.rs`; Core keeps its own +//! copy until the macOS migration lands. + +use super::support::{execute_json, temporary_root}; +use serde_json::Value; +use std::fs; + +#[test] +fn mybatis_index_links_mapper_methods_to_xml_statements() { + let root = temporary_root("mybatis-index"); + let java = root.join("src/main/java/com/example/mapper"); + let resources = root.join("src/main/resources/mapper"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(&resources).expect("XML fixture directory should be creatable"); + fs::write( + java.join("UserMapper.java"), + r#"package com.example.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UserMapper { + User selectById(@Param("id") Long id); + + int insert(User user); + + @Select("SELECT * FROM users WHERE name = #{name}") + User selectByName(String name); + + default User findOrEmpty(Long id) { + User user = selectById(id); + return user == null ? new User() : user; + } + + void updateById( + @Param("id") Long id, + @Param("name") String name + ); +} +"#, + ) + .expect("mapper interface fixture should be writable"); + fs::write( + resources.join("UserMapper.xml"), + r#" + + + + + + INSERT INTO users(name) VALUES(#{name}) + + + UPDATE users SET name = #{name} WHERE id = #{id} + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &[ + "src/main/java/com/example/mapper/UserMapper.java", + "src/main/resources/mapper/UserMapper.xml", + ], + serde_json::json!({}), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let ids = statements + .iter() + .map(|value| value["statementId"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(ids, vec!["insert", "selectById", "updateById"]); + assert!( + statements + .iter() + .all(|value| value["namespace"] == "com.example.mapper.UserMapper"), + "{statements:?}" + ); + let select = statements + .iter() + .find(|value| value["statementId"] == "selectById") + .expect("selectById should be indexed"); + assert_eq!(select["kind"], "select"); + assert_eq!( + select["javaPath"], + "src/main/java/com/example/mapper/UserMapper.java" + ); + assert_eq!(select["javaLine"], 9); + assert_eq!(select["javaColumn"], 10); + assert_eq!(select["javaEndColumn"], 20); + assert_eq!(select["javaEndLine"], 9); + assert_eq!( + select["xmlPath"], + "src/main/resources/mapper/UserMapper.xml" + ); + assert_eq!(select["xmlLine"], 5); + assert_eq!(select["xmlColumn"], 17); + assert_eq!(select["xmlEndColumn"], 27); + let insert = statements + .iter() + .find(|value| value["statementId"] == "insert") + .expect("insert should be indexed"); + assert_eq!(insert["xmlLine"], 9); + let update = statements + .iter() + .find(|value| value["statementId"] == "updateById") + .expect("updateById should be indexed"); + assert_eq!(update["javaLine"], 21); + assert_eq!(update["javaEndColumn"], 20); + assert_eq!(update["javaEndLine"], 24); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Unsaved XML edits must win over disk so a mapper id rename can navigate +/// before the buffer is written. +#[test] +fn mybatis_index_uses_text_overrides_before_disk() { + let root = temporary_root("mybatis-overrides"); + let java = root.join("src/main/java"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("OrderMapper.java"), + "package demo;\npublic interface OrderMapper {\n Order find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("OrderMapper.xml"), + r#""#, + ) + .expect("stale XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["src/main/java/OrderMapper.java", "OrderMapper.xml"], + serde_json::json!({ + "textOverrides": { + "OrderMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["statementId"], "find"); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_pairs_nested_generic_and_split_signatures() { + let root = temporary_root("mybatis-nested-generic"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + r#"package demo; +public interface UserMapper { + List> find(); + + List> + splitFind(); +} +"#, + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#" + + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["UserMapper.java", "UserMapper.xml"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let find = statements + .iter() + .find(|value| value["statementId"] == "find") + .expect("nested generic find should be indexed"); + assert_eq!(find["javaLine"], 3); + assert_eq!(find["javaColumn"], 31); + assert_eq!(find["javaEndColumn"], 35); + let split = statements + .iter() + .find(|value| value["statementId"] == "splitFind") + .expect("split signature should be indexed"); + assert_eq!(split["javaLine"], 6); + assert_eq!(split["javaColumn"], 5); + assert_eq!(split["javaEndColumn"], 14); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Commented-out signatures must not steal the real method location, including +/// when the comment contains braces that a line scanner would treat as scope. +#[test] +fn mybatis_index_ignores_methods_inside_comments() { + let root = temporary_root("mybatis-commented-method"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + [ + "package demo;\npublic interface UserMapper {\n ", + "/", + "*\n User find();\n *", + "/\n User find();\n\n // {\n User listed();\n}\n", + ] + .concat(), + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#" + + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["UserMapper.java", "UserMapper.xml"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let find = statements + .iter() + .find(|value| value["statementId"] == "find") + .expect("real find should be indexed"); + assert_eq!(find["javaLine"], 6); + let listed = statements + .iter() + .find(|value| value["statementId"] == "listed") + .expect("method after a brace comment should be indexed"); + assert_eq!(listed["javaLine"], 9); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_skips_unrelated_and_oversized_files_before_reading() { + let _ = crate::mybatis::take_mybatis_disk_reads(); + let root = temporary_root("mybatis-skip-reads"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + "package demo;\npublic interface UserMapper {\n User find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#""#, + ) + .expect("mapper XML fixture should be writable"); + fs::write(root.join("dump.sql"), "SELECT 1;\n".repeat(8 * 1024)) + .expect("unrelated SQL dump should be writable"); + fs::write(root.join("pom.xml"), "").expect("pom should be writable"); + let oversized = vec![b'a'; 2 * 1024 * 1024 + 1]; + fs::write(root.join("Huge.java"), oversized).expect("oversized Java file should be writable"); + + let response = execute_mybatis( + &root, + &[ + "dump.sql", + "pom.xml", + "Huge.java", + "UserMapper.java", + "UserMapper.xml", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["statementId"], "find"); + + let mut reads = crate::mybatis::take_mybatis_disk_reads(); + reads.sort(); + assert_eq!( + reads, + vec!["UserMapper.java".to_string(), "UserMapper.xml".to_string()], + "{reads:?}" + ); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Only Java files a mapper namespace names are read and parsed: services and +/// controllers stay untouched however many the workspace has. +#[test] +fn mybatis_index_reads_only_java_files_named_by_a_mapper_namespace() { + let _ = crate::mybatis::take_mybatis_disk_reads(); + let root = temporary_root("mybatis-namespace-reads"); + fs::create_dir_all(root.join("demo")).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("demo/UserMapper.java"), + "package demo;\npublic interface UserMapper {\n User find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("demo/Outer.java"), + "package demo;\npublic class Outer {\n public interface Inner {\n User nested();\n }\n}\n", + ) + .expect("nested mapper fixture should be writable"); + fs::write( + root.join("demo/UserService.java"), + "package demo;\npublic interface UserService {\n User find();\n}\n", + ) + .expect("unrelated service fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#""#, + ) + .expect("mapper XML fixture should be writable"); + fs::write( + root.join("Inner.xml"), + r#""#, + ) + .expect("nested mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &[ + "demo/UserService.java", + "demo/UserMapper.java", + "demo/Outer.java", + "UserMapper.xml", + "Inner.xml", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let java_paths = statements + .iter() + .map(|statement| statement["javaPath"].as_str().unwrap()) + .collect::>(); + assert_eq!(java_paths, vec!["demo/Outer.java", "demo/UserMapper.java"]); + + let mut reads = crate::mybatis::take_mybatis_disk_reads(); + reads.sort(); + assert_eq!( + reads, + vec![ + "Inner.xml".to_string(), + "UserMapper.xml".to_string(), + "demo/Outer.java".to_string(), + "demo/UserMapper.java".to_string(), + ], + "{reads:?}" + ); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_rejects_a_relative_root() { + let response = execute_json( + r#"{"id":"mybatis","command":"mybatis.index","payload":{"root":"relative","paths":[]}}"#, + ); + let parsed: Value = serde_json::from_str(&response).expect("response should be JSON"); + assert_eq!(parsed["ok"], false, "{parsed}"); + assert_eq!(parsed["error"]["code"], "invalid_request"); +} + +#[test] +fn mybatis_index_handles_deep_expressions_in_unrelated_java() { + // A small Java file can produce thousands of left-associative AST nodes, + // even when the mapper itself is shallow and the source is below 2 MiB. + let deep_java = format!( + "package demo; class Calculation {{ int value() {{ return 1{}; }} }}", + " + 1".repeat(8192) + ); + let response = execute_mybatis( + &std::env::temp_dir(), + &["Calculation.java", "UserMapper.java", "UserMapper.xml"], + serde_json::json!({ + "textOverrides": { + "Calculation.java": deep_java, + "UserMapper.java": "package demo;\npublic interface UserMapper {\n User find();\n}\n", + "UserMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.UserMapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "UserMapper.java"); + assert_eq!(statements[0]["javaLine"], 3); + assert_eq!(statements[0]["javaColumn"], 10); + assert_eq!(statements[0]["javaEndColumn"], 14); +} + +#[test] +fn mybatis_index_finds_mapper_below_deeply_nested_blocks() { + // The mapped declaration is below the deep subtree, so truncating traversal + // or skipping the Java file would lose a statement and fail this regression. + let deep_java = format!( + "package demo;\nclass Container {{ void declarations() {{ {}\nabstract class DeepMapper {{\n abstract java.util.List find();\n}}\n{} }} }}", + "{".repeat(4096), + "}".repeat(4096) + ); + let response = execute_mybatis( + &std::env::temp_dir(), + &["Container.java", "DeepMapper.xml"], + serde_json::json!({ + "textOverrides": { + "Container.java": deep_java, + "DeepMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.Container.DeepMapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "Container.java"); + assert_eq!(statements[0]["javaLine"], 4); + assert_eq!(statements[0]["javaColumn"], 37); + assert_eq!(statements[0]["javaEndColumn"], 41); + assert_eq!(statements[0]["javaEndLine"], 4); +} + +#[test] +fn mybatis_index_preserves_source_order_for_nested_types_with_same_name() { + // Local classes in separate method scopes share the index's qualified name. + // Pairing must keep choosing the first declaration in source order. + let response = execute_mybatis( + &std::env::temp_dir(), + &["Container.java", "Mapper.xml"], + serde_json::json!({ + "textOverrides": { + "Container.java": "package demo;\nclass Container {\n void first() {\n abstract class Mapper {\n abstract java.util.List find();\n }\n }\n void second() {\n abstract class Mapper {\n abstract java.util.List find();\n }\n }\n}\n", + "Mapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.Container.Mapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "Container.java"); + assert_eq!(statements[0]["javaLine"], 5); + assert_eq!(statements[0]["javaColumn"], 40); + assert_eq!(statements[0]["javaEndColumn"], 44); + assert_eq!(statements[0]["javaEndLine"], 5); +} + +fn execute_mybatis(root: &std::path::Path, paths: &[&str], extra: Value) -> Value { + let mut payload = serde_json::json!({"root": root, "paths": paths}); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().cloned().unwrap_or_default()); + let request = serde_json::json!({ + "id": "mybatis", + "command": "mybatis.index", + "payload": payload + }); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("MyBatis response should be JSON") +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs new file mode 100644 index 000000000..23a87e84d --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs @@ -0,0 +1,605 @@ +//! `spring.index`. Copied from Core's `tests/spring.rs`; Core keeps its own copy +//! until the macOS migration lands. + +use super::support::{execute_json, temporary_root}; +use serde_json::Value; +use std::fs::{self, File}; +use std::io::Write; + +#[test] +fn spring_index_links_configuration_profiles_beans_and_endpoints() { + let root = temporary_root("spring-index"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(resources.join("META-INF")) + .expect("resource fixture directory should be creatable"); + fs::write( + java.join("DemoProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "demo") +public class DemoProperties { + private boolean enabled; + private int retryCount; + private Security security; + public static class Security { + private java.time.Duration timeout; + } +} +"#, + ) + .expect("configuration properties fixture should be writable"); + fs::write( + java.join("RecordProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "recorded") +public record RecordProperties(boolean enabled, int retryCount) {} +"#, + ) + .expect("record configuration properties fixture should be writable"); + fs::write( + java.join("GreetingService.java"), + "package demo;\n@Service\npublic class GreetingService {}\n", + ) + .expect("service fixture should be writable"); + fs::write( + java.join("GreetingController.java"), + r#"package demo; +@RestController +@RequestMapping("/api") +public class GreetingController { + @Autowired + private GreetingService service; + @GetMapping("/greet") + public String greet() { return "hi"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + resources.join("application.yml"), + "demo:\n enabled: true\n retry-count: 3\n", + ) + .expect("base configuration fixture should be writable"); + fs::write( + resources.join("application-dev.yml"), + "demo:\n retry-count: nope\n", + ) + .expect("profile configuration fixture should be writable"); + fs::write( + resources.join("META-INF/spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.title","type":"java.lang.String","description":"Display title."}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/DemoProperties.java", + "src/main/java/demo/RecordProperties.java", + "src/main/java/demo/GreetingService.java", + "src/main/java/demo/GreetingController.java", + "src/main/resources/application.yml", + "src/main/resources/application-dev.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + + assert_eq!(response["ok"], true, "{response}"); + let properties = response["data"]["properties"].as_array().unwrap(); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.retry-count")); + assert!(properties.iter().any(|value| value["name"] == "demo.title")); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.security.timeout")); + assert!(properties + .iter() + .any(|value| value["name"] == "recorded.retry-count")); + let profile_value = response["data"]["values"] + .as_array() + .unwrap() + .iter() + .find(|value| value["path"].as_str().unwrap().contains("application-dev")) + .unwrap(); + assert_eq!(profile_value["profile"], "dev"); + assert_eq!(profile_value["overridesBaseValue"], true); + assert!(profile_value["targetPath"] + .as_str() + .unwrap() + .ends_with("DemoProperties.java")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["severity"] == "error")); + assert!(response["data"]["beans"] + .as_array() + .unwrap() + .iter() + .any(|value| value["typeName"] == "GreetingService")); + assert!( + response["data"]["injections"][0]["beanIds"] + .as_array() + .unwrap() + .len() + == 1, + "{response}" + ); + assert_eq!(response["data"]["endpoints"][0]["route"], "/api/greet"); + assert_eq!(response["data"]["endpoints"][0]["httpMethods"][0], "GET"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_resolves_qualifiers_primary_interfaces_and_constructors() { + let root = temporary_root("spring-injection"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("PaymentService.java"), + "package demo;\npublic interface PaymentService {}\n", + ) + .expect("interface fixture should be writable"); + fs::write( + java.join("StripePaymentService.java"), + r#"package demo; +@Service("stripe") +public class StripePaymentService implements PaymentService {} +"#, + ) + .expect("qualified service fixture should be writable"); + fs::write( + java.join("PaypalPaymentService.java"), + r#"package demo; +@Service +@Primary +public class PaypalPaymentService implements PaymentService {} +"#, + ) + .expect("primary service fixture should be writable"); + fs::write( + java.join("CheckoutController.java"), + r#"package demo; +@RestController +public class CheckoutController { + private final PaymentService paymentService; + public CheckoutController(@Qualifier("stripe") PaymentService paymentService) { + this.paymentService = paymentService; + } +} +"#, + ) + .expect("qualified constructor fixture should be writable"); + fs::write( + java.join("ReportController.java"), + r#"package demo; +@Controller +public class ReportController { + public ReportController(PaymentService paymentService) {} +} +"#, + ) + .expect("primary constructor fixture should be writable"); + + let paths = [ + "src/main/java/demo/PaymentService.java", + "src/main/java/demo/StripePaymentService.java", + "src/main/java/demo/PaypalPaymentService.java", + "src/main/java/demo/CheckoutController.java", + "src/main/java/demo/ReportController.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + let injections = response["data"]["injections"].as_array().unwrap(); + let qualified = injections + .iter() + .find(|value| { + value["path"] + .as_str() + .unwrap() + .contains("CheckoutController") + }) + .unwrap_or_else(|| panic!("missing qualified injection: {response}")); + assert_eq!(qualified["qualifier"], "stripe"); + assert_eq!(qualified["beanIds"].as_array().unwrap().len(), 1); + assert!(qualified["beanIds"][0].as_str().unwrap().contains("stripe")); + let primary = injections + .iter() + .find(|value| value["path"].as_str().unwrap().contains("ReportController")) + .unwrap(); + assert_eq!(primary["beanIds"].as_array().unwrap().len(), 1); + assert!(primary["beanIds"][0] + .as_str() + .unwrap() + .contains("paypalPaymentService")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_links_value_references_profiles_and_mapping_variants() { + let root = temporary_root("spring-web-config"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources/META-INF"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(&resources).expect("resource fixture directory should be creatable"); + fs::write( + java.join("ApiController.java"), + r#"package demo; +@RestController +@RequestMapping(path = {"/api", "/v2"}) +public class ApiController { + @Value("${demo.retryCount:3}") + private int retryCount; + @GetMapping(path = {"/a", "/b"}) + public String get() { return "ok"; } + @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST}) + public String multi() { return "ok"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + root.join("src/main/resources/application.yml"), + "demo:\n retryCount: 2\n---\ndemo:\n retry-count:\n - 3\nspring:\n config:\n activate:\n on-profile: dev\n", + ) + .expect("multi-document YAML fixture should be writable"); + fs::write( + resources.join("spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.retry-count","type":"java.lang.Integer"}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/ApiController.java", + "src/main/resources/application.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["propertyReferences"][0]["key"], + "demo.retry-count" + ); + let values = response["data"]["values"].as_array().unwrap(); + assert!(values + .iter() + .any(|value| { value["key"] == "demo.retry-count" && value["profile"] == "dev" })); + assert!(!response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["message"].as_str().unwrap().contains("Unknown"))); + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!( + endpoints + .iter() + .filter(|value| value["route"].as_str().unwrap().ends_with("/a")) + .count(), + 2 + ); + assert!(endpoints.iter().any(|value| { + value["route"] == "/api/multi" && value["httpMethods"] == serde_json::json!(["GET", "POST"]) + })); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Annotation detection matches `@Name` only when the name ends at whitespace, +/// an argument list, or the end of the context. Caching the compiled patterns +/// must not turn a prefix such as `@Bean` into a match for `@BeanFactory`. +#[test] +fn spring_index_does_not_treat_longer_annotations_as_recognized_ones() { + let root = temporary_root("spring-annotation-boundary"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("RealConfig.java"), + r#"package demo; +@Configuration +public class RealConfig { + @Bean + public Clock clock() { return null; } + @BeanFactory + public Clock decoyClock() { return null; } +} +"#, + ) + .expect("configuration fixture should be writable"); + fs::write( + java.join("DecoyService.java"), + "package demo;\n@ServiceLocator\npublic class DecoyService {}\n", + ) + .expect("decoy fixture should be writable"); + fs::write( + java.join("DecoyController.java"), + "package demo;\n@RestControllerAdvice\npublic class DecoyController {\n @GetMapping(\"/decoy\")\n public String decoy() { return \"\"; }\n}\n", + ) + .expect("decoy controller fixture should be writable"); + + let paths = [ + "src/main/java/demo/RealConfig.java", + "src/main/java/demo/DecoyService.java", + "src/main/java/demo/DecoyController.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"clock"), "{response}"); + assert!(names.contains(&"realConfig"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"decoyService"), "{response}"); + + // @RestControllerAdvice is not @RestController, so no route is collected. + assert!( + response["data"]["endpoints"].as_array().unwrap().is_empty(), + "{response}" + ); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Bean and component names must come from the exact annotation's own +/// argument list. A prefix decoy or a later neighbor cannot supply the name. +#[test] +fn spring_index_isolates_bean_and_component_names_from_neighbor_annotations() { + let root = temporary_root("spring-bean-name-isolation"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("ClockConfig.java"), + r#"package demo; +@Configuration +public class ClockConfig { + @BeanFactory("decoy") @Bean("real") + public Clock clock() { return null; } + @Bean("foo(") @Bean("bar") + public Clock parenClock() { return null; } + @BeanFactory("decoyOnly") + public Clock decoyClock() { return null; } + @Bean + public Clock unnamedClock() { return null; } +} +"#, + ) + .expect("bean name fixture should be writable"); + fs::write( + java.join("Demo.java"), + "package demo;\n@Service(\"s\") @Component(\"c\")\npublic class Demo {}\n", + ) + .expect("component name fixture should be writable"); + fs::write( + java.join("Ordered.java"), + "package demo;\n@Component(\"c\") @Service(\"s\")\npublic class Ordered {}\n", + ) + .expect("reversed component fixture should be writable"); + + let paths = [ + "src/main/java/demo/ClockConfig.java", + "src/main/java/demo/Demo.java", + "src/main/java/demo/Ordered.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"real"), "{response}"); + assert!(names.contains(&"foo("), "{response}"); + assert!(names.contains(&"unnamedClock"), "{response}"); + assert!(names.contains(&"clockConfig"), "{response}"); + assert!(names.contains(&"s"), "{response}"); + assert!(names.contains(&"c"), "{response}"); + assert!(!names.contains(&"decoy"), "{response}"); + assert!(!names.contains(&"decoyOnly"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"bar"), "{response}"); + assert!(!names.contains(&") @Component("), "{response}"); + + let demo = beans + .iter() + .find(|value| value["typeName"] == "Demo") + .unwrap_or_else(|| panic!("missing Demo bean: {response}")); + assert_eq!(demo["name"], "s"); + let ordered = beans + .iter() + .find(|value| value["typeName"] == "Ordered") + .unwrap_or_else(|| panic!("missing Ordered bean: {response}")); + assert_eq!(ordered["name"], "c"); + let clock = beans + .iter() + .find(|value| value["name"] == "real" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing named Clock bean: {response}")); + assert_eq!(clock["typeName"], "Clock"); + let paren = beans + .iter() + .find(|value| value["name"] == "foo(" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing parenthesis Clock bean: {response}")); + assert_eq!(paren["typeName"], "Clock"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// An empty `@Component("")` value is not a bean name. Indexing must fall back +/// to the default type name instead of recording an empty id. +#[test] +fn spring_index_falls_back_when_a_component_name_is_an_empty_string() { + let root = temporary_root("spring-empty-component-name"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("EmptyName.java"), + "package demo;\n@Component(\"\")\npublic class EmptyName {}\n", + ) + .expect("empty component fixture should be writable"); + fs::write( + java.join("Neighbor.java"), + "package demo;\n@Component(\"\") @Service(\"s\")\npublic class Neighbor {}\n", + ) + .expect("empty-then-neighbor fixture should be writable"); + + let response = execute_spring( + &root, + &[ + "src/main/java/demo/EmptyName.java", + "src/main/java/demo/Neighbor.java", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let empty_name = beans + .iter() + .find(|value| value["typeName"] == "EmptyName") + .unwrap_or_else(|| panic!("missing EmptyName bean: {response}")); + assert_eq!(empty_name["name"], "emptyName"); + assert!( + empty_name["id"].as_str().unwrap().ends_with(":emptyName"), + "{response}" + ); + let neighbor = beans + .iter() + .find(|value| value["typeName"] == "Neighbor") + .unwrap_or_else(|| panic!("missing Neighbor bean: {response}")); + assert_eq!(neighbor["name"], "neighbor"); + assert_ne!(empty_name["name"], ""); + assert_ne!(neighbor["name"], ""); + assert_ne!(neighbor["name"], "s"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Custom annotations that only share a Mapping prefix must not become +/// endpoints or class-level base routes; exact Spring Mapping names still do. +#[test] +fn spring_index_ignores_mapping_annotations_that_only_share_a_prefix() { + let root = temporary_root("spring-mapping-prefix"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("DemoController.java"), + r#"package demo; +@RestController +@RequestMappingFoo("/base") +public class DemoController { + @GetMappingCustom("/x") + public String custom() { return ""; } + @PostMappingCustom("/post") + public String postCustom() { return ""; } + @PutMappingCustom("/put") + public String putCustom() { return ""; } + @DeleteMappingCustom("/delete") + public String deleteCustom() { return ""; } + @PatchMappingCustom("/patch") + public String patchCustom() { return ""; } + @GetMapping("/real") + public String real() { return ""; } +} +"#, + ) + .expect("controller fixture should be writable"); + + let response = execute_spring( + &root, + &["src/main/java/demo/DemoController.java"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!(endpoints.len(), 1, "{response}"); + assert_eq!(endpoints[0]["route"], "/real"); + assert_eq!(endpoints[0]["httpMethods"][0], "GET"); + assert_eq!(endpoints[0]["method"], "real"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_dependency_metadata_cache_refresh_is_explicit() { + let root = temporary_root("spring-metadata-cache"); + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("metadata repository should be creatable"); + let archive = repository.join("fixture.jar"); + write_metadata_archive(&archive, "cache.first"); + + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.first")); + write_metadata_archive(&archive, "cache.second"); + let cached = execute_spring( + &root, + &[], + serde_json::json!({"metadataRepository": repository}), + ); + assert!(has_property(&cached, "cache.first")); + assert!(!has_property(&cached, "cache.second")); + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.second")); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +fn execute_spring(root: &std::path::Path, paths: &[&str], extra: Value) -> Value { + let mut payload = serde_json::json!({"root": root, "paths": paths}); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().cloned().unwrap_or_default()); + let request = serde_json::json!({ + "id": "spring", + "command": "spring.index", + "payload": payload + }); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("Spring response should be JSON") +} + +fn write_metadata_archive(path: &std::path::Path, property: &str) { + let file = File::create(path).expect("metadata archive should be creatable"); + let mut archive = zip::ZipWriter::new(file); + archive + .start_file( + "META-INF/spring-configuration-metadata.json", + zip::write::SimpleFileOptions::default(), + ) + .expect("metadata entry should be creatable"); + write!(archive, r#"{{"properties":[{{"name":"{property}"}}]}}"#) + .expect("metadata should be writable"); + archive.finish().expect("metadata archive should close"); +} + +fn has_property(response: &Value, name: &str) -> bool { + response["data"]["properties"] + .as_array() + .unwrap() + .iter() + .any(|value| value["name"] == name) +} diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/support.rs b/windows/tauri/crates/lithe-legacy-java/src/tests/support.rs new file mode 100644 index 000000000..1be43d5c4 --- /dev/null +++ b/windows/tauri/crates/lithe-legacy-java/src/tests/support.rs @@ -0,0 +1,37 @@ +//! Helpers the command tests share: sending a request and owning scratch paths. + +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + OnceLock, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Sends `request` through the same entry point the Windows host uses. +pub(super) fn execute_json(request: &str) -> String { + crate::try_execute(request).expect("the request should name a legacy Java command") +} + +pub(crate) fn temporary_root(label: &str) -> PathBuf { + static NONCE: OnceLock = OnceLock::new(); + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + // A clock tick is not a unique ID: parallel fixtures must never share cleanup ownership. + let nonce = NONCE.get_or_init(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos() + }); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "lithe-legacy-java-{label}-{}-{nonce}-{sequence}", + std::process::id() + )) +} + +#[test] +fn temporary_roots_do_not_alias_with_the_same_label_and_process_timestamp() { + let roots: std::collections::HashSet<_> = + (0..10_000).map(|_| temporary_root("same-label")).collect(); + assert_eq!(roots.len(), 10_000, "Each fixture must own a distinct path"); +} From 599db648129ffacef03427b31edd96211b86acda Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:56:08 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(core):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BB=85=20Windows=20=E4=BD=BF=E7=94=A8=E7=9A=84=20Maven=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E7=B1=BB=E8=B7=AF=E5=BE=84=20jar=EF=BC=8C=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E8=BF=87=E6=B8=A1=20crate=20=E9=9C=80=E8=A6=81=E7=9A=84?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1495:这些能力已搬入 windows/tauri/crates/lithe-legacy-java,macOS 从未调用,Core 不再保留。 删除(已用 grep 确认 macOS 和 Core 其他模块没有引用): - maven.launchPlan、maven.dependencyPlan、maven.diagnostics 三个命令,以及 maven.scan、maven.dependencies、 maven.testResults 在 dispatcher 和 CoreCommand 中的分支;Core 内部仍使用的 scan、dependencies、 test_results 函数保留,供构建提供者调用 - 仅服务这些命令的 launch_plan、dependency_plan、diagnostics 实现与诊断响应类型 - execution::classpath_jar(超长类路径 jar 规划) - execution::workspace_scan 中的 list_run_java_sources 及其测试 - lib.rs 里的 jdt_workspace_key 包装;project 对 settings_with_local_repository 的公开导出改为 crate 内可见 (JDT Maven settings 仍在 Core 内使用) 保留并开放: - lithe_core::check_cancellation 与 CancellationScope,供 crate 的 try_execute 复用 Core 的取消语义 - execution 下版本比较与工具链需求相关的少量函数改为 pub,并从 execution 重新导出 - is_run_scan_excluded_directory 对外重新导出;launch_command 的内部常量改回私有 tests/languages.rs 中仍覆盖 scan、dependencies、test_results 的测试保留,经测试专用的 execute_json 垫片 直接调用 project 中的函数;已删除命令对应的测试随实现迁到 crate。 (cherry picked from commit 70c2fe0869a46f9b97b337076e12244d0c52dd9c) --- .../lithe-core/src/execution/classpath_jar.rs | 619 ------------------ .../lithe-core/src/execution/configuration.rs | 12 +- .../src/execution/launch_command.rs | 8 +- rust/lithe-core/src/execution/mod.rs | 12 +- .../src/execution/workspace_scan.rs | 93 --- rust/lithe-core/src/lib.rs | 20 +- rust/lithe-core/src/lsp/languages/mod.rs | 2 +- rust/lithe-core/src/project/maven.rs | 169 +---- .../src/project/maven_dependency_tree.rs | 2 +- .../src/project/maven_test_reports.rs | 2 +- rust/lithe-core/src/project/mod.rs | 1 - rust/lithe-core/src/protocol/command.rs | 26 - rust/lithe-core/src/protocol/contracts.rs | 18 - rust/lithe-core/src/runtime/dispatcher.rs | 110 +--- rust/lithe-core/src/tests/languages.rs | 295 ++------- .../lithe-core/src/tests/run_configuration.rs | 5 +- 16 files changed, 99 insertions(+), 1295 deletions(-) delete mode 100644 rust/lithe-core/src/execution/classpath_jar.rs diff --git a/rust/lithe-core/src/execution/classpath_jar.rs b/rust/lithe-core/src/execution/classpath_jar.rs deleted file mode 100644 index fafacf933..000000000 --- a/rust/lithe-core/src/execution/classpath_jar.rs +++ /dev/null @@ -1,619 +0,0 @@ -//! Shortens an oversized Java 8 class path through a manifest-only JAR. -//! -//! JDK 9 added `java @file` argument files, so [`super::plan_launch_command`] -//! cannot help a JDK 8 launch. The launcher has always accepted a JAR whose -//! `META-INF/MANIFEST.MF` lists further class-path entries in its `Class-Path` -//! attribute. Passing that one JAR with `-cp` keeps the command line short -//! while the application class loader still sees every original entry in order. -//! -//! Core converts each entry to an absolute `file:` URL and renders the manifest -//! text, including the 72-byte line rule. The host owns the filesystem: it -//! answers whether an entry is a directory, writes the JAR at the path it -//! supplied, and deletes it after that execution exits. Every URL is -//! percent-encoded UTF-8, so the manifest is pure ASCII and, unlike an argument -//! file, does not depend on the Windows system code page. - -use super::launch_command::{command_line_length, COMMAND_LINE_MARGIN, WINDOWS_COMMAND_LINE_LIMIT}; -use std::path::Path; - -/// First JDK release whose launcher reads argument files; from this version on -/// the argument-file planner owns shortening. -const FIRST_ARGFILE_JAVA_VERSION: u32 = 9; - -/// Launcher options that a JDK 8 `java` accepts for the class path. The long -/// `--class-path` spelling only exists from JDK 9 on. -const CLASSPATH_OPTIONS: &[&str] = &["-cp", "-classpath"]; - -/// Longest manifest line in bytes, excluding the line break. Longer headers -/// continue on lines that start with one space (JAR File Specification). -const MANIFEST_LINE_LIMIT: usize = 72; - -/// Path syntax of the host that will start the JVM. It decides the class-path -/// separator and how absolute paths are recognized and turned into URLs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClasspathStyle { - /// `;`-separated entries with drive-letter or UNC absolute paths. - Windows, - /// `:`-separated entries with `/`-rooted absolute paths. - Posix, -} - -/// Inputs for [`plan_classpath_jar_launch`]. -#[derive(Debug, Clone, Copy)] -pub struct ClasspathJarRequest<'a> { - /// Java executable path used for launcher detection. - pub executable: &'a str, - /// Complete argument vector before shortening. - pub arguments: &'a [String], - /// Directory the JVM starts in. Relative class-path entries resolve against - /// it on the command line, but against the JAR inside a manifest, so Core - /// makes them absolute first. - pub working_directory: &'a str, - /// Host-owned path that may be referenced by a classpath-JAR plan. - pub classpath_jar_path: &'a Path, - /// Optional host-specific command-line cap; `None` uses the Windows cap. - pub limit: Option, - /// JDK feature version read by the host from its `release` file. - pub java_feature_version: Option, - /// Path syntax used by the class-path value. - pub style: ClasspathStyle, -} - -/// How a host should start a JDK 8 launch whose class path may be too long. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClasspathJarPlan { - /// Spawn with the original arguments. - Direct, - /// Write a JAR containing only `META-INF/MANIFEST.MF` with `manifest` at the - /// supplied path, then spawn with `arguments`. - ClasspathJar { - /// Arguments whose effective class-path value is the JAR path. - arguments: Vec, - /// Complete ASCII manifest text with CRLF line breaks. - manifest: String, - }, -} - -/// Plans a manifest-JAR launch for a known JDK older than 9. -/// -/// `is_directory` receives each absolute entry and reports whether it names a -/// directory: a manifest URL must end in `/` for the class loader to treat it as -/// a directory rather than an archive. Returns [`ClasspathJarPlan::Direct`] when -/// the JDK is unknown or supports argument files, the command fits, the launch -/// uses `-jar` (which ignores `-cp`), or an entry cannot be expressed as an -/// absolute URL without changing its meaning, such as a `lib/*` wildcard. -pub fn plan_classpath_jar_launch( - request: ClasspathJarRequest<'_>, - is_directory: impl Fn(&str) -> bool, -) -> ClasspathJarPlan { - let executable_name = request - .executable - .rsplit(['/', '\\']) - .next() - .unwrap_or(request.executable); - if !["java", "java.exe", "javaw", "javaw.exe"] - .iter() - .any(|name| executable_name.eq_ignore_ascii_case(name)) - || !request - .java_feature_version - .is_some_and(|version| version < FIRST_ARGFILE_JAVA_VERSION) - { - return ClasspathJarPlan::Direct; - } - let budget = request - .limit - .unwrap_or(WINDOWS_COMMAND_LINE_LIMIT) - .saturating_sub(COMMAND_LINE_MARGIN); - if command_line_length(request.executable, request.arguments) <= budget { - return ClasspathJarPlan::Direct; - } - let Some(value_index) = effective_classpath_value_index(request.arguments) else { - return ClasspathJarPlan::Direct; - }; - let Some(urls) = classpath_urls( - &request.arguments[value_index], - request.working_directory, - request.style, - &is_directory, - ) else { - return ClasspathJarPlan::Direct; - }; - let mut arguments = request.arguments.to_vec(); - arguments[value_index] = request.classpath_jar_path.display().to_string(); - let mut manifest = String::from("Manifest-Version: 1.0\r\n"); - manifest.push_str(&manifest_header("Class-Path", &urls.join(" "))); - // A header is only recognized once its line is terminated, and the blank - // line closes the main section. - manifest.push_str("\r\n"); - ClasspathJarPlan::ClasspathJar { - arguments, - manifest, - } -} - -/// Finds the class-path value the JDK 8 launcher will use. -/// -/// Launcher options end at the main class or `-jar`. When `-cp` appears more -/// than once the last occurrence wins, so only that value is replaced. `-jar` -/// ignores `-cp` entirely, which leaves nothing to shorten. -fn effective_classpath_value_index(arguments: &[String]) -> Option { - let mut effective = None; - let mut index = 0; - while index < arguments.len() { - let argument = arguments[index].as_str(); - if argument == "-jar" { - return None; - } - if !argument.starts_with('-') { - break; - } - if CLASSPATH_OPTIONS.contains(&argument) && index + 1 < arguments.len() { - effective = Some(index + 1); - index += 2; - } else { - index += 1; - } - } - effective -} - -/// Converts a joined class-path value into manifest URLs in launcher order. -fn classpath_urls( - value: &str, - working_directory: &str, - style: ClasspathStyle, - is_directory: &dyn Fn(&str) -> bool, -) -> Option> { - let separator = match style { - ClasspathStyle::Windows => ';', - ClasspathStyle::Posix => ':', - }; - value - .split(separator) - .map(|entry| { - // The launcher reads an empty element as the current directory. - let entry = if entry.is_empty() { "." } else { entry }; - let absolute = absolute_entry(entry, working_directory, style)?; - // Launcher wildcards are expanded only on the command line; the - // manifest class loader would look for a file literally named `*`. - if absolute.rsplit(['/', '\\']).next() == Some("*") { - return None; - } - Some(file_url(&absolute, style, is_directory(&absolute))) - }) - .collect() -} - -/// Makes one entry absolute, or returns `None` when its base is ambiguous. -fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) -> Option { - match style { - ClasspathStyle::Posix => { - if entry.starts_with('/') { - Some(entry.to_string()) - } else if working_directory.starts_with('/') { - Some(format!( - "{}/{entry}", - working_directory.trim_end_matches('/') - )) - } else { - None - } - } - ClasspathStyle::Windows => { - let entry = strip_verbatim_prefix(&entry.replace('/', "\\"))?; - let working_directory = strip_verbatim_prefix(&working_directory.replace('/', "\\"))?; - let working_directory = working_directory.as_str(); - let path = if windows_root(&entry).is_some() { - entry - } else if entry.starts_with('\\') { - // `\lib\a.jar` is rooted on the working directory's drive. - format!( - "{}{entry}", - windows_root(working_directory)?.trim_end_matches('\\') - ) - } else if entry.as_bytes().get(1) == Some(&b':') { - // `C:lib` depends on a per-drive current directory that the - // host never sees, so it cannot be resolved deterministically. - return None; - } else { - windows_root(working_directory)?; - format!( - "{}\\{entry}", - working_directory.trim_end_matches(['\\', '/']) - ) - }; - Some(normalize_windows_path(&path)) - } - } -} - -/// Rewrites `\\?\C:\x` to `C:\x` and `\\?\UNC\host\share` to `\\host\share`. -/// -/// Other `\\?\` and `\\.\` device paths have no `file:` URL form, so they -/// return `None` and keep the launch direct instead of becoming a bogus UNC -/// host named `?` or `.`. -fn strip_verbatim_prefix(entry: &str) -> Option { - if let Some(rest) = entry.strip_prefix("\\\\?\\UNC\\") { - return Some(format!("\\\\{rest}")); - } - if let Some(rest) = entry.strip_prefix("\\\\?\\") { - let bytes = rest.as_bytes(); - let drive = bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && bytes[2] == b'\\'; - return drive.then(|| rest.to_string()); - } - if entry.starts_with("\\\\.\\") { - return None; - } - Some(entry.to_string()) -} - -/// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`. -fn windows_root(path: &str) -> Option { - let path = path.replace('/', "\\"); - let bytes = path.as_bytes(); - if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { - return Some(path[..3].to_string()); - } - let unc = path.strip_prefix("\\\\")?; - let mut parts = unc.splitn(3, '\\'); - let host = parts.next().filter(|part| !part.is_empty())?; - let share = parts.next().filter(|part| !part.is_empty())?; - Some(format!("\\\\{host}\\{share}")) -} - -/// Removes `.` and `..` segments the way Windows does, which is lexical. -fn normalize_windows_path(path: &str) -> String { - let root = windows_root(path).expect("absolute Windows path"); - let mut segments: Vec<&str> = Vec::new(); - for segment in path[root.len()..].split('\\') { - match segment { - "" | "." => {} - ".." => { - segments.pop(); - } - _ => segments.push(segment), - } - } - let root = root.trim_end_matches('\\'); - if segments.is_empty() { - format!("{root}\\") - } else { - format!("{root}\\{}", segments.join("\\")) - } -} - -/// Renders an absolute path as a `file:` URL the JDK class loader can open. -/// -/// Drive paths become `file:/C:/...` and UNC paths `file:////host/share/...`, -/// matching `java.io.File.toURI`. Every byte outside a small unreserved set is -/// percent-encoded as UTF-8; the JDK decodes the URL before opening the file. -fn file_url(path: &str, style: ClasspathStyle, directory: bool) -> String { - let path = match style { - ClasspathStyle::Windows => { - let slashed = path.replace('\\', "/"); - if slashed.starts_with("//") { - format!("//{slashed}") - } else { - format!("/{slashed}") - } - } - ClasspathStyle::Posix => path.to_string(), - }; - let mut url = String::from("file:"); - for byte in path.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') { - url.push(byte as char); - } else { - url.push_str(&format!("%{byte:02X}")); - } - } - if directory && !url.ends_with('/') { - url.push('/'); - } - url -} - -/// Renders one manifest header, continuing lines after 72 bytes. -/// -/// The text is ASCII because every URL is percent-encoded, so splitting at a -/// byte offset can never cut a character in half. -fn manifest_header(name: &str, value: &str) -> String { - let line = format!("{name}: {value}"); - debug_assert!(line.is_ascii()); - let mut rendered = String::with_capacity(line.len() + line.len() / 35 + 2); - let mut rest = line.as_str(); - let mut width = MANIFEST_LINE_LIMIT; - loop { - let (head, tail) = rest.split_at(rest.len().min(width)); - rendered.push_str(head); - rendered.push_str("\r\n"); - if tail.is_empty() { - return rendered; - } - rendered.push(' '); - rest = tail; - width = MANIFEST_LINE_LIMIT - 1; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn jar_path() -> &'static Path { - Path::new("C:\\Temp\\lithe-run\\launch-1-0.classpath.jar") - } - - /// Mirrors a resolved Maven runtime classpath that exceeds the Windows cap. - fn classpath(entries: usize) -> String { - (0..entries) - .map(|index| { - format!( - "C:\\Users\\developer\\.m2\\repository\\org\\example\\artifact-{index}\\1.0.{index}\\artifact-{index}-1.0.{index}.jar" - ) - }) - .collect::>() - .join(";") - } - - fn request<'a>(arguments: &'a [String], version: Option) -> ClasspathJarRequest<'a> { - ClasspathJarRequest { - executable: "C:\\jdk8\\bin\\java.exe", - arguments, - working_directory: "C:\\work\\demo", - classpath_jar_path: jar_path(), - limit: None, - java_feature_version: version, - style: ClasspathStyle::Windows, - } - } - - /// Joins continuation lines back into logical headers. - fn class_path_urls(manifest: &str) -> Vec { - let logical = manifest.replace("\r\n ", ""); - logical - .split("\r\n") - .find_map(|line| line.strip_prefix("Class-Path: ")) - .expect("manifest has a Class-Path header") - .split(' ') - .map(str::to_string) - .collect() - } - - fn no_directories(_: &str) -> bool { - false - } - - /// Regression for #955: a large JDK 8 project failed in `CreateProcessW` - /// because argument files only exist from JDK 9 on. - #[test] - fn an_oversized_jdk8_classpath_moves_into_a_manifest_jar() { - let arguments = vec![ - "-Dfile.encoding=UTF-8".to_string(), - "-cp".to_string(), - classpath(500), - "com.example.Main".to_string(), - "--server.port=8080".to_string(), - ]; - let ClasspathJarPlan::ClasspathJar { - arguments: shortened, - manifest, - } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) - else { - panic!("an oversized JDK 8 classpath must be shortened"); - }; - assert_eq!( - shortened, - [ - "-Dfile.encoding=UTF-8", - "-cp", - "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar", - "com.example.Main", - "--server.port=8080", - ] - ); - assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/Users/")); - assert!(manifest.ends_with("\r\n\r\n")); - let urls = class_path_urls(&manifest); - assert_eq!(urls.len(), 500); - assert_eq!( - urls[0], - "file:/C:/Users/developer/.m2/repository/org/example/artifact-0/1.0.0/artifact-0-1.0.0.jar" - ); - } - - #[test] - fn manifest_lines_never_exceed_72_bytes() { - let arguments = vec!["-cp".into(), classpath(500), "Main".into()]; - let ClasspathJarPlan::ClasspathJar { manifest, .. } = - plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) - else { - panic!("classpath should move"); - }; - let body = manifest - .strip_suffix("\r\n\r\n") - .expect("terminated section"); - let lines: Vec<&str> = body.split("\r\n").collect(); - assert!(lines.len() > 100, "a long header must wrap"); - assert!(lines.iter().all(|line| line.len() <= MANIFEST_LINE_LIMIT)); - // Continuation lines start with the one marker space and fill the - // remaining 71 bytes, except the last one. The content after the - // marker may itself begin with the space that separates two URLs. - for line in &lines[2..lines.len() - 1] { - assert_eq!(line.len(), MANIFEST_LINE_LIMIT); - assert!(line.starts_with(' ')); - } - let urls = class_path_urls(&manifest); - assert_eq!(urls.len(), 500); - assert!(urls.iter().all(|url| url.starts_with("file:/C:/Users/"))); - // Every break is CRLF, the form the `jar` tool writes. - assert_eq!( - manifest.matches('\n').count(), - manifest.matches("\r\n").count() - ); - } - - #[test] - fn spaces_chinese_and_reserved_characters_are_percent_encoded() { - let entries = format!( - "C:\\Program Files\\示例 库\\a#b%c+d.jar;D:/mixed/slash.jar;{}", - classpath(500) - ); - let arguments = vec!["-cp".into(), entries, "Main".into()]; - let ClasspathJarPlan::ClasspathJar { manifest, .. } = - plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) - else { - panic!("classpath should move"); - }; - assert!( - manifest.is_ascii(), - "manifest must not depend on a code page" - ); - let urls = class_path_urls(&manifest); - assert_eq!( - urls[0], - "file:/C:/Program%20Files/%E7%A4%BA%E4%BE%8B%20%E5%BA%93/a%23b%25c%2Bd.jar" - ); - assert_eq!(urls[1], "file:/D:/mixed/slash.jar"); - } - - #[test] - fn directories_relative_unc_and_empty_entries_become_absolute_urls() { - let entries = format!( - "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;\\\\?\\C:\\long\\v.jar;\\\\?\\UNC\\server\\share\\w.jar;{}", - classpath(500) - ); - let arguments = vec!["-cp".into(), entries, "Main".into()]; - let directories = ["C:\\work\\demo\\target\\classes", "C:\\work\\demo"]; - let ClasspathJarPlan::ClasspathJar { manifest, .. } = - plan_classpath_jar_launch(request(&arguments, Some(8)), |path| { - directories.contains(&path) - }) - else { - panic!("classpath should move"); - }; - let urls = class_path_urls(&manifest); - assert_eq!(urls[0], "file:/C:/work/demo/target/classes/"); - assert_eq!(urls[1], "file:/C:/work/shared/lib.jar"); - assert_eq!(urls[2], "file:/C:/tools/x.jar"); - assert_eq!(urls[3], "file:////server/share/dep.jar"); - // An empty element means the working directory to the launcher. - assert_eq!(urls[4], "file:/C:/work/demo/"); - // Verbatim paths from canonicalized tooling keep their real target. - assert_eq!(urls[5], "file:/C:/long/v.jar"); - assert_eq!(urls[6], "file:////server/share/w.jar"); - } - - #[test] - fn jdk9_unknown_short_and_non_java_launches_stay_direct() { - let long = vec!["-cp".into(), classpath(500), "Main".into()]; - for version in [None, Some(9), Some(21)] { - assert_eq!( - plan_classpath_jar_launch(request(&long, version), no_directories), - ClasspathJarPlan::Direct - ); - } - let short = vec!["-cp".into(), classpath(3), "Main".into()]; - assert_eq!( - plan_classpath_jar_launch(request(&short, Some(8)), no_directories), - ClasspathJarPlan::Direct - ); - let relative = vec![ - "-cp".into(), - format!("classes;{}", classpath(500)), - "Main".into(), - ]; - let mut verbatim = request(&relative, Some(8)); - verbatim.working_directory = "\\\\?\\C:\\work\\demo"; - let ClasspathJarPlan::ClasspathJar { manifest, .. } = - plan_classpath_jar_launch(verbatim, no_directories) - else { - panic!("a verbatim working directory still resolves relative entries"); - }; - assert_eq!(class_path_urls(&manifest)[0], "file:/C:/work/demo/classes"); - let mut node = request(&long, Some(8)); - node.executable = "node.exe"; - assert_eq!( - plan_classpath_jar_launch(node, no_directories), - ClasspathJarPlan::Direct - ); - } - - #[test] - fn launches_whose_meaning_a_manifest_cannot_preserve_stay_direct() { - for (entries, target) in [ - (format!("lib\\*;{}", classpath(500)), "Main"), - (format!("C:lib\\a.jar;{}", classpath(500)), "Main"), - (format!("\\\\.\\pipe\\x;{}", classpath(500)), "Main"), - (classpath(500), "-jar"), - ] { - let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()]; - assert_eq!( - plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories), - ClasspathJarPlan::Direct, - "{target}" - ); - } - let without_classpath = vec!["Main".into(), classpath(500)]; - assert_eq!( - plan_classpath_jar_launch(request(&without_classpath, Some(8)), no_directories), - ClasspathJarPlan::Direct - ); - } - - #[test] - fn only_the_effective_classpath_before_the_main_class_is_replaced() { - let arguments = vec![ - "-cp".into(), - "ignored.jar".into(), - "-classpath".into(), - classpath(500), - "Main".into(), - "-cp".into(), - "program-value".into(), - ]; - let ClasspathJarPlan::ClasspathJar { - arguments: shortened, - manifest, - } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) - else { - panic!("classpath should move"); - }; - assert_eq!(&shortened[..3], &arguments[..3]); - assert_eq!( - shortened[3], - "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar" - ); - assert_eq!(&shortened[4..], &arguments[4..]); - assert!(!manifest.contains("program-value")); - assert!(!manifest.contains("ignored.jar")); - } - - #[test] - fn posix_paths_use_colon_separators() { - let entries = (0..800) - .map(|index| format!("/home/dev/.m2/repository/lib {index}/artifact-{index}.jar")) - .chain(["classes".to_string()]) - .collect::>() - .join(":"); - let arguments = vec!["-cp".into(), entries, "Main".into()]; - let mut posix = request(&arguments, Some(8)); - posix.executable = "/opt/jdk8/bin/java"; - posix.working_directory = "/work/demo"; - posix.style = ClasspathStyle::Posix; - let ClasspathJarPlan::ClasspathJar { manifest, .. } = - plan_classpath_jar_launch(posix, |path| path == "/work/demo/classes") - else { - panic!("classpath should move"); - }; - let urls = class_path_urls(&manifest); - assert_eq!( - urls[0], - "file:/home/dev/.m2/repository/lib%200/artifact-0.jar" - ); - assert_eq!(urls[800], "file:/work/demo/classes/"); - } -} diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 94f8a92d8..078c89342 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -3125,7 +3125,7 @@ fn validate_version(version: u32) -> Result<(), CoreError> { Ok(()) } -pub(super) fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { +pub fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { if version != SIDECAR_VERSION { return Err( CoreError::new(ErrorCode::NotSupported, "Unsupported document version") @@ -3682,9 +3682,7 @@ fn detected_ids(root: &Path) -> BTreeSet { /// generation, else derived from the project's own declarations (`pom.xml`, /// `.java-version`, the Maven wrapper) so a JDK can be chosen before the Run /// list has been identified. -pub(super) fn read_requirements( - root: &Path, -) -> Result, CoreError> { +pub fn read_requirements(root: &Path) -> Result, CoreError> { if let Some(layer) = generated_layer::get(root) { return Ok(Some(layer.requirements.clone())); } @@ -3928,7 +3926,7 @@ fn append_toolchain_diagnostics( } } -pub(super) fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { +pub fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { let mut actual_parts = version_parts(actual); let mut required_parts = version_parts(required); if kind == "java" { @@ -3945,7 +3943,7 @@ pub(super) fn version_satisfies(kind: &str, actual: &str, required: &str, minimu } } -pub(super) fn version_parts(value: &str) -> Vec { +pub fn version_parts(value: &str) -> Vec { value .split(|character: char| !character.is_ascii_digit()) .filter(|part| !part.is_empty()) @@ -3955,7 +3953,7 @@ pub(super) fn version_parts(value: &str) -> Vec { /// Drops the legacy `1.` prefix Java 8 and earlier report (`1.8.0_504`), so /// those runtimes compare on the same feature-version scale as `8` or `17.0.12`. -pub(super) fn java_feature_version_parts(parts: Vec) -> Vec { +pub fn java_feature_version_parts(parts: Vec) -> Vec { if parts.len() > 1 && parts[0] == 1 { parts[1..].to_vec() } else { diff --git a/rust/lithe-core/src/execution/launch_command.rs b/rust/lithe-core/src/execution/launch_command.rs index b1e57f248..24758441e 100644 --- a/rust/lithe-core/src/execution/launch_command.rs +++ b/rust/lithe-core/src/execution/launch_command.rs @@ -13,8 +13,8 @@ //! host must encode it losslessly with the launcher's native platform encoding. //! JEP 400 does not make Windows launcher argument files UTF-8. //! -//! JDK 8 has no argument files; [`super::plan_classpath_jar_launch`] covers -//! that release with a manifest-only class-path JAR under the same budget. +//! JDK 8 has no argument files, so a launch for that release always stays +//! direct here. use serde::{Deserialize, Serialize}; use std::path::Path; @@ -26,7 +26,7 @@ pub const WINDOWS_COMMAND_LINE_LIMIT: usize = 32_767; /// Headroom for the null terminator and for host quoting that this estimate /// does not model exactly. Shortening early costs nothing; shortening too late /// fails the launch. -pub(super) const COMMAND_LINE_MARGIN: usize = 2_048; +const COMMAND_LINE_MARGIN: usize = 2_048; /// Options whose value is a joined list of absolute paths. These carry /// practically all of an oversized command line, so moving them into the @@ -256,7 +256,7 @@ pub fn plan_launch_command( /// Windows counts UTF-16 code units, and each argument containing whitespace or /// a quote is wrapped in quotes. The estimate never has to be exact because /// [`COMMAND_LINE_MARGIN`] absorbs the difference. -pub(super) fn command_line_length(executable: &str, arguments: &[String]) -> usize { +fn command_line_length(executable: &str, arguments: &[String]) -> usize { let mut length = quoted_length(executable); for argument in arguments { length += 1 + quoted_length(argument); diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 02a92774e..2530a7f52 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -1,7 +1,6 @@ //! Run configuration generation, resolution, and project detectors. mod argument_text; -mod classpath_jar; mod configuration; mod context_selection; mod detectors; @@ -11,6 +10,12 @@ mod launch_command; mod project_ownership; mod provider_run_options; mod provider_runs; +// Toolchain requirement reading that a host-side Java selector needs without +// owning Core's in-process generated layer. +pub use configuration::{ + java_feature_version_parts, read_requirements, validate_sidecar_version, version_parts, + version_satisfies, ToolchainRequirementsDocument, +}; pub use java_selection::{ compare_java_candidates, select_java, select_java_candidates, JavaSelection, JavaSelectionCandidate, JavaSelectionRequest, @@ -18,9 +23,6 @@ pub use java_selection::{ mod types; pub(crate) use argument_text::{format_argument_text, ArgumentTextRequest}; -pub use classpath_jar::{ - plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, -}; pub(crate) use configuration::*; pub(crate) use context_selection::{select_context_configuration, ContextSelectionRequest}; pub(crate) use detectors::{scan, DirectoryContext}; @@ -31,7 +33,7 @@ pub use launch_command::{ }; pub(crate) mod workspace_scan; -pub use workspace_scan::{is_run_scan_excluded_directory, list_run_java_sources}; +pub use workspace_scan::is_run_scan_excluded_directory; mod environment; pub use environment::{ diff --git a/rust/lithe-core/src/execution/workspace_scan.rs b/rust/lithe-core/src/execution/workspace_scan.rs index fc78d46a1..734593166 100644 --- a/rust/lithe-core/src/execution/workspace_scan.rs +++ b/rust/lithe-core/src/execution/workspace_scan.rs @@ -176,99 +176,10 @@ fn relative(root: &Path, path: &Path) -> String { .replace('\\', "/") } -/// Returns Java paths using the same scan policy as run generation. Failure below -/// the root is tolerated; the subsequent Core fingerprint supplies diagnostics. -/// Stops before opening another directory once the limit is reached, retaining -/// only Java paths from the deterministic traversal rather than a full snapshot. -pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, CoreError> { - list_java_sources_with_reader(root, limit, &read_directory) -} - -fn list_java_sources_with_reader( - root: &Path, - limit: usize, - read: &impl Fn(&Path) -> io::Result>, -) -> Result, CoreError> { - let mut paths = Vec::new(); - if limit == 0 { - return Ok(paths); - } - visit_with_reader(root, None, read, |directory| { - for name in directory.files { - if name.to_ascii_lowercase().ends_with(".java") { - paths.push(relative(root, &directory.path.join(name))); - if paths.len() == limit { - return ControlFlow::Break(()); - } - } - } - ControlFlow::Continue(()) - })?; - paths.sort(); - Ok(paths) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn java_source_limit_stops_directory_reads_and_keeps_deterministic_results() { - let root = Path::new("fixture"); - for limit in [1, 8_000] { - let visited = std::cell::RefCell::new(Vec::new()); - let paths = list_java_sources_with_reader(root, limit, &|path| { - visited.borrow_mut().push(path.to_owned()); - if path == root { - Ok(vec![ - (root.join("z-unvisited"), true, false), - (root.join("b-sources"), true, false), - (root.join("a-blocked"), true, false), - (root.join(".pytest_cache"), true, false), - (root.join("link"), true, true), - ]) - } else if path.ends_with("a-blocked") { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - } else if path.ends_with("b-sources") { - // More than the cap in one directory must neither exceed the - // result limit nor open a later directory. Reverse native - // enumeration order to protect deterministic selection. - Ok((0..=limit) - .rev() - .map(|index| (path.join(format!("Main{index:05}.java")), false, false)) - .chain([(path.join("nested"), true, false)]) - .collect()) - } else { - panic!("source budget must prevent reading {}", path.display()); - } - }) - .unwrap(); - assert_eq!( - paths, - (0..limit) - .map(|index| format!("b-sources/Main{index:05}.java")) - .collect::>() - ); - assert_eq!( - *visited.borrow(), - [ - root.to_owned(), - root.join("a-blocked"), - root.join("b-sources") - ] - ); - } - } - - #[test] - fn java_source_zero_limit_does_not_read_the_workspace() { - let paths = list_java_sources_with_reader(Path::new("fixture"), 0, &|_| { - panic!("zero budget must not read directories") - }) - .unwrap(); - assert!(paths.is_empty()); - } - #[test] fn early_stop_retains_observed_unreadable_paths_without_affecting_full_scans() { let root = Path::new("fixture"); @@ -300,10 +211,6 @@ mod tests { .directories .iter() .any(|dir| dir.path.ends_with("z-later"))); - assert_eq!( - list_java_sources_with_reader(root, 10, &read).unwrap(), - ["b-sources/Main.java", "z-later/Main.java"] - ); } #[test] diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 4bb62e364..63052a4d3 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -1,7 +1,5 @@ //! Deterministic application services shared by the macOS and Windows hosts. -use std::path::Path; - mod action_search; mod agent; pub mod ai; @@ -35,6 +33,11 @@ pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, }; +/// The cancellation scope of one request, for a host crate that answers some +/// commands itself and still has to honour `cancel_operation` and timeouts the +/// way Core's own dispatcher does. +pub use protocol::cancellation::{check as check_cancellation, Scope as CancellationScope}; + /// Executes one versioned application command and returns a JSON response. pub fn execute_json(request: &str) -> String { runtime::execute_json(request) @@ -71,14 +74,6 @@ pub fn cancel_operation(operation_id: &str) -> bool { protocol::cancellation::cancel(operation_id) } -/// Derives the shared directory key for one JDT LS workspace state. -/// -/// Platform hosts use this when clearing the same cache directory that the -/// Rust-owned language-server runtime selected during startup. -pub fn jdt_workspace_key(workspace_root: &Path, workspace_fingerprint: Option<&str>) -> String { - lsp::workspace_key(workspace_root, workspace_fingerprint) -} - #[cfg(test)] mod tests; @@ -112,11 +107,6 @@ pub use git::{ GitMetadataWatchContext, }; -/// Rewrites `` in a Maven settings document while keeping -/// every other element and comment, so hosts editing the user's own -/// `settings.xml` share the writer JDT LS settings generation already uses. -pub use project::settings_with_local_repository; - /// Connects an authorized native window to the local IDE broker's request notifications. /// The host retains the callback until close; receivers claim IDs through `ideHost.control` /// before invoking application actions. This adapter boundary has no UI dependencies. diff --git a/rust/lithe-core/src/lsp/languages/mod.rs b/rust/lithe-core/src/lsp/languages/mod.rs index d721f6e66..055fe39d0 100644 --- a/rust/lithe-core/src/lsp/languages/mod.rs +++ b/rust/lithe-core/src/lsp/languages/mod.rs @@ -26,6 +26,6 @@ pub(crate) use java_workspace::{ java_workspace_policy, jdt_cache_retention, jdt_workspace_fingerprint, JavaWorkspacePolicyRequest, JdtCacheRetentionRequest, JdtWorkspaceFingerprintRequest, }; -pub(crate) use jdt::{resolve_workspace_key, workspace_key, JdtWorkspaceKeyRequest}; +pub(crate) use jdt::{resolve_workspace_key, JdtWorkspaceKeyRequest}; pub(crate) use jdt_configuration::prepare_configuration_area as prepare_jdt_configuration_area; pub(crate) use jdt_project_metadata::prepare_workspace as prepare_jdt_workspace; diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index 4e78b6ad8..29f4b7449 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -1,13 +1,11 @@ //! Maven reactor inspection, profile discovery, and source diagnostics. -use super::maven_dependency_tree; use super::maven_test_reports::{self, MavenTestReportsRequest}; use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ - MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenLaunchExecutableResponse, - MavenLaunchPlanResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, - MavenSourceRootKind, MavenSourceRootResponse, MavenTestFailureResponse, - MavenTestResultsResponse, + MavenLaunchExecutableResponse, MavenLaunchPlanResponse, MavenModuleResponse, + MavenProfileResponse, MavenScanResponse, MavenSourceRootKind, MavenSourceRootResponse, + MavenTestFailureResponse, MavenTestResultsResponse, }; use crate::remote::workspace_io as fs; use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; @@ -16,7 +14,7 @@ use regex::Regex; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap}; use std::path::{Component, Path, PathBuf}; #[derive(Debug, Deserialize)] @@ -28,14 +26,6 @@ pub struct MavenScanRequest { pub paths: Vec, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Maven process output to normalize into workspace diagnostics. -pub struct MavenDiagnosticsRequest { - pub root: String, - pub output: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Maven Surefire/Failsafe output to normalize into JUnit result data. @@ -73,33 +63,6 @@ pub struct MavenLaunchContextRequest { pub java_home_path: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Ordered Maven invocation tokens selected by a tool-window consumer. -/// -/// The first token is a lifecycle or custom goal. Remaining tokens are passed -/// directly to Maven as arguments without shell interpretation. -pub struct MavenLaunchPlanRequest { - pub root: String, - pub context: MavenLaunchContextRequest, - #[serde(default)] - pub module: Option, - pub goals: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// One bounded dependency-tree invocation for a validated Maven module. -pub struct MavenDependencyPlanRequest { - pub root: String, - pub context: MavenLaunchContextRequest, - #[serde(default)] - pub module: Option, - /// Absolute platform-owned file the plugin writes the tree to. The same - /// path is later passed to `maven.dependencies`. - pub output_file: String, -} - #[derive(Debug, Clone)] /// Validated Maven import settings consumed by the JDT LS adapter. pub(crate) struct MavenJdtConfiguration { @@ -134,54 +97,11 @@ struct ValidatedMavenContext { java_home_path: Option, } -/// Produces a deterministic Maven plan without resolving a native executable. -pub fn launch_plan(request: MavenLaunchPlanRequest) -> Result { - let arguments = normalized_tool_window_arguments(request.goals)?; - launch_plan_with_arguments( - request.root, - request.context, - request.module, - arguments, - true, - ) -} - -/// Produces a fixed invocation that writes one module's tree to a file. -/// -/// Exactly one project runs, because every project in the session would -/// overwrite the same file: a module is selected with `-pl`, and the reactor -/// root with `-N` so its modules are not visited. -pub fn dependency_plan( - request: MavenDependencyPlanRequest, -) -> Result { - let output_file = maven_dependency_tree::validated_output_file(&request.output_file)?; - let selects_root = request - .module - .as_deref() - .map(|module| normalized_project_path(module, "Maven module")) - .transpose()? - .is_none_or(|module| module == "."); - let mut arguments = Vec::new(); - if selects_root { - arguments.push("-N".to_string()); - } - arguments.extend(maven_dependency_tree::dependency_tree_arguments( - &output_file, - )); - launch_plan_with_arguments( - request.root, - request.context, - request.module, - arguments, - false, - ) -} - /// Applies a validated Maven context to Core-owned run/debug arguments. /// -/// Unlike the public tool-window command, the trailing arguments may contain -/// properties such as `-Dexec.mainClass`: those values were generated and -/// validated by the run-configuration domain rather than entered as goals. +/// The trailing arguments may contain properties such as `-Dexec.mainClass`: +/// those values were generated and validated by the run-configuration domain +/// rather than entered by a user as goals. pub(crate) fn launch_plan_with_arguments( root: String, context: MavenLaunchContextRequest, @@ -295,7 +215,7 @@ pub(crate) fn jdt_configuration( /// has to keep the mirrors, servers, and proxies the workspace already builds /// with. Dropping them would send project import to the default remote /// repositories instead of the ones Maven resolves through. -pub fn settings_with_local_repository( +pub(crate) fn settings_with_local_repository( source: &str, local_repository: &str, ) -> Result { @@ -640,40 +560,6 @@ fn normalized_profiles(values: Vec) -> Result, CoreError> { Ok(profiles.into_iter().collect()) } -fn normalized_tool_window_arguments(values: Vec) -> Result, CoreError> { - if values.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "At least one Maven goal is required", - )); - } - - let mut arguments = Vec::with_capacity(values.len()); - for (index, value) in values.into_iter().enumerate() { - let argument = value.trim(); - if argument.is_empty() || argument.chars().any(char::is_control) { - return Err( - CoreError::new(ErrorCode::InvalidRequest, "Maven argument is invalid") - .with_details(argument), - ); - } - if index == 0 { - let valid_goal = !argument.starts_with('-') - && argument.chars().all(|character| { - character.is_ascii_alphanumeric() || ".:_-".contains(character) - }); - if !valid_goal { - return Err( - CoreError::new(ErrorCode::InvalidRequest, "Maven goal is invalid") - .with_details(argument), - ); - } - } - arguments.push(argument.to_string()); - } - Ok(arguments) -} - pub(super) fn normalized_project_path(value: &str, label: &str) -> Result { let trimmed = value.trim(); if trimmed == "." { @@ -1224,45 +1110,6 @@ pub(crate) fn maven_root( } } -/// Parses Maven compiler output into stable workspace-relative diagnostics. -pub fn diagnostics( - request: MavenDiagnosticsRequest, -) -> Result { - existing_root(&request.root)?; - let expression = Regex::new(r"\[(ERROR|WARNING)]\s+(.*?):\[(\d+)(?:,(\d+))?]\s+(.*)") - .expect("static Maven diagnostic expression is valid"); - let mut seen = HashSet::new(); - let issues = request - .output - .lines() - .filter_map(|line| { - let captures = expression.captures(line)?; - let path = captures.get(2)?.as_str().trim().to_string(); - let line_number = captures.get(3)?.as_str().parse().ok()?; - let column = captures - .get(4) - .and_then(|value| value.as_str().parse().ok()); - let severity = captures.get(1)?.as_str().to_lowercase(); - let message = captures.get(5)?.as_str().trim().to_string(); - let key = ( - path.clone(), - line_number, - column, - severity.clone(), - message.clone(), - ); - seen.insert(key).then_some(MavenDiagnosticResponse { - path, - line: line_number, - column, - severity, - message, - }) - }) - .collect(); - Ok(MavenDiagnosticsResponse { issues }) -} - #[derive(Clone, Copy)] enum MavenTestFailureKind { Failure, diff --git a/rust/lithe-core/src/project/maven_dependency_tree.rs b/rust/lithe-core/src/project/maven_dependency_tree.rs index cb204b682..1911426fc 100644 --- a/rust/lithe-core/src/project/maven_dependency_tree.rs +++ b/rust/lithe-core/src/project/maven_dependency_tree.rs @@ -39,7 +39,7 @@ const MAX_TREE_BYTES: u64 = ((MAX_NODES + 1) * MAX_LINE_BYTES) as u64; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -/// Dependency-tree file written by one `maven.dependencyPlan` invocation. +/// Dependency-tree file written by one Maven dependency-plan invocation. pub struct MavenDependenciesRequest { /// Reactor-relative module the plan selected; `.` is the reactor root. pub module_path: String, diff --git a/rust/lithe-core/src/project/maven_test_reports.rs b/rust/lithe-core/src/project/maven_test_reports.rs index 583584450..1a762a59a 100644 --- a/rust/lithe-core/src/project/maven_test_reports.rs +++ b/rust/lithe-core/src/project/maven_test_reports.rs @@ -1,6 +1,6 @@ //! Per-test outcomes read from Maven Surefire and Failsafe XML reports. //! -//! The text reporter parsed by `maven.testResults` names only failing tests, +//! The text reporter parsed by `test_results` names only failing tests, //! so it cannot tell which of a class's methods passed or were skipped. The //! XML reports Surefire and Failsafe write for every executed class can: each //! `` records its outcome. Editors use these outcomes for per-method diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 22c7903fd..6d78461d8 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -23,7 +23,6 @@ pub(crate) use document_lifecycle::*; pub(crate) use files::*; pub(crate) use history::*; pub(crate) use markdown::*; -pub use maven::settings_with_local_repository; pub(crate) use maven::*; pub(crate) use maven_dependency_tree::{ dependencies, dependency_tree_arguments, validated_output_file, MavenDependenciesRequest, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 1ea65d11f..844f35079 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -378,10 +378,6 @@ pub enum CoreCommand { HistoryClear, /// Compares two host-supplied texts line by line (`diff.compareTexts`). DiffCompareTexts, - /// Inspects a declared Maven reactor (`maven.scan`). - MavenScan, - /// Produces a deterministic Maven invocation (`maven.launchPlan`). - MavenLaunchPlan, /// Shortens an oversized Java launch using a JDK argument file when needed. ExecutionPlanLaunchCommand, /// Resolve environment precedence after platform tool discovery. @@ -394,14 +390,6 @@ pub enum CoreCommand { ProcessLifecycleInventory, /// Turns a plan and the user's answers into what ends (`processLifecycle.settle`). ProcessLifecycleSettle, - /// Produces a bounded Maven dependency-tree invocation (`maven.dependencyPlan`). - MavenDependencyPlan, - /// Normalizes Maven dependency-tree output (`maven.dependencies`). - MavenDependencies, - /// Normalizes diagnostics from Maven output (`maven.diagnostics`). - MavenDiagnostics, - /// Normalizes JUnit/Surefire results from Maven output (`maven.testResults`). - MavenTestResults, /// Renders and sanitizes shared Markdown (`markdown.render`). MarkdownRender, /// Compiles a bounded boolean shortcut context (`keymap.parseContext`). @@ -1032,18 +1020,12 @@ impl CoreCommand { "history.usage" => Some(Self::HistoryUsage), "history.clear" => Some(Self::HistoryClear), "diff.compareTexts" => Some(Self::DiffCompareTexts), - "maven.scan" => Some(Self::MavenScan), - "maven.launchPlan" => Some(Self::MavenLaunchPlan), "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), "execution.resolveSearchPath" => Some(Self::ExecutionResolveSearchPath), "execution.planLaunchCommand" => Some(Self::ExecutionPlanLaunchCommand), "processLifecycle.resolve" => Some(Self::ProcessLifecycleResolve), "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), "processLifecycle.settle" => Some(Self::ProcessLifecycleSettle), - "maven.dependencyPlan" => Some(Self::MavenDependencyPlan), - "maven.dependencies" => Some(Self::MavenDependencies), - "maven.diagnostics" => Some(Self::MavenDiagnostics), - "maven.testResults" => Some(Self::MavenTestResults), "markdown.render" => Some(Self::MarkdownRender), "keymap.parseContext" => Some(Self::KeymapParseContext), "keymap.evaluateContext" => Some(Self::KeymapEvaluateContext), @@ -1357,14 +1339,6 @@ mod tests { )); } - #[test] - fn parses_maven_test_results_command() { - assert!(matches!( - CoreCommand::parse("maven.testResults"), - Some(CoreCommand::MavenTestResults) - )); - } - #[test] fn parses_debug_runtime_commands() { for command in [ diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 6ad336f75..e81442b85 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -357,24 +357,6 @@ pub struct MavenDependenciesResponse { pub dependencies: Vec, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// One normalized issue parsed from Maven process output. -pub struct MavenDiagnosticResponse { - pub path: String, - pub line: usize, - pub column: Option, - pub severity: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Maven diagnostics in stable source order. -pub struct MavenDiagnosticsResponse { - pub issues: Vec, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One failed or errored JUnit test reported by Maven Surefire/Failsafe. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index f698e0c08..16f87af44 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -24,6 +24,7 @@ use crate::languages::{ JavaClassNameRequest, JavaCodeVisionRequest, JavaServerPortRequest, JavaSourceDefinitionRequest, JavaStructureRequest, MybatisIndexRequest, SpringIndexRequest, }; +use crate::project::MarkdownRenderRequest; use crate::project::{ self, DirectoryListingRequest, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, ReplacementPreviewRequest, SearchIndexRequest, SearchIndexUpdateRequest, SearchRequest, @@ -34,10 +35,6 @@ use crate::project::{ HistoryIgnorePreviewRequest, HistoryRecordRequest, HistoryRelocateRequest, HistoryRenameRequest, HistoryStorageRequest, }; -use crate::project::{ - MarkdownRenderRequest, MavenDependenciesRequest, MavenDependencyPlanRequest, - MavenDiagnosticsRequest, MavenScanRequest, -}; use crate::protocol::CoreResponse; use crate::protocol::{CoreCommand, CoreRequest}; use crate::protocol::{CoreError, ErrorCode}; @@ -1290,37 +1287,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { CoreCommand::DiffCompareTexts => { plugin_response(id, parsed.payload, crate::diff::compare_texts_command) } - CoreCommand::MavenScan => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Maven scan request") - .with_details(error.to_string()) - }) - .and_then(crate::project::scan) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven scan response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::MavenLaunchPlan => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Maven launch-plan request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::launch_plan) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven launch plan should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } CoreCommand::ExecutionResolveSearchPath => { match serde_json::from_value::(parsed.payload) { Ok(request) => CoreResponse::success( @@ -1334,6 +1300,8 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { ), } } +======= +>>>>>>> 70c2fe086 (refactor(core): 移除仅 Windows 使用的 Maven 命令入口与超长类路径 jar,开放过渡 crate 需要的通用函数) CoreCommand::ExecutionResolveEnvironment => { match serde_json::from_value::( parsed.payload, @@ -1428,78 +1396,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { ), } } - CoreCommand::MavenDependencyPlan => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Maven dependency-plan request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::dependency_plan) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven dependency plan should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::MavenDependencies => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Maven dependencies request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::dependencies) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven dependencies should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::MavenDiagnostics => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Maven diagnostics request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::diagnostics) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven diagnostics should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::MavenTestResults => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Maven test-results request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::test_results) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Maven test results should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } CoreCommand::KeymapParseContext => { match serde_json::from_value::(parsed.payload) { Ok(request) => CoreResponse::success( diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 0507953e9..2a4c6d4d2 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -1,12 +1,72 @@ use super::support::temporary_root; -use crate::execute_json; use crate::project::{ jdt_configuration, settings_with_local_repository, MavenLaunchContextRequest, }; +use crate::protocol::{CoreError, CoreResponse, ErrorCode}; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; +/// Runs one request the way the tests below were written for. +/// +/// The Windows host answers the `maven.*` commands itself, so Core no longer +/// dispatches them. `scan`, `dependencies` and `test_results` remain Core code +/// because the Maven build provider calls them, and the tests below keep +/// covering that code: the three old command names go straight to those +/// functions, and every other command goes through Core's dispatcher. +fn execute_json(request: &str) -> String { + let parsed: Value = serde_json::from_str(request).expect("test request should be JSON"); + let id = parsed["id"].as_str().map(str::to_string); + let payload = parsed["payload"].clone(); + let response = match parsed["command"].as_str() { + Some("maven.scan") => maven_response( + id, + payload, + "Invalid Maven scan request", + crate::project::scan, + ), + Some("maven.dependencies") => maven_response( + id, + payload, + "Invalid Maven dependencies request", + crate::project::dependencies, + ), + Some("maven.testResults") => maven_response( + id, + payload, + "Invalid Maven test-results request", + crate::project::test_results, + ), + _ => return crate::execute_json(request), + }; + serde_json::to_string(&response).expect("response should encode") +} + +fn maven_response( + id: Option, + payload: Value, + invalid_message: &str, + run: impl FnOnce(Request) -> Result, +) -> CoreResponse +where + Request: serde::de::DeserializeOwned, + Response: serde::Serialize, +{ + let result = serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, invalid_message) + .with_details(error.to_string()) + }) + .and_then(run); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Maven response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } +} + #[test] fn java_launch_command_json_reuses_the_shared_argfile_planner() { let classpath = (0..800) @@ -211,30 +271,6 @@ fn maven_scan_returns_recursive_shared_project_model() { response["data"]["modules"][0]["modules"][0]["relativePath"], "module-a/module-b" ); - let diagnostics = serde_json::json!({ - "id": "maven-diagnostics", - "command": "maven.diagnostics", - "payload": { - "root": root, - "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" - } - }); - let diagnostics_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), - )) - .expect("diagnostics response should be JSON"); - assert_eq!(diagnostics_response["ok"], true); - assert_eq!( - diagnostics_response["data"]["issues"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert_eq!( - diagnostics_response["data"]["issues"][0]["severity"], - "error" - ); fs::remove_dir_all(root).expect("Maven fixture should be removable"); } @@ -414,62 +450,6 @@ fn maven_jdt_configuration_includes_module_java_source_paths() { fs::remove_dir_all(root).expect("Maven fixture should be removable"); } -#[test] -fn maven_launch_plan_matches_the_shared_compatibility_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/maven/launch-plan-v1.json" - )) - .expect("Maven launch-plan fixture should be valid JSON"); - let root = temporary_root("maven-launch-plan"); - fs::create_dir_all(root.join("projects/demo/service-api")) - .expect("nested Maven reactor should be creatable"); - fs::write( - root.join("projects/demo/pom.xml"), - r#"demopomservice-api"#, - ) - .expect("reactor pom should be writable"); - fs::write( - root.join("projects/demo/service-api/pom.xml"), - r#"service-api"#, - ) - .expect("module pom should be writable"); - fs::write( - root.join("pom.xml"), - r#"root"#, - ) - .expect("root pom should be writable"); - fs::create_dir_all(root.join(".mvn")).expect("Maven config directory should be creatable"); - fs::write(root.join(".mvn/maven.config"), "-DfromConfig=true\n") - .expect("Maven config should be writable"); - - for case in fixture["cases"] - .as_array() - .expect("Maven launch-plan fixture should contain cases") - { - let request = serde_json::json!({ - "id": case["name"], - "command": "maven.launchPlan", - "payload": { - "root": root, - "context": case["context"], - "module": case["module"], - "goals": case["goals"] - } - }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Maven launch-plan response should be JSON"); - - assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "case {}", case["name"]); - assert!(!response["data"]["arguments"] - .as_array() - .expect("arguments should be an array") - .iter() - .any(|argument| argument == "-DfromConfig=true")); - } - fs::remove_dir_all(root).expect("Maven launch-plan fixture should be removable"); -} - /// Temporary directory that is removed even when an assertion fails. struct TreeDirectory(PathBuf); @@ -495,27 +475,6 @@ impl Drop for TreeDirectory { } } -fn maven_dependency_plan(root: &Path, module: Value, output_file: Value) -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "maven-dependency-plan", - "command": "maven.dependencyPlan", - "payload": { - "root": root, - "context": { - "version": 1, - "reactorPath": ".", - "profiles": ["dev"] - }, - "module": module, - "outputFile": output_file - } - }) - .to_string(), - )) - .expect("Maven dependency-plan response should be JSON") -} - fn maven_dependencies(module_path: &str, output_file: &Path) -> Value { serde_json::from_str(&execute_json( &serde_json::json!({ @@ -539,99 +498,6 @@ fn flat_tree(nodes: usize) -> String { tree } -#[test] -fn maven_dependency_plan_writes_one_module_tree_to_the_platform_file() { - let workspace = TreeDirectory::new("maven-dependency-plan"); - let root = &workspace.0; - fs::create_dir_all(root.join("service")).expect("Maven module should be creatable"); - fs::write( - root.join("pom.xml"), - r#"demopomservice"#, - ) - .expect("reactor pom should be writable"); - fs::write( - root.join("service/pom.xml"), - r#"service"#, - ) - .expect("module pom should be writable"); - let output_file = root.join("scratch/tree.txt"); - let tree_arguments = |output_file: &Path| { - vec![ - Value::from("org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"), - Value::from("-Dverbose=true"), - Value::from("-DoutputType=text"), - Value::from("-Dtokens=standard"), - Value::from(format!("-DoutputFile={}", output_file.display())), - Value::from("-DoutputEncoding=UTF-8"), - Value::from("-DappendOutput=false"), - Value::from("-Dstyle.color=never"), - Value::from("-Duser.language=en"), - Value::from("-Duser.country=US"), - ] - }; - - let module = - maven_dependency_plan(root, Value::from("service"), serde_json::json!(output_file)); - assert_eq!(module["ok"], true, "{module}"); - let mut expected = vec![ - Value::from("-B"), - Value::from("-ntp"), - Value::from("-P"), - Value::from("dev"), - Value::from("-pl"), - Value::from("service"), - ]; - expected.extend(tree_arguments(&output_file)); - assert_eq!(module["data"]["arguments"], Value::Array(expected)); - - // Without `-N` every reactor project would overwrite the same file, and - // the root query would return whichever module Maven visited last. - for root_module in [Value::Null, Value::from(".")] { - let reactor = - maven_dependency_plan(root, root_module.clone(), serde_json::json!(output_file)); - assert_eq!(reactor["ok"], true, "{root_module}: {reactor}"); - let mut expected = vec![ - Value::from("-B"), - Value::from("-ntp"), - Value::from("-P"), - Value::from("dev"), - Value::from("-N"), - ]; - expected.extend(tree_arguments(&output_file)); - assert_eq!( - reactor["data"]["arguments"], - Value::Array(expected), - "{root_module}" - ); - } -} - -#[test] -fn maven_dependency_plan_rejects_output_paths_maven_would_misread() { - let workspace = TreeDirectory::new("maven-dependency-plan-output"); - let root = &workspace.0; - fs::write( - root.join("pom.xml"), - r#"demo"#, - ) - .expect("pom should be writable"); - let absolute = root.join("tree.txt").display().to_string(); - for (name, output_file) in [ - ("missing", Value::Null), - ("empty", Value::from("")), - ("relative", Value::from("scratch/tree.txt")), - ("padded", Value::from(format!(" {absolute}"))), - ( - "control", - Value::from(format!("{absolute}\n-Dverbose=false")), - ), - ] { - let response = maven_dependency_plan(root, Value::Null, output_file); - assert_eq!(response["ok"], false, "case {name}: {response}"); - assert_eq!(response["error"]["code"], "invalid_request", "case {name}"); - } -} - #[test] fn maven_dependencies_match_the_shared_compatibility_fixture() { let fixture: Value = serde_json::from_str(include_str!( @@ -1150,45 +1016,6 @@ fn maven_test_results_reject_oversized_output_and_bound_failure_details() { fs::remove_dir_all(root).expect("Maven bounds fixture should be removable"); } -#[test] -fn maven_launch_plan_rejects_unknown_modules_and_invalid_invocation_tokens() { - let root = temporary_root("maven-launch-invalid"); - fs::create_dir_all(&root).expect("invalid Maven fixture should be creatable"); - fs::write( - root.join("pom.xml"), - r#"demo"#, - ) - .expect("pom should be writable"); - for (name, module, goals) in [ - ("unknown-module", Some("missing"), vec!["verify"]), - ("missing-goal", None, vec!["-q", "-DskipTests"]), - ("invalid-goal", None, vec!["verify;", "-q"]), - ( - "control-character", - None, - vec!["verify", "-Dvalue=line\nbreak"], - ), - ] { - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": name, - "command": "maven.launchPlan", - "payload": { - "root": root, - "context": {"version": 1, "reactorPath": "."}, - "module": module, - "goals": goals - } - }) - .to_string(), - )) - .expect("invalid Maven response should be JSON"); - assert_eq!(response["ok"], false, "case {name}: {response}"); - assert_eq!(response["error"]["code"], "invalid_request"); - } - fs::remove_dir_all(root).expect("invalid Maven fixture should be removable"); -} - #[test] fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { let root = temporary_root("nested-maven"); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index d23cc4aab..1b8a36fb8 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -5097,8 +5097,9 @@ fn shared_run_scan_excludes_caches_and_keeps_hidden_sources() { fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(file, "class Main {}").unwrap(); } - let paths = crate::execution::list_run_java_sources(&root, 100).unwrap(); - assert_eq!(paths, vec![".sources/Main.java", "src/Main.java"]); + // What a host's source listing passes in: the cache directories are already + // left out, the hidden source directory stays. + let paths = vec![".sources/Main.java", "src/Main.java"]; let call = |command, payload| -> Value { let response: Value = serde_json::from_str(&execute_json( &serde_json::json!({ From 52fb74c811658b259847adba967f69d29aa9686f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:56:21 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(windows):=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20lithe-legacy-java=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=97=A7=20Java/Maven=20=E5=91=BD=E4=BB=A4=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20Windows=20=E5=8D=95=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1495:Windows 前端不变,原先发给 Core 的旧命令在宿主入口先交给 lithe_legacy_java::try_execute。 - core_execute 与 platform_invoke 两个入口都先调用 try_execute,命中旧命令名即返回,否则继续走 Core - run.rs、run/launch_arguments.rs、lsp.rs、lsp/jdt_workspace.rs、maven/user_settings.rs 中对已迁出函数 (超长类路径 jar、启动命令规划、JDK 选择、JDT 工作区键、运行源码枚举、本地仓库 settings 改写)的调用 改为 lithe_legacy_java - Cargo.toml 增加 lithe-legacy-java 路径依赖;Cargo.lock 同步增加该包及其依赖项 - test-stability-windows.ps1 的 WindowsRust 范围新增 lithe-legacy-java 的计时测试,依赖包测试不会被 宿主包的 cargo test 带上 - 功能矩阵中 Windows 的证据文件从 Core 已删除或不再被 Windows 使用的路径改为 crate 路径: run-java-long-classpath、run-discovery、java-runtime-discovery、java-maven-profiles-dependencies、 maven-user-settings-editing (cherry picked from commit 7b2a8feb853ee645c269da03675a3a253c4f4b83) --- .../scripts/test-stability-windows.ps1 | 6 ++++ .../java-maven-profiles-dependencies.json | 2 +- .../features/java-runtime-discovery.json | 4 +-- .../features/maven-user-settings-editing.json | 2 +- .../features/run-discovery.json | 2 +- .../features/run-java-long-classpath.json | 4 +-- windows/tauri/src-tauri/Cargo.lock | 16 ++++++++++ windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/core.rs | 6 ++++ windows/tauri/src-tauri/src/lsp.rs | 7 +++-- .../tauri/src-tauri/src/lsp/jdt_workspace.rs | 2 +- .../src-tauri/src/maven/user_settings.rs | 12 ++++---- windows/tauri/src-tauri/src/platform.rs | 5 ++++ windows/tauri/src-tauri/src/run.rs | 29 +++++++------------ .../src-tauri/src/run/launch_arguments.rs | 14 ++++----- 15 files changed, 70 insertions(+), 42 deletions(-) diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index 8b2eba2a3..e3f3f814b 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -202,6 +202,12 @@ if ($Scope -in @("All", "WindowsRust")) { -TargetDirectory "windows/tauri/src-tauri/target" ` -Report (Join-Path $reportRoot "windows-terminal-rust.json") + Invoke-TimedRustTests ` + -Manifest "windows/tauri/src-tauri/Cargo.toml" ` + -Package "lithe-legacy-java" ` + -TargetDirectory "windows/tauri/src-tauri/target" ` + -Report (Join-Path $reportRoot "windows-legacy-java-rust.json") + Invoke-TimedRustTests ` -Manifest "windows/tauri/src-tauri/Cargo.toml" ` -TargetDirectory "windows/tauri/src-tauri/target" ` diff --git a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json index fbc928a22..255228459 100644 --- a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json +++ b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json @@ -18,7 +18,7 @@ "evidence": [ "windows/tauri/src/features/maven", "windows/tauri/src-tauri/src/maven.rs", - "rust/lithe-core/src/project/maven_dependency_tree.rs" + "windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" diff --git a/shared/platform-feature-matrix/features/java-runtime-discovery.json b/shared/platform-feature-matrix/features/java-runtime-discovery.json index a19e0188e..7a3d822f4 100644 --- a/shared/platform-feature-matrix/features/java-runtime-discovery.json +++ b/shared/platform-feature-matrix/features/java-runtime-discovery.json @@ -34,8 +34,8 @@ "shared/fixtures/run-configuration/automatic-java-selection.json", "windows/tauri/src/features/run/utils/run-configuration.ts", "windows/tauri/src/features/run/utils/run-configuration.test.ts", - "rust/lithe-core/src/project/maven.rs", - "rust/lithe-core/src/tests/languages.rs", + "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", + "windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs", "shared/fixtures/execution/maven-runtime-versions.json", ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md", ".agents/notes/implemented/bug-fix/2026-10-30-login-shell-environment-and-jdk-selection.md", diff --git a/shared/platform-feature-matrix/features/maven-user-settings-editing.json b/shared/platform-feature-matrix/features/maven-user-settings-editing.json index c4e394694..642e7a240 100644 --- a/shared/platform-feature-matrix/features/maven-user-settings-editing.json +++ b/shared/platform-feature-matrix/features/maven-user-settings-editing.json @@ -18,7 +18,7 @@ "windows/tauri/src/features/settings/components/tabs/maven-user-settings-group.tsx", "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx", "windows/tauri/src/features/maven/api/maven-host-api.ts", - "rust/lithe-core/src/project/maven.rs", + "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", "windows/tauri/src/features/settings/components/tabs/maven-user-settings-group.test.tsx" ], "implementationStatus": "implemented", diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index 333e2364d..0e57057e9 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -42,7 +42,7 @@ "windows": { "evidence": [ "windows/tauri/src/features/run", - "rust/lithe-core/src/project/maven.rs", + "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", "rust/lithe-core/src/execution/detectors/maven.rs", "rust/lithe-core/src/languages/text.rs", "rust/lithe-core/src/languages/java.rs", diff --git a/shared/platform-feature-matrix/features/run-java-long-classpath.json b/shared/platform-feature-matrix/features/run-java-long-classpath.json index 90340f6f9..830dfe4d9 100644 --- a/shared/platform-feature-matrix/features/run-java-long-classpath.json +++ b/shared/platform-feature-matrix/features/run-java-long-classpath.json @@ -16,8 +16,8 @@ "windows": { "evidence": [ "windows/tauri/src-tauri/src/run/launch_arguments.rs", - "rust/lithe-core/src/execution/launch_command.rs", - "rust/lithe-core/src/execution/classpath_jar.rs", + "windows/tauri/crates/lithe-legacy-java/src/launch_command.rs", + "windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs", "windows/tauri/src-tauri/src/run.rs" ], "implementationStatus": "implemented", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 5064ad262..93fe70226 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2961,6 +2961,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "lithe-legacy-java" +version = "0.1.0" +dependencies = [ + "lithe-core", + "quick-xml 0.37.5", + "regex", + "serde", + "serde_json", + "sha2", + "tree-sitter", + "tree-sitter-java", + "zip 2.4.2", +] + [[package]] name = "lithe-project" version = "0.1.0" @@ -3015,6 +3030,7 @@ dependencies = [ "lithe-agent-host", "lithe-core", "lithe-git-host", + "lithe-legacy-java", "lithe-project", "lithe-terminal", "quick-xml 0.37.5", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index bfb42251b..ae1219fe9 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ chrono = { version = "0.4", features = ["serde"] } lithe-agent-host = { path = "../../../rust/lithe-agent-host" } lithe-git-host = { path = "../../../rust/lithe-git-host" } lithe-core = { path = "../../../rust/lithe-core" } +lithe-legacy-java = { path = "../crates/lithe-legacy-java" } lithe-project = { path = "../crates/project" } lithe-terminal = { path = "../crates/terminal" } keyring = { version = "3.6.3", features = ["windows-native"] } diff --git a/windows/tauri/src-tauri/src/core.rs b/windows/tauri/src-tauri/src/core.rs index 982e7592a..24433d337 100644 --- a/windows/tauri/src-tauri/src/core.rs +++ b/windows/tauri/src-tauri/src/core.rs @@ -361,6 +361,12 @@ pub async fn core_execute(window: tauri::WebviewWindow, request: String) -> Stri let owner = window.label().to_owned(); let app = window.app_handle().clone(); tauri::async_runtime::spawn_blocking(move || { + // The legacy Java, Maven, Spring and MyBatis commands are answered by + // the transitional crate (the Maven ones no longer exist in Core). + // It returns None for every other command, which continues to Core. + if let Some(response) = lithe_legacy_java::try_execute(&request) { + return response; + } let parsed = serde_json::from_str::(&request).ok(); let previous = parsed.as_ref().and_then(|value| { let root = canonical_indexing_root(value["payload"]["root"].as_str()?); diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 96b22cc00..d78a85334 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -192,7 +192,7 @@ fn resolve_java_launch( .transpose()?; let cache_directory = language_server_cache_directory(app); let active_workspace_key = - lithe_core::jdt_workspace_key(&workspace, workspace_fingerprint.as_deref()); + lithe_legacy_java::jdt_workspace_key(&workspace, workspace_fingerprint.as_deref()); log_manager.emit_json( "info", "windows.java.jdt_cache".to_string(), @@ -1310,8 +1310,9 @@ mod tests { let workspace = temp_dir(); let cache = temp_dir(); let current_fingerprint = "build=pom.xml@1:1|modules=core|jdtls=1.38.0"; - let current_key = lithe_core::jdt_workspace_key(&workspace, Some(current_fingerprint)); - let sibling_key = lithe_core::jdt_workspace_key(&workspace, Some("older-structure")); + let current_key = + lithe_legacy_java::jdt_workspace_key(&workspace, Some(current_fingerprint)); + let sibling_key = lithe_legacy_java::jdt_workspace_key(&workspace, Some("older-structure")); let current = cache.join("jdtls").join(current_key); let sibling = cache.join("jdtls").join(sibling_key); fs::create_dir_all(¤t).expect("current state directory"); diff --git a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs index 4df96039d..1b76f3a81 100644 --- a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs +++ b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs @@ -408,7 +408,7 @@ pub(super) fn clear_java_index_directory( workspace_root: &Path, workspace_fingerprint: Option<&str>, ) -> Result<(), String> { - let workspace_key = lithe_core::jdt_workspace_key(workspace_root, workspace_fingerprint); + let workspace_key = lithe_legacy_java::jdt_workspace_key(workspace_root, workspace_fingerprint); let target = cache_directory.join("jdtls").join(workspace_key); match std::fs::symlink_metadata(&target) { Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { diff --git a/windows/tauri/src-tauri/src/maven/user_settings.rs b/windows/tauri/src-tauri/src/maven/user_settings.rs index e85440ce8..8fd092ac2 100644 --- a/windows/tauri/src-tauri/src/maven/user_settings.rs +++ b/windows/tauri/src-tauri/src/maven/user_settings.rs @@ -129,13 +129,13 @@ pub(super) fn write_local_repository( if read_local_repository(document).is_err() { return Err(INVALID_ERROR.into()); } - let rewritten = lithe_core::settings_with_local_repository(document, local_repository) + let rewritten = lithe_legacy_java::settings_with_local_repository(document, local_repository) .map_err(|error| { - format!( - "{INVALID_ERROR}: {}", - error.details.unwrap_or(error.message) - ) - })?; + format!( + "{INVALID_ERROR}: {}", + error.details.unwrap_or(error.message) + ) + })?; // A settings file kept in a dotfiles repository is often a link; replacing // the link with a regular file would detach it from that repository. let target = if current.is_some() { diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index b05c99e89..c9b8c53c6 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -146,6 +146,11 @@ pub async fn platform_invoke( }; let response = tauri::async_runtime::spawn_blocking(move || { let _completion = completion; + // Same early answer as `core_execute`: the legacy Java-family commands + // are served by the transitional crate before Core sees them. + if let Some(response) = lithe_legacy_java::try_execute(&request) { + return response; + } if let Some(events) = events { return lithe_core::execute_json_with_events(&request, events); } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index d6f8a5ae6..e44cd0cd7 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -529,8 +529,7 @@ pub async fn run_list_java_sources(root: PathBuf) -> Result, String> fn list_java_sources(root: &Path) -> Result, String> { let root = existing_directory(root)?; - lithe_core::execution::list_run_java_sources(&root, MAX_JAVA_SOURCES) - .map_err(|error| error.message) + lithe_legacy_java::list_run_java_sources(&root, MAX_JAVA_SOURCES).map_err(|error| error.message) } #[tauri::command] @@ -1420,7 +1419,7 @@ pub(crate) fn language_service_java_runtimes( fn java_selection_candidates( root: Option<&Path>, runtimes: &[JavaRuntime], -) -> Vec { +) -> Vec { // Resolve source identities once, not from the sorting comparator. Windows // path identity is case-insensitive and may be reached through a symlink. let key = |path: &Path| normalize_path(path).to_string_lossy().to_lowercase(); @@ -1443,7 +1442,7 @@ fn java_selection_candidates( } else { 2 }; - lithe_core::execution::JavaSelectionCandidate { + lithe_legacy_java::JavaSelectionCandidate { id: runtime.home_path.clone(), version: runtime.version.clone(), priority, @@ -1455,13 +1454,13 @@ fn java_selection_candidates( fn select_project_java( root: Option<&Path>, runtimes: &[JavaRuntime], -) -> Result { +) -> Result { let candidates = java_selection_candidates(root, runtimes); let fallback_id = candidates .iter() - .min_by(|a, b| lithe_core::execution::compare_java_candidates(a, b)) + .min_by(|a, b| lithe_legacy_java::compare_java_candidates(a, b)) .map(|candidate| candidate.id.clone()); - lithe_core::execution::select_java(lithe_core::execution::JavaSelectionRequest { + lithe_legacy_java::select_java(lithe_legacy_java::JavaSelectionRequest { root: root.map(Path::to_path_buf), requirements: None, candidates, @@ -1488,7 +1487,7 @@ fn probe_java_homes(homes: Vec) -> Vec { .map(|candidate| (candidate.id.clone(), candidate)) .collect::>(); java.sort_by(|left, right| { - lithe_core::execution::compare_java_candidates( + lithe_legacy_java::compare_java_candidates( &candidates[&left.home_path], &candidates[&right.home_path], ) @@ -2938,18 +2937,12 @@ mod tests { #[test] fn skipped_directories_include_build_outputs() { - assert!(lithe_core::execution::is_run_scan_excluded_directory( - "target" - )); - assert!(lithe_core::execution::is_run_scan_excluded_directory( - ".git" - )); - assert!(lithe_core::execution::is_run_scan_excluded_directory( + assert!(lithe_legacy_java::is_run_scan_excluded_directory("target")); + assert!(lithe_legacy_java::is_run_scan_excluded_directory(".git")); + assert!(lithe_legacy_java::is_run_scan_excluded_directory( "node_modules" )); - assert!(!lithe_core::execution::is_run_scan_excluded_directory( - "src" - )); + assert!(!lithe_legacy_java::is_run_scan_excluded_directory("src")); } #[test] diff --git a/windows/tauri/src-tauri/src/run/launch_arguments.rs b/windows/tauri/src-tauri/src/run/launch_arguments.rs index 02c4a9720..050c55187 100644 --- a/windows/tauri/src-tauri/src/run/launch_arguments.rs +++ b/windows/tauri/src-tauri/src/run/launch_arguments.rs @@ -28,18 +28,18 @@ pub(super) fn prepare( working_directory: &str, ) -> Result<(Vec, Option), String> { let version = java_feature_version(executable); - // JDK 8 has no `@argfile`; Core plans a manifest-only class-path JAR for - // it instead. Both planners check executable identity, and an unknown - // version keeps the original command. + // JDK 8 has no `@argfile`; the legacy Java crate plans a manifest-only + // class-path JAR for it instead. Both planners check executable identity, + // and an unknown version keeps the original command. if version.is_some_and(|version| version < 9) { return prepare_classpath_jar(executable, arguments, working_directory, version); } for _ in 0..128 { let path = next_temporary_path("argfile"); - let lithe_core::execution::LaunchCommandPlan::Argfile { + let lithe_legacy_java::LaunchCommandPlan::Argfile { arguments: shortened, argfile_contents, - } = lithe_core::execution::plan_launch_command(executable, arguments, &path, None, version) + } = lithe_legacy_java::plan_launch_command(executable, arguments, &path, None, version) else { return Ok((arguments.to_vec(), None)); }; @@ -76,7 +76,7 @@ fn prepare_classpath_jar( working_directory: &str, version: Option, ) -> Result<(Vec, Option), String> { - use lithe_core::execution::{ + use lithe_legacy_java::{ plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, }; let style = if cfg!(windows) { @@ -148,7 +148,7 @@ fn next_temporary_path(extension: &str) -> PathBuf { fn java_feature_version(executable: &str) -> Option { let home = std::path::Path::new(executable).parent()?.parent()?; let contents = fs::read_to_string(home.join("release")).ok()?; - lithe_core::execution::java_feature_version_from_release(&contents) + lithe_legacy_java::java_feature_version_from_release(&contents) } fn encode_argfile(text: &str) -> Result, String> { From 202cdf96d6fc1a263830ee95f2eef9fa8bafbe61 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 07:01:42 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(windows):=20=E8=BF=87=E6=B8=A1=20c?= =?UTF-8?q?rate=20=E5=8E=BB=E6=8E=89=E7=94=A8=E4=B8=8D=E5=88=B0=E7=9A=84?= =?UTF-8?q?=20Maven=20=E6=8F=92=E4=BB=B6=E7=BB=A7=E6=89=BF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=92=8C=E6=9C=AA=E4=BD=BF=E7=94=A8=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动计划只需要确认模块属于当前 reactor,因此只收集模块相对路径; 插件继承、坐标校验等仅 Core 运行标记用过的解析一并删除。 parent 的 groupId/version 仍按 Maven 规则补给未声明的子模块。 (cherry picked from commit 42dcf2909661cc2db0750bfe382d533c534af10b) --- .../src/java_source_definition.rs | 11 - .../crates/lithe-legacy-java/src/maven/mod.rs | 402 ++---------------- 2 files changed, 28 insertions(+), 385 deletions(-) diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs b/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs index ca994ed21..97baa138f 100644 --- a/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs +++ b/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs @@ -351,14 +351,3 @@ fn referenced_member( fn utf16_column(line: &str, byte: usize) -> usize { line[..byte.min(line.len())].encode_utf16().count() } - -fn line_number(source: &str, byte: usize) -> usize { - source[..byte.min(source.len())] - .bytes() - .filter(|value| *value == b'\n') - .count() -} - -fn utf16_offset(source: &str, byte: usize) -> usize { - source[..byte.min(source.len())].encode_utf16().count() -} diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs b/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs index f5271819b..9531180e6 100644 --- a/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs +++ b/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs @@ -6,7 +6,7 @@ mod dependency_tree; mod test_reports; -pub use self::dependency_tree::{dependencies, MavenDependenciesRequest}; +pub use self::dependency_tree::dependencies; use self::test_reports::MavenTestReportsRequest; use crate::contracts::{ MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenLaunchExecutableResponse, @@ -21,7 +21,6 @@ use quick_xml::{Reader, Writer}; use regex::Regex; use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; @@ -177,11 +176,8 @@ fn launch_plan_with_arguments( .map(|value| normalized_project_path(value, "Maven module")) .transpose()?; if let Some(module) = module.as_deref().filter(|value| *value != ".") { - let declared = declared_modules(&validated.canonical_reactor)?; - if !declared - .iter() - .any(|candidate| candidate.relative_path == module) - { + let declared = declared_module_paths(&validated.canonical_reactor)?; + if !declared.iter().any(|path| path == module) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Maven module is not part of the selected reactor", @@ -578,20 +574,12 @@ struct Descriptor { group_id: Option, artifact_id: Option, version: Option, - /// Coordinates of ``, recorded so a module can be matched back to the - /// reactor entry it inherits from. Maven identifies a parent by all three - /// coordinates: a shared `artifactId` does not prove the relationship, so a - /// module whose parent lives outside this reactor keeps its own plugins. - parent_group_id: Option, - parent_artifact_id: Option, - parent_version: Option, packaging: String, build_directory: Option, /// `` values configured for Surefire or Failsafe, raw. test_report_directories: Vec, module_paths: Vec, profiles: Vec, - plugins: Vec, source_directory: Option, test_source_directory: Option, resource_directories: Vec, @@ -600,93 +588,17 @@ struct Descriptor { generated_test_source_directories: Vec, } -impl Descriptor { - /// The coordinates a child must name in `` to inherit from this POM. - /// - /// `groupId` and `version` fall back to what `` declares, which is the - /// value Maven gives a module that inherits them. A coordinate that is still a - /// `${...}` expression proves nothing -- see `still_an_expression`. - fn coordinates(&self) -> Option { - verified_coordinates( - self.group_id.as_deref(), - self.artifact_id.as_deref(), - self.version.as_deref(), - ) - } - - /// The coordinates this POM names in its own ``, when it declares all - /// three. A partial declaration, or one still carrying a `${...}` expression, - /// names no verifiable parent. - fn parent_key(&self) -> Option { - verified_coordinates( - self.parent_group_id.as_deref(), - self.parent_artifact_id.as_deref(), - self.parent_version.as_deref(), - ) - } -} - -/// Whether a coordinate value is still a `${...}` expression. -/// -/// Maven interpolates expressions before it matches a parent, so the same raw text -/// can resolve to different values: two POMs writing `${revision}` may end up with -/// different versions once their own properties apply. A coordinate that has not -/// been resolved therefore cannot prove which POM a module inherits from, and is -/// refused rather than compared as a literal. -fn still_an_expression(value: &str) -> bool { - value.contains("${") -} - -/// Builds the `groupId:artifactId:version` triple a parent is identified by, or -/// `None` when any part is missing or still an unresolved expression. -fn verified_coordinates( - group_id: Option<&str>, - artifact_id: Option<&str>, - version: Option<&str>, -) -> Option { - let parts = [group_id?, artifact_id?, version?]; - if parts.iter().any(|value| still_an_expression(value)) { - return None; - } - Some(( - parts[0].to_string(), - parts[1].to_string(), - parts[2].to_string(), - )) -} - -/// One `` entry as Maven applies it. -/// -/// `inherited` mirrors ``: `false` keeps the plugin on the module that -/// declares it and stops it from reaching any child, which is the only way a -/// parent can ship a framework plugin to itself without making the whole reactor -/// runnable. - /// Maven's default `` for a plugin declaration that omits one. The /// `Plugin` model initializes the field to this value, so a bare artifactId is /// never resolved against another group. const DEFAULT_PLUGIN_GROUP: &str = "org.apache.maven.plugins"; -#[derive(Debug, Clone)] -/// Visibility matches `DeclaredModule`, which is re-exported crate-wide. -struct BuildPlugin { - /// The group Maven resolves the plugin under. A declaration that omits - /// `` gets Maven's default of `org.apache.maven.plugins`, so a - /// bare `spring-boot-maven-plugin` names a different plugin than - /// `org.springframework.boot:spring-boot-maven-plugin`. - pub(crate) group_id: String, - pub(crate) artifact_id: String, - pub(crate) inherited: bool, -} - #[derive(Debug, Default)] /// Configuration buffered until the owning build plugin's `artifactId` is known. struct PendingBuildPlugin { /// ``; absent means Maven's default of `org.apache.maven.plugins`. group_id: Option, artifact_id: Option, - /// ``; absent means Maven's default of `true`. - inherited: Option, compiler_generated_source_directories: Vec, compiler_generated_test_source_directories: Vec, build_helper_source_directories: Vec, @@ -694,232 +606,30 @@ struct PendingBuildPlugin { reports_directories: Vec, } -/// One module of the declared build graph, flattened with the root first. +/// Lists the project-relative path of every module the reactor declares, with +/// the root first as `.`. /// -/// Callers inside the core read this instead of walking directories: Maven -/// modules are declared in ``, so a module may sit in a directory the -/// shared detector walk prunes -- one named `build` or `out` is invisible to a -/// directory-driven scan -- while a stray `pom.xml` outside the graph is not a -/// module at all. -#[derive(Debug, Clone)] -pub struct DeclaredModule { - /// Project-relative, forward-slashed, `.` for the root module. - pub relative_path: String, - pub artifact_id: String, - pub packaging: String, - /// Every plugin in the module's effective ``, including the - /// ones it inherits from ``. - pub plugins: Vec, - /// Source roots parsed from this module's own POM. - pub source_roots: Vec, -} - -impl DeclaredModule { - /// Reports whether this module applies a build plugin, whether it declares - /// the plugin itself or inherits it from ``. - /// - /// A plugin is identified by `groupId:artifactId`. Maven gives a declaration - /// that omits `` the default `org.apache.maven.plugins`, so a bare - /// `spring-boot-maven-plugin` names a different artifact than - /// `org.springframework.boot:spring-boot-maven-plugin` -- matching on the - /// artifactId alone would hand the module a framework whose goal it never - /// applied. - pub fn applies_plugin(&self, group_id: &str, artifact_id: &str) -> bool { - self.plugins - .iter() - .any(|plugin| plugin.group_id == group_id && plugin.artifact_id == artifact_id) - } - - /// `pom` packaging is an aggregator: it produces no artifact to run, so a - /// plugin declared there configures its children rather than a service. - pub fn is_aggregator(&self) -> bool { - self.packaging == "pom" - } -} - -/// Reads the declared module graph, or an empty list when the root is not a -/// Maven project. -pub fn declared_modules(root: &Path) -> Result, CoreError> { +/// Maven modules are declared in ``, so a module may sit in a +/// directory a directory-driven scan would prune -- one named `build` or `out` +/// -- while a stray `pom.xml` outside the graph is not a module at all. +fn declared_module_paths(root: &Path) -> Result, CoreError> { let Some(root_descriptor) = descriptor(&root.join("pom.xml"))? else { return Ok(Vec::new()); }; - // Inheritance is resolved from the `` coordinates a module names, so - // the reactor is indexed by directory first and the walk reads that resolved - // set instead of assuming an ancestor sits above the module in the module graph. - let resolved = inheritance_index(root, &root_descriptor)?; - let mut modules = Vec::new(); + let mut paths = Vec::new(); let mut visited = vec![root.to_path_buf()]; - collect_modules( - root, - root, - root_descriptor, - &mut modules, - &mut visited, - &resolved, - ); - Ok(modules) -} - -/// Full `groupId:artifactId:version` identity of one POM. -/// -/// Maven matches a `` reference against this triple, so a shared -/// `artifactId` cannot prove which POM a module inherits from: a reactor may -/// aggregate a module whose real parent lives outside it under that name. -type ModuleCoordinates = (String, String, String); - -/// Indexes every module the reactor declares by the directory it lives in, with -/// the plugin set it effectively applies. -/// -/// Only the reactor is consulted. A `` this reactor cannot prove -- one -/// whose full coordinates match no module here, including a parent reached through -/// `` outside the workspace or from the local repository -- -/// contributes nothing, which is what keeps a module from being handed a -/// framework the reactor cannot verify. -/// -/// The reader stays deliberately shallow rather than reproducing Maven's -/// effective model: it reads coordinates, ``, and `` -/// with their `` flags, and resolves only parent links the reactor -/// itself proves. Property interpolation, profiles, and `relativePath` resolution -/// stay with Maven, and anything that would need them resolves to no inheritance -/// instead of a guess. -fn inheritance_index( - root: &Path, - root_descriptor: &Descriptor, -) -> Result>, CoreError> { - // Pass one: every POM the reactor declares. Inheritance can only be resolved - // once all of them are known, because a child may name a parent that is not - // its aggregator and sits later in the module graph. - let mut modules: Vec<(PathBuf, Descriptor)> = Vec::new(); - let mut pending = vec![(root.to_path_buf(), root_descriptor.clone())]; - let mut visited = vec![root.to_path_buf()]; - - while let Some((directory, current)) = pending.pop() { - modules.push((directory.clone(), current.clone())); - for raw_path in ¤t.module_paths { - let Some(relative) = normalize_relative_path(raw_path) else { - continue; - }; - let child = directory.join(&relative).clean(); - if visited.iter().any(|path| path == &child) || !child.starts_with(root) { - continue; - } - let Ok(Some(child_descriptor)) = descriptor(&child.join("pom.xml")) else { - continue; - }; - visited.push(child.clone()); - pending.push((child, child_descriptor)); - } - } - - // Two modules sharing one coordinate set leave a child unable to say which it - // inherits, so neither is offered as a parent. - let mut by_coordinates: BTreeMap = BTreeMap::new(); - let mut ambiguous: BTreeSet = BTreeSet::new(); - for (position, (_, module)) in modules.iter().enumerate() { - let Some(coordinates) = module.coordinates() else { - continue; - }; - if by_coordinates.contains_key(&coordinates) { - by_coordinates.remove(&coordinates); - ambiguous.insert(coordinates); - } else if !ambiguous.contains(&coordinates) { - by_coordinates.insert(coordinates, position); - } - } - - // Pass two: resolve from the top down, so every module reads the set its own - // parent ended up with. Reading the ancestors' raw declarations instead would - // let a plugin an intermediate parent stopped reappear below it. - let mut resolved: BTreeMap> = BTreeMap::new(); - let mut visiting: BTreeSet = BTreeSet::new(); - for position in 0..modules.len() { - resolve_plugins( - position, - &modules, - &by_coordinates, - &mut resolved, - &mut visiting, - ); - } - - Ok(modules - .into_iter() - .enumerate() - .map(|(position, (directory, _))| { - ( - directory, - resolved.get(&position).cloned().unwrap_or_default(), - ) - }) - .collect()) -} - -/// Resolves one module's effective ``: its own declarations plus -/// what its parent hands down. -/// -/// `false` stops a plugin at the module that declares it. -/// Because a child reads this resolved set rather than the ancestors' raw -/// declarations, the stop holds for every descendant instead of letting a further -/// ancestor supply the plugin again. -fn resolve_plugins( - position: usize, - modules: &[(PathBuf, Descriptor)], - by_coordinates: &BTreeMap, - resolved: &mut BTreeMap>, - visiting: &mut BTreeSet, -) -> Vec { - if let Some(existing) = resolved.get(&position) { - return existing.clone(); - } - // Two POMs naming each other cannot produce a model; stopping at the repeat - // keeps the walk finite and leaves that module with its own declarations. - if !visiting.insert(position) { - return modules[position].1.plugins.clone(); - } - - let descriptor = &modules[position].1; - let inherited = descriptor - .parent_key() - .and_then(|key| by_coordinates.get(&key).copied()) - .filter(|parent| *parent != position) - .map(|parent| resolve_plugins(parent, modules, by_coordinates, resolved, visiting)) - .unwrap_or_default(); - visiting.remove(&position); - - let effective = merge_inherited(&descriptor.plugins, &inherited); - resolved.insert(position, effective.clone()); - effective + collect_module_paths(root, root, &root_descriptor, &mut paths, &mut visited); + Ok(paths) } -/// Merges what a module declares with what its parent hands down. -/// -/// The module's own declaration wins, matching the single `groupId:artifactId` -/// Maven merges the two under, and only plugins the parent passes on are -/// considered at all. -fn merge_inherited(own: &[BuildPlugin], inherited: &[BuildPlugin]) -> Vec { - let mut merged = own.to_vec(); - for plugin in inherited { - if plugin.inherited - && !merged.iter().any(|existing| { - existing.group_id == plugin.group_id && existing.artifact_id == plugin.artifact_id - }) - { - merged.push(plugin.clone()); - } - } - merged -} -/// Flattens the graph depth-first. Unlike `module`, which builds the nested -/// response, a visited path is never released: a module reachable through two -/// parents is one module, and emitting it twice would produce two run -/// configurations for one service. -fn collect_modules( +/// Walks the module graph depth-first. A visited directory is never released: +/// a module reachable through two parents is still one module. +fn collect_module_paths( root: &Path, directory: &Path, - current: Descriptor, - modules: &mut Vec, + current: &Descriptor, + paths: &mut Vec, visited: &mut Vec, - resolved: &BTreeMap>, ) { let relative_path = directory .strip_prefix(root) @@ -927,27 +637,7 @@ fn collect_modules( .map(|value| value.to_string_lossy().replace('\\', "/")) .filter(|value| !value.is_empty()) .unwrap_or_else(|| ".".to_string()); - let current_source_roots = source_roots(Some(¤t)); - // The index already resolved this directory against the `` it names, - // so these are the effective plugins. A directory it did not resolve has no - // provable parent and keeps only what it declares. - let effective_plugins = resolved - .get(directory) - .cloned() - .unwrap_or_else(|| current.plugins.clone()); - modules.push(DeclaredModule { - relative_path, - artifact_id: current.artifact_id.unwrap_or_else(|| { - directory - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("module") - .to_string() - }), - packaging: current.packaging, - plugins: effective_plugins, - source_roots: current_source_roots, - }); + paths.push(relative_path); for raw_path in ¤t.module_paths { let Some(relative) = normalize_relative_path(raw_path) else { continue; @@ -960,7 +650,7 @@ fn collect_modules( continue; }; visited.push(child.clone()); - collect_modules(root, &child, child_descriptor, modules, visited, resolved); + collect_module_paths(root, &child, &child_descriptor, paths, visited); } } @@ -1679,12 +1369,10 @@ fn path_has_suffix(path: &str, suffix: &str) -> bool { .is_some_and(|prefix| prefix.ends_with('/')) } -/// The reactor resolves inheritance through ``, so the parent's full -/// coordinates have to survive parsing -- they are what proves which reactor POM -/// a module inherits from. The parent's values also become the module's own -/// `groupId`/`version` when it declares neither, so this asserts both readings. +/// A module that declares neither `groupId` nor `version` takes the ones its +/// `` names, which is the identity Maven resolves for it. #[test] -fn descriptor_keeps_the_parent_coordinates() { +fn descriptor_inherits_missing_coordinates_from_the_parent() { let dir = std::env::temp_dir().join(format!("lithe-descriptor-parent-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let pom = dir.join("pom.xml"); @@ -1695,25 +1383,9 @@ fn descriptor_keeps_the_parent_coordinates() { .unwrap(); let parsed = descriptor(&pom).unwrap().expect("descriptor"); + assert_eq!(parsed.group_id.as_deref(), Some("com.example")); assert_eq!(parsed.artifact_id.as_deref(), Some("the-child")); - assert_eq!( - parsed.parent_key(), - Some(( - "com.example".to_string(), - "the-parent".to_string(), - "1".to_string() - )) - ); - // The module declares neither value itself, so its effective coordinates are - // the parent's pair plus its own `artifactId`. - assert_eq!( - parsed.coordinates(), - Some(( - "com.example".to_string(), - "the-child".to_string(), - "1".to_string() - )) - ); + assert_eq!(parsed.version.as_deref(), Some("1")); std::fs::remove_dir_all(&dir).unwrap(); } @@ -2122,24 +1794,16 @@ fn descriptor(path: &Path) -> Result, CoreError> { "project/groupId" => value.group_id = non_empty(text.clone()), "project/artifactId" => value.artifact_id = non_empty(text.clone()), "project/version" => value.version = non_empty(text.clone()), - // `` coordinates are recorded separately from the - // module's own. Maven identifies a parent by all three, so a - // shared `artifactId` alone cannot prove the relationship, and - // they also become the module's own `groupId`/`version` when it - // declares neither -- the effective coordinates Maven resolves. + // A module that declares no `groupId` or `version` of its own + // takes the one its `` names, as Maven resolves it. "project/parent/groupId" => { - value.parent_group_id = non_empty(text.clone()); if value.group_id.is_none() { - value.group_id = value.parent_group_id.clone(); + value.group_id = non_empty(text.clone()); } } - "project/parent/artifactId" => { - value.parent_artifact_id = non_empty(text.clone()) - } "project/parent/version" => { - value.parent_version = non_empty(text.clone()); if value.version.is_none() { - value.version = value.parent_version.clone(); + value.version = non_empty(text.clone()); } } "project/packaging" => { @@ -2227,11 +1891,6 @@ fn descriptor(path: &Path) -> Result, CoreError> { plugin.artifact_id = non_empty(text.clone()); } } - "project/build/plugins/plugin/inherited" => { - if let Some(plugin) = pending_build_plugin.as_mut() { - plugin.inherited = Some(text.eq_ignore_ascii_case("true")); - } - } "project/build/plugins/plugin" => { if let Some(plugin) = pending_build_plugin.take() { if let Some(artifact_id) = plugin.artifact_id { @@ -2261,11 +1920,6 @@ fn descriptor(path: &Path) -> Result, CoreError> { } _ => {} } - value.plugins.push(BuildPlugin { - group_id, - inherited: plugin.inherited.unwrap_or(true), - artifact_id, - }); } } } From 09914493ede23c190359e53e746f356805ec9687 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 6 Nov 2026 20:10:21 +0800 Subject: [PATCH 5/5] =?UTF-8?q?refactor(windows):=20=E8=BF=87=E6=B8=A1=20c?= =?UTF-8?q?rate=20=E6=94=B9=E5=90=8D=E5=B9=B6=E4=BF=AE=E5=A5=BD=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=93=E5=B1=9E=E9=97=B8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1739 的四个提交移植到最新 preview 后仍过不了 `verify-no-host-language-specifics`:它让 reviewer 跑 `--update-baseline`, 但闸门是严格棘轮,baseline 只降、allowlist 只减,搬代码引起的计数上升不能 用改基线绕开。两处真正的原因都已修掉: - 过渡 crate 改名为 `lithe-legacy-tooling`。crate 名叫 `lithe-legacy-java` 时,宿主引用它就会把 `java` 这个词带进 `windows/tauri/src-tauri/src` 的 七个文件(core.rs、platform.rs、run.rs、lsp.rs 等共 12 处词命中)。宿主 说的是“处理遗留命令”,不需要知道是哪门语言,改名后计数回落到基线以下。 - `tests/languages.rs` 不再用 `maven.scan`/`maven.dependencies`/ `maven.testResults` 三个命令名做分派(那三个 match 臂是 +3 nameComparisons),改为 `maven_json` 直接调用 Core 仍保留的 `project::scan`/`dependencies`/`test_results`。测试覆盖不变。 同批补上 #1739 把几个函数改成 `pub` 后缺的 Rustdoc (`validate_sidecar_version`、`version_satisfies`、`version_parts`、 `cancellation::check`、`ToolchainRequirementsDocument` 的字段)。 验证(macOS 本地): - `cargo test -p lithe-core`:全绿 - `cargo test -p lithe-legacy-tooling`:132 个通过 - `verify-no-host-language-specifics.mjs --base origin/preview`:Ratchet verified - `verify-rust-core-comments.sh`:通过 - `cargo check --bin lithe-windows`:只剩 preview 既有的 2 个 Windows 专用错误 - `verify-platform-feature-matrix-change.sh origin/preview HEAD`:通过 未执行:Windows 实机单测与 Parallels 冒烟,需要在 Windows CI 或实机确认。 --- .../scripts/test-stability-windows.ps1 | 2 +- .../lithe-core/src/execution/configuration.rs | 11 + rust/lithe-core/src/protocol/cancellation.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 2 - rust/lithe-core/src/tests/languages.rs | 222 +++++++----------- .../language-specifics-baseline.json | 18 +- .../java-maven-profiles-dependencies.json | 2 +- .../features/java-runtime-discovery.json | 4 +- .../features/maven-user-settings-editing.json | 2 +- .../features/run-discovery.json | 2 +- .../features/run-java-long-classpath.json | 4 +- .../Cargo.toml | 2 +- .../src/classpath_jar.rs | 0 .../src/commands.rs | 0 .../src/contracts.rs | 0 .../src/java_run_markers.rs | 0 .../src/java_selection.rs | 0 .../src/java_source_definition.rs | 0 .../src/java_workspace.rs | 0 .../src/jdt_workspace_key.rs | 0 .../src/launch_command.rs | 0 .../src/lib.rs | 0 .../src/local_fs.rs | 0 .../src/maven/dependency_tree.rs | 0 .../src/maven/mod.rs | 0 .../src/maven/test_reports.rs | 0 .../src/mybatis.rs | 0 .../src/run_sources.rs | 0 .../src/spring.rs | 0 .../src/tests/java.rs | 0 .../src/tests/maven.rs | 0 .../src/tests/mod.rs | 0 .../src/tests/mybatis.rs | 0 .../src/tests/spring.rs | 0 .../src/tests/support.rs | 2 +- windows/tauri/src-tauri/Cargo.lock | 4 +- windows/tauri/src-tauri/Cargo.toml | 2 +- windows/tauri/src-tauri/src/core.rs | 2 +- windows/tauri/src-tauri/src/lsp.rs | 6 +- .../tauri/src-tauri/src/lsp/jdt_workspace.rs | 2 +- .../src-tauri/src/maven/user_settings.rs | 2 +- windows/tauri/src-tauri/src/platform.rs | 2 +- windows/tauri/src-tauri/src/run.rs | 22 +- .../src-tauri/src/run/launch_arguments.rs | 8 +- 44 files changed, 140 insertions(+), 187 deletions(-) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/Cargo.toml (95%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/classpath_jar.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/commands.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/contracts.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/java_run_markers.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/java_selection.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/java_source_definition.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/java_workspace.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/jdt_workspace_key.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/launch_command.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/lib.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/local_fs.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/maven/dependency_tree.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/maven/mod.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/maven/test_reports.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/mybatis.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/run_sources.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/spring.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/java.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/maven.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/mod.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/mybatis.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/spring.rs (100%) rename windows/tauri/crates/{lithe-legacy-java => lithe-legacy-tooling}/src/tests/support.rs (95%) diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index e3f3f814b..cc7d07959 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -204,7 +204,7 @@ if ($Scope -in @("All", "WindowsRust")) { Invoke-TimedRustTests ` -Manifest "windows/tauri/src-tauri/Cargo.toml" ` - -Package "lithe-legacy-java" ` + -Package "lithe-legacy-tooling" ` -TargetDirectory "windows/tauri/src-tauri/target" ` -Report (Join-Path $reportRoot "windows-legacy-java-rust.json") diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 078c89342..9fe882476 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -560,7 +560,9 @@ impl RunConfiguration { #[serde(rename_all = "camelCase")] /// Versioned requirements written separately from run configurations. pub struct ToolchainRequirementsDocument { + /// Document version the reader must accept before trusting any requirement. pub version: u32, + /// Requirement per toolchain kind, keyed by the kind the caller asks about. #[serde(default)] pub toolchains: BTreeMap, } @@ -3125,6 +3127,7 @@ fn validate_version(version: u32) -> Result<(), CoreError> { Ok(()) } +/// Rejects a sidecar document written for a version this Core does not read. pub fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { if version != SIDECAR_VERSION { return Err( @@ -3926,6 +3929,10 @@ fn append_toolchain_diagnostics( } } +/// Answers whether `actual` meets `required` for one toolchain kind. +/// +/// `minimum` compares ordered versions; otherwise `actual` must start with +/// `required`, which is how a dialect such as Java compares feature versions. pub fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { let mut actual_parts = version_parts(actual); let mut required_parts = version_parts(required); @@ -3943,6 +3950,10 @@ pub fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool } } +/// Splits a version into its numeric parts, ignoring separators and suffixes. +/// +/// An input with no digits yields an empty list, which callers treat as "not a +/// version" rather than as zero. pub fn version_parts(value: &str) -> Vec { value .split(|character: char| !character.is_ascii_digit()) diff --git a/rust/lithe-core/src/protocol/cancellation.rs b/rust/lithe-core/src/protocol/cancellation.rs index 08758244d..0d7e4d513 100644 --- a/rust/lithe-core/src/protocol/cancellation.rs +++ b/rust/lithe-core/src/protocol/cancellation.rs @@ -93,6 +93,10 @@ pub fn cancel(operation_id: &str) -> bool { .unwrap_or(false) } +/// Fails with the cancellation error when the current scope was cancelled. +/// +/// Long-running Core work calls this between steps; outside a scope it always +/// succeeds. pub fn check() -> Result<(), CoreError> { CURRENT.with(|current| { let Some(state) = current.borrow().as_ref().cloned() else { diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 16f87af44..33270f5c3 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1300,8 +1300,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { ), } } -======= ->>>>>>> 70c2fe086 (refactor(core): 移除仅 Windows 使用的 Maven 命令入口与超长类路径 jar,开放过渡 crate 需要的通用函数) CoreCommand::ExecutionResolveEnvironment => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 2a4c6d4d2..e6e6c8434 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -7,39 +7,30 @@ use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; -/// Runs one request the way the tests below were written for. +/// Runs one request through Core's dispatcher. /// /// The Windows host answers the `maven.*` commands itself, so Core no longer /// dispatches them. `scan`, `dependencies` and `test_results` remain Core code -/// because the Maven build provider calls them, and the tests below keep -/// covering that code: the three old command names go straight to those -/// functions, and every other command goes through Core's dispatcher. +/// because the Maven build provider calls them; the tests below reach those +/// functions through [`maven_json`] instead of a command name, so no host-side +/// code names a build tool to dispatch on it. fn execute_json(request: &str) -> String { - let parsed: Value = serde_json::from_str(request).expect("test request should be JSON"); - let id = parsed["id"].as_str().map(str::to_string); - let payload = parsed["payload"].clone(); - let response = match parsed["command"].as_str() { - Some("maven.scan") => maven_response( - id, - payload, - "Invalid Maven scan request", - crate::project::scan, - ), - Some("maven.dependencies") => maven_response( - id, - payload, - "Invalid Maven dependencies request", - crate::project::dependencies, - ), - Some("maven.testResults") => maven_response( - id, - payload, - "Invalid Maven test-results request", - crate::project::test_results, - ), - _ => return crate::execute_json(request), - }; - serde_json::to_string(&response).expect("response should encode") + crate::execute_json(request) +} + +/// Answers one Maven test request from its payload, reporting failures the way +/// the former command did. +fn maven_json( + request: &Value, + invalid_message: &str, + run: impl FnOnce(Request) -> Result, +) -> Value +where + Request: serde::de::DeserializeOwned, + Response: serde::Serialize, +{ + let response = maven_response(None, request["payload"].clone(), invalid_message, run); + serde_json::to_value(response).expect("response should encode") } fn maven_response( @@ -247,14 +238,9 @@ fn maven_scan_returns_recursive_shared_project_model() { fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); let request = serde_json::json!({ - "id": "maven", - "command": "maven.scan", "payload": {"root": root, "paths": ["module-a/pom.xml", "pom.xml"]} }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Maven request should encode"), - )) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true); assert_eq!(response["data"]["relativePath"], "."); assert_eq!(response["data"]["artifactId"], "demo"); @@ -294,12 +280,9 @@ fn maven_scan_recognizes_configured_and_generated_source_roots_per_module() { .expect("configured pom should be writable"); let request = serde_json::json!({ - "id": "maven-source-roots", - "command": "maven.scan", "payload": {"root": root, "paths": ["module/pom.xml"]} }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -336,12 +319,9 @@ fn maven_scan_expands_custom_build_directory_and_execution_scoped_sources() { .expect("execution-scoped pom should be writable"); let request = serde_json::json!({ - "id": "maven-source-root-regressions", - "command": "maven.scan", "payload": {"root": root, "paths": ["custom-build/pom.xml"]} }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -383,12 +363,9 @@ fn maven_scan_ignores_unrelated_plugin_sources_and_invalid_build_directory() { .expect("invalid build pom should be writable"); let request = serde_json::json!({ - "id": "maven-source-root-boundaries", - "command": "maven.scan", "payload": {"root": root, "paths": ["pom.xml"]} }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -476,15 +453,13 @@ impl Drop for TreeDirectory { } fn maven_dependencies(module_path: &str, output_file: &Path) -> Value { - serde_json::from_str(&execute_json( + maven_json( &serde_json::json!({ - "id": "maven-dependencies", - "command": "maven.dependencies", "payload": {"modulePath": module_path, "outputFile": output_file} - }) - .to_string(), - )) - .expect("Maven dependency response should be JSON") + }), + "Invalid Maven dependencies request", + crate::project::dependencies, + ) } /// One tree line per node, as the pinned plugin writes a flat direct list. @@ -703,15 +678,13 @@ fn maven_test_results_match_the_shared_compatibility_fixture() { .expect("source directory should be creatable"); fs::write(path, "class CalculatorTest {}").expect("source should be writable"); } - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": case["name"], - "command": "maven.testResults", "payload": {"root": root, "output": case["output"]} - }) - .to_string(), - )) - .expect("Maven test-results response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); assert_eq!(response["data"], case["expected"], "case {}", case["name"]); fs::remove_dir_all(root).expect("Maven test fixture should be removable"); @@ -782,10 +755,8 @@ fn maven_test_results_read_current_reports_of_the_selected_classes() { set_modified(&module.join(report), run_started_seconds + 5); } - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-reports", - "command": "maven.testResults", "payload": { "root": root, "output": "[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 1\n", @@ -795,10 +766,10 @@ fn maven_test_results_read_current_reports_of_the_selected_classes() { "notBeforeMillis": not_before_millis } } - }) - .to_string(), - )) - .expect("report response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -839,10 +810,8 @@ fn maven_test_results_find_the_module_from_the_test_source() { }) .expect("report timestamp"); - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-reports-source", - "command": "maven.testResults", "payload": { "root": root, "output": "", @@ -851,10 +820,10 @@ fn maven_test_results_find_the_module_from_the_test_source() { "notBeforeMillis": 1_700_000_000_000_u64 } } - }) - .to_string(), - )) - .expect("report response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -895,19 +864,17 @@ fn maven_test_results_keep_current_reports_beyond_many_stale_ones() { .expect("current report"); set_modified(¤t, 1_700_000_010); - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-reports-bound", - "command": "maven.testResults", "payload": { "root": root, "output": "", "reports": { "notBeforeMillis": 1_700_000_000_000_u64 } } - }) - .to_string(), - )) - .expect("report response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -925,19 +892,17 @@ fn maven_test_results_reject_report_requests_outside_the_workspace() { ("..", serde_json::json!(["demo.OrderTest"])), (".", serde_json::json!(["../demo.OrderTest"])), ] { - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-reports-bounds", - "command": "maven.testResults", "payload": { "root": root, "output": "", "reports": { "module": module, "classes": classes, "notBeforeMillis": 0 } } - }) - .to_string(), - )) - .expect("bounds response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], false, "{module}: {response}"); assert_eq!(response["error"]["code"], "invalid_request", "{module}"); } @@ -948,18 +913,16 @@ fn maven_test_results_reject_report_requests_outside_the_workspace() { fn maven_test_results_aggregate_class_summaries_without_results_footer() { let root = temporary_root("maven-test-results-aggregate"); fs::create_dir_all(&root).expect("Maven aggregate workspace should be creatable"); - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-results-aggregate", - "command": "maven.testResults", "payload": { "root": root, "output": "[INFO] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.12 s - in FirstTest\n[INFO] Tests run: 3, Failures: 0, Errors: 1, Skipped: 1, Time elapsed: 0.08 s <<< FAILURE! -- in SecondTest\n" } - }) - .to_string(), - )) - .expect("aggregate Maven test-results response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); assert_eq!(response["data"]["testsRun"], 5); @@ -975,18 +938,16 @@ fn maven_test_results_aggregate_class_summaries_without_results_footer() { fn maven_test_results_aggregate_all_module_footers_and_ignore_reactor_lines() { let root = temporary_root("maven-test-results-footers"); fs::create_dir_all(&root).expect("Maven footer workspace should be creatable"); - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-results-footers", - "command": "maven.testResults", "payload": { "root": root, "output": "[INFO] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0\n[INFO] Tests run: 3, Failures: 0, Errors: 1, Skipped: 1\n[INFO] Reactor Summary for reactor 1.0-SNAPSHOT:\n[INFO] base ................................ SUCCESS\n[INFO] app ................................ FAILURE\n" } - }) - .to_string(), - )) - .expect("footer Maven test-results response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); assert_eq!(response["data"]["testsRun"], 5); @@ -1002,15 +963,13 @@ fn maven_test_results_aggregate_all_module_footers_and_ignore_reactor_lines() { fn maven_test_results_reject_oversized_output_and_bound_failure_details() { let root = temporary_root("maven-test-results-bounds"); fs::create_dir_all(&root).expect("Maven bounds workspace should be creatable"); - let oversized = serde_json::from_str::(&execute_json( + let oversized = maven_json( &serde_json::json!({ - "id": "oversized", - "command": "maven.testResults", "payload": {"root": root, "output": "x".repeat(500_001)} - }) - .to_string(), - )) - .expect("oversized test-results response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(oversized["ok"], false); assert_eq!(oversized["error"]["code"], "parse_failed"); fs::remove_dir_all(root).expect("Maven bounds fixture should be removable"); @@ -1038,8 +997,6 @@ fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { .expect("second pom should be writable"); let request = serde_json::json!({ - "id": "nested-maven", - "command": "maven.scan", "payload": { "root": root, "paths": [ @@ -1049,10 +1006,7 @@ fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { ] } }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Maven request should encode"), - )) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true); assert_eq!(response["data"]["relativePath"], "apps-a"); @@ -1074,17 +1028,12 @@ fn maven_scan_skips_a_malformed_root_descriptor_for_a_valid_nested_project() { .expect("nested pom should be writable"); let request = serde_json::json!({ - "id": "nested-maven-malformed-root", - "command": "maven.scan", "payload": { "root": root, "paths": ["pom.xml", "projects/demo/pom.xml"] } }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Maven request should encode"), - )) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true, "{response}"); assert_eq!(response["data"]["relativePath"], "projects/demo"); @@ -1877,12 +1826,9 @@ fn maven_scan_requires_the_official_coordinate_for_source_root_plugins() { } let request = serde_json::json!({ - "id": "maven-scan-plugin-coordinates", - "command": "maven.scan", "payload": {"root": root, "paths": ["official/pom.xml"]} }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Maven response should be JSON"); + let response = maven_json(&request, "Invalid Maven scan request", crate::project::scan); assert_eq!(response["ok"], true, "{response}"); let modules = response["data"]["modules"].as_array().unwrap(); @@ -1974,10 +1920,8 @@ fn maven_test_results_require_the_official_coordinate_for_report_plugins() { // The official pair contributes both custom directories, so both reports // are read; the same-named plugins under the custom group contribute none. - let response: Value = serde_json::from_str(&execute_json( + let response = maven_json( &serde_json::json!({ - "id": "maven-test-report-coordinates", - "command": "maven.testResults", "payload": { "root": root, "output": "", @@ -1987,10 +1931,10 @@ fn maven_test_results_require_the_official_coordinate_for_report_plugins() { "notBeforeMillis": 1_700_000_000_000_u64 } } - }) - .to_string(), - )) - .expect("report response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(response["ok"], true, "{response}"); let cases = response["data"]["testCases"].as_array().unwrap(); assert_eq!(cases.len(), 2, "{response}"); @@ -2001,10 +1945,8 @@ fn maven_test_results_require_the_official_coordinate_for_report_plugins() { ); } - let negative: Value = serde_json::from_str(&execute_json( + let negative = maven_json( &serde_json::json!({ - "id": "maven-test-report-coordinates", - "command": "maven.testResults", "payload": { "root": root, "output": "", @@ -2014,10 +1956,10 @@ fn maven_test_results_require_the_official_coordinate_for_report_plugins() { "notBeforeMillis": 1_700_000_000_000_u64 } } - }) - .to_string(), - )) - .expect("report response should be JSON"); + }), + "Invalid Maven test-results request", + crate::project::test_results, + ); assert_eq!(negative["ok"], true, "{negative}"); assert_eq!( negative["data"]["testCases"], diff --git a/scripts/architecture/language-specifics-baseline.json b/scripts/architecture/language-specifics-baseline.json index e1402734a..8a745c3f7 100644 --- a/scripts/architecture/language-specifics-baseline.json +++ b/scripts/architecture/language-specifics-baseline.json @@ -207,7 +207,6 @@ "rust/lithe-core/src/diagnostics/request_trace.rs": {"words":4}, "rust/lithe-core/src/editor/line_edit.rs": {"words":1}, "rust/lithe-core/src/editor/tests.rs": {"words":1}, - "rust/lithe-core/src/execution/classpath_jar.rs": {"words":10}, "rust/lithe-core/src/execution/configuration.rs": {"words":477,"nameComparisons":24}, "rust/lithe-core/src/execution/configuration/project_toolchain.rs": {"words":32}, "rust/lithe-core/src/execution/detectors/gradle.rs": {"words":11,"toolTables":4}, @@ -218,14 +217,13 @@ "rust/lithe-core/src/execution/launch_command.rs": {"words":54}, "rust/lithe-core/src/execution/mod.rs": {"words":11}, "rust/lithe-core/src/execution/project_ownership.rs": {"words":9}, - "rust/lithe-core/src/execution/workspace_scan.rs": {"words":20,"toolTables":34}, + "rust/lithe-core/src/execution/workspace_scan.rs": {"words":7,"toolTables":34}, "rust/lithe-core/src/languages/java.rs": {"words":109,"nameComparisons":2}, "rust/lithe-core/src/languages/java_syntax.rs": {"words":27,"nameComparisons":1}, "rust/lithe-core/src/languages/mod.rs": {"words":7}, "rust/lithe-core/src/languages/mybatis.rs": {"words":53,"nameComparisons":2}, "rust/lithe-core/src/languages/spring.rs": {"words":134,"nameComparisons":1}, "rust/lithe-core/src/languages/text.rs": {"words":2}, - "rust/lithe-core/src/lib.rs": {"words":1}, "rust/lithe-core/src/lsp/interface/client.rs": {"words":5,"nameComparisons":1}, "rust/lithe-core/src/lsp/interface/engine.rs": {"words":1551,"nameComparisons":19}, "rust/lithe-core/src/lsp/interface/engine_logging.rs": {"words":45}, @@ -256,21 +254,21 @@ "rust/lithe-core/src/project/files.rs": {"words":6}, "rust/lithe-core/src/project/history.rs": {"words":8,"toolTables":30}, "rust/lithe-core/src/project/indexing.rs": {"words":44,"nameComparisons":2}, - "rust/lithe-core/src/project/maven.rs": {"words":256}, + "rust/lithe-core/src/project/maven.rs": {"words":233}, "rust/lithe-core/src/project/maven_dependency_tree.rs": {"words":28}, "rust/lithe-core/src/project/maven_test_reports.rs": {"words":15}, - "rust/lithe-core/src/project/mod.rs": {"words":9}, + "rust/lithe-core/src/project/mod.rs": {"words":8}, "rust/lithe-core/src/project/name_matching.rs": {"words":1}, "rust/lithe-core/src/project/search_index.rs": {"words":5,"nameComparisons":1}, "rust/lithe-core/src/project_generators/archive.rs": {"words":4}, "rust/lithe-core/src/project_generators/form.rs": {"words":11}, "rust/lithe-core/src/project_generators/manifest.rs": {"words":1,"toolTables":1}, "rust/lithe-core/src/project_generators/template.rs": {"words":1}, - "rust/lithe-core/src/protocol/command.rs": {"words":88,"nameComparisons":25}, - "rust/lithe-core/src/protocol/contracts.rs": {"words":75}, + "rust/lithe-core/src/protocol/command.rs": {"words":67,"nameComparisons":19}, + "rust/lithe-core/src/protocol/contracts.rs": {"words":72}, "rust/lithe-core/src/remote/sessions_integration_tests.rs": {"words":14,"nameComparisons":2}, "rust/lithe-core/src/remote/workspace.rs": {"words":1}, - "rust/lithe-core/src/runtime/dispatcher.rs": {"words":127}, + "rust/lithe-core/src/runtime/dispatcher.rs": {"words":99}, "rust/lithe-core/src/tests/build_configuration.rs": {"words":3}, "rust/lithe-core/src/tests/build_project_options.rs": {"words":2}, "rust/lithe-core/src/tests/build_runtime_versions.rs": {"words":9}, @@ -284,14 +282,14 @@ "rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/dependencies.json": {"words":2}, "rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/model.json": {"words":332}, "rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/plugin.json": {"words":28}, - "rust/lithe-core/src/tests/languages.rs": {"words":416,"nameComparisons":2}, + "rust/lithe-core/src/tests/languages.rs": {"words":368,"nameComparisons":2}, "rust/lithe-core/src/tests/marketplace_plugins.rs": {"words":3}, "rust/lithe-core/src/tests/mod.rs": {"words":2}, "rust/lithe-core/src/tests/mybatis.rs": {"words":139}, "rust/lithe-core/src/tests/project.rs": {"words":64}, "rust/lithe-core/src/tests/project_generators.rs": {"words":75}, "rust/lithe-core/src/tests/protocol.rs": {"words":7}, - "rust/lithe-core/src/tests/run_configuration.rs": {"words":934,"nameComparisons":37}, + "rust/lithe-core/src/tests/run_configuration.rs": {"words":933,"nameComparisons":37}, "rust/lithe-core/src/tests/spring.rs": {"words":151}, "rust/lithe-core/src/tests/support.rs": {"words":1}, "rust/lithe-core/src/tests/toolchain_pins.rs": {"words":33}, diff --git a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json index 255228459..5072877c1 100644 --- a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json +++ b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json @@ -18,7 +18,7 @@ "evidence": [ "windows/tauri/src/features/maven", "windows/tauri/src-tauri/src/maven.rs", - "windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs" + "windows/tauri/crates/lithe-legacy-tooling/src/maven/dependency_tree.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" diff --git a/shared/platform-feature-matrix/features/java-runtime-discovery.json b/shared/platform-feature-matrix/features/java-runtime-discovery.json index 7a3d822f4..a590a2f29 100644 --- a/shared/platform-feature-matrix/features/java-runtime-discovery.json +++ b/shared/platform-feature-matrix/features/java-runtime-discovery.json @@ -34,8 +34,8 @@ "shared/fixtures/run-configuration/automatic-java-selection.json", "windows/tauri/src/features/run/utils/run-configuration.ts", "windows/tauri/src/features/run/utils/run-configuration.test.ts", - "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", - "windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/maven/mod.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/tests/maven.rs", "shared/fixtures/execution/maven-runtime-versions.json", ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md", ".agents/notes/implemented/bug-fix/2026-10-30-login-shell-environment-and-jdk-selection.md", diff --git a/shared/platform-feature-matrix/features/maven-user-settings-editing.json b/shared/platform-feature-matrix/features/maven-user-settings-editing.json index 642e7a240..058354c6b 100644 --- a/shared/platform-feature-matrix/features/maven-user-settings-editing.json +++ b/shared/platform-feature-matrix/features/maven-user-settings-editing.json @@ -18,7 +18,7 @@ "windows/tauri/src/features/settings/components/tabs/maven-user-settings-group.tsx", "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx", "windows/tauri/src/features/maven/api/maven-host-api.ts", - "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/maven/mod.rs", "windows/tauri/src/features/settings/components/tabs/maven-user-settings-group.test.tsx" ], "implementationStatus": "implemented", diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index 0e57057e9..f399feab6 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -42,7 +42,7 @@ "windows": { "evidence": [ "windows/tauri/src/features/run", - "windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/maven/mod.rs", "rust/lithe-core/src/execution/detectors/maven.rs", "rust/lithe-core/src/languages/text.rs", "rust/lithe-core/src/languages/java.rs", diff --git a/shared/platform-feature-matrix/features/run-java-long-classpath.json b/shared/platform-feature-matrix/features/run-java-long-classpath.json index 830dfe4d9..df5f99469 100644 --- a/shared/platform-feature-matrix/features/run-java-long-classpath.json +++ b/shared/platform-feature-matrix/features/run-java-long-classpath.json @@ -16,8 +16,8 @@ "windows": { "evidence": [ "windows/tauri/src-tauri/src/run/launch_arguments.rs", - "windows/tauri/crates/lithe-legacy-java/src/launch_command.rs", - "windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/launch_command.rs", + "windows/tauri/crates/lithe-legacy-tooling/src/classpath_jar.rs", "windows/tauri/src-tauri/src/run.rs" ], "implementationStatus": "implemented", diff --git a/windows/tauri/crates/lithe-legacy-java/Cargo.toml b/windows/tauri/crates/lithe-legacy-tooling/Cargo.toml similarity index 95% rename from windows/tauri/crates/lithe-legacy-java/Cargo.toml rename to windows/tauri/crates/lithe-legacy-tooling/Cargo.toml index 6fde013fc..2388f6c27 100644 --- a/windows/tauri/crates/lithe-legacy-java/Cargo.toml +++ b/windows/tauri/crates/lithe-legacy-tooling/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "lithe-legacy-java" +name = "lithe-legacy-tooling" version = "0.1.0" # Edition 2021 on purpose: most of this crate is code copied verbatim from # lithe-core, which is an edition 2021 crate. Keeping the edition means the diff --git a/windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs b/windows/tauri/crates/lithe-legacy-tooling/src/classpath_jar.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/classpath_jar.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/classpath_jar.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/commands.rs b/windows/tauri/crates/lithe-legacy-tooling/src/commands.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/commands.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/commands.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/contracts.rs b/windows/tauri/crates/lithe-legacy-tooling/src/contracts.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/contracts.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/contracts.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs b/windows/tauri/crates/lithe-legacy-tooling/src/java_run_markers.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/java_run_markers.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/java_run_markers.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_selection.rs b/windows/tauri/crates/lithe-legacy-tooling/src/java_selection.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/java_selection.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/java_selection.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs b/windows/tauri/crates/lithe-legacy-tooling/src/java_source_definition.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/java_source_definition.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/java_source_definition.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs b/windows/tauri/crates/lithe-legacy-tooling/src/java_workspace.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/java_workspace.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/java_workspace.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs b/windows/tauri/crates/lithe-legacy-tooling/src/jdt_workspace_key.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/jdt_workspace_key.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/jdt_workspace_key.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/launch_command.rs b/windows/tauri/crates/lithe-legacy-tooling/src/launch_command.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/launch_command.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/launch_command.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/lib.rs b/windows/tauri/crates/lithe-legacy-tooling/src/lib.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/lib.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/lib.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/local_fs.rs b/windows/tauri/crates/lithe-legacy-tooling/src/local_fs.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/local_fs.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/local_fs.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs b/windows/tauri/crates/lithe-legacy-tooling/src/maven/dependency_tree.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/maven/dependency_tree.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/maven/dependency_tree.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs b/windows/tauri/crates/lithe-legacy-tooling/src/maven/mod.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/maven/mod.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/maven/mod.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs b/windows/tauri/crates/lithe-legacy-tooling/src/maven/test_reports.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/maven/test_reports.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/maven/test_reports.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/mybatis.rs b/windows/tauri/crates/lithe-legacy-tooling/src/mybatis.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/mybatis.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/mybatis.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/run_sources.rs b/windows/tauri/crates/lithe-legacy-tooling/src/run_sources.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/run_sources.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/run_sources.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/spring.rs b/windows/tauri/crates/lithe-legacy-tooling/src/spring.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/spring.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/spring.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/java.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/java.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/tests/java.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/java.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/maven.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/tests/maven.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/maven.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/mod.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/tests/mod.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/mod.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/mybatis.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/tests/mybatis.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/mybatis.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/spring.rs similarity index 100% rename from windows/tauri/crates/lithe-legacy-java/src/tests/spring.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/spring.rs diff --git a/windows/tauri/crates/lithe-legacy-java/src/tests/support.rs b/windows/tauri/crates/lithe-legacy-tooling/src/tests/support.rs similarity index 95% rename from windows/tauri/crates/lithe-legacy-java/src/tests/support.rs rename to windows/tauri/crates/lithe-legacy-tooling/src/tests/support.rs index 1be43d5c4..79aadc327 100644 --- a/windows/tauri/crates/lithe-legacy-java/src/tests/support.rs +++ b/windows/tauri/crates/lithe-legacy-tooling/src/tests/support.rs @@ -24,7 +24,7 @@ pub(crate) fn temporary_root(label: &str) -> PathBuf { }); let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!( - "lithe-legacy-java-{label}-{}-{nonce}-{sequence}", + "lithe-legacy-tooling-{label}-{}-{nonce}-{sequence}", std::process::id() )) } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 93fe70226..832f46c80 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2962,7 +2962,7 @@ dependencies = [ ] [[package]] -name = "lithe-legacy-java" +name = "lithe-legacy-tooling" version = "0.1.0" dependencies = [ "lithe-core", @@ -3030,7 +3030,7 @@ dependencies = [ "lithe-agent-host", "lithe-core", "lithe-git-host", - "lithe-legacy-java", + "lithe-legacy-tooling", "lithe-project", "lithe-terminal", "quick-xml 0.37.5", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index ae1219fe9..dd1afda24 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -30,7 +30,7 @@ chrono = { version = "0.4", features = ["serde"] } lithe-agent-host = { path = "../../../rust/lithe-agent-host" } lithe-git-host = { path = "../../../rust/lithe-git-host" } lithe-core = { path = "../../../rust/lithe-core" } -lithe-legacy-java = { path = "../crates/lithe-legacy-java" } +lithe-legacy-tooling = { path = "../crates/lithe-legacy-tooling" } lithe-project = { path = "../crates/project" } lithe-terminal = { path = "../crates/terminal" } keyring = { version = "3.6.3", features = ["windows-native"] } diff --git a/windows/tauri/src-tauri/src/core.rs b/windows/tauri/src-tauri/src/core.rs index 24433d337..2943f6be4 100644 --- a/windows/tauri/src-tauri/src/core.rs +++ b/windows/tauri/src-tauri/src/core.rs @@ -364,7 +364,7 @@ pub async fn core_execute(window: tauri::WebviewWindow, request: String) -> Stri // The legacy Java, Maven, Spring and MyBatis commands are answered by // the transitional crate (the Maven ones no longer exist in Core). // It returns None for every other command, which continues to Core. - if let Some(response) = lithe_legacy_java::try_execute(&request) { + if let Some(response) = lithe_legacy_tooling::try_execute(&request) { return response; } let parsed = serde_json::from_str::(&request).ok(); diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index d78a85334..132fa99ae 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -192,7 +192,7 @@ fn resolve_java_launch( .transpose()?; let cache_directory = language_server_cache_directory(app); let active_workspace_key = - lithe_legacy_java::jdt_workspace_key(&workspace, workspace_fingerprint.as_deref()); + lithe_legacy_tooling::jdt_workspace_key(&workspace, workspace_fingerprint.as_deref()); log_manager.emit_json( "info", "windows.java.jdt_cache".to_string(), @@ -1311,8 +1311,8 @@ mod tests { let cache = temp_dir(); let current_fingerprint = "build=pom.xml@1:1|modules=core|jdtls=1.38.0"; let current_key = - lithe_legacy_java::jdt_workspace_key(&workspace, Some(current_fingerprint)); - let sibling_key = lithe_legacy_java::jdt_workspace_key(&workspace, Some("older-structure")); + lithe_legacy_tooling::jdt_workspace_key(&workspace, Some(current_fingerprint)); + let sibling_key = lithe_legacy_tooling::jdt_workspace_key(&workspace, Some("older-structure")); let current = cache.join("jdtls").join(current_key); let sibling = cache.join("jdtls").join(sibling_key); fs::create_dir_all(¤t).expect("current state directory"); diff --git a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs index 1b76f3a81..b97a4264a 100644 --- a/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs +++ b/windows/tauri/src-tauri/src/lsp/jdt_workspace.rs @@ -408,7 +408,7 @@ pub(super) fn clear_java_index_directory( workspace_root: &Path, workspace_fingerprint: Option<&str>, ) -> Result<(), String> { - let workspace_key = lithe_legacy_java::jdt_workspace_key(workspace_root, workspace_fingerprint); + let workspace_key = lithe_legacy_tooling::jdt_workspace_key(workspace_root, workspace_fingerprint); let target = cache_directory.join("jdtls").join(workspace_key); match std::fs::symlink_metadata(&target) { Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { diff --git a/windows/tauri/src-tauri/src/maven/user_settings.rs b/windows/tauri/src-tauri/src/maven/user_settings.rs index 8fd092ac2..449d624d4 100644 --- a/windows/tauri/src-tauri/src/maven/user_settings.rs +++ b/windows/tauri/src-tauri/src/maven/user_settings.rs @@ -129,7 +129,7 @@ pub(super) fn write_local_repository( if read_local_repository(document).is_err() { return Err(INVALID_ERROR.into()); } - let rewritten = lithe_legacy_java::settings_with_local_repository(document, local_repository) + let rewritten = lithe_legacy_tooling::settings_with_local_repository(document, local_repository) .map_err(|error| { format!( "{INVALID_ERROR}: {}", diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index c9b8c53c6..2598e41d7 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -148,7 +148,7 @@ pub async fn platform_invoke( let _completion = completion; // Same early answer as `core_execute`: the legacy Java-family commands // are served by the transitional crate before Core sees them. - if let Some(response) = lithe_legacy_java::try_execute(&request) { + if let Some(response) = lithe_legacy_tooling::try_execute(&request) { return response; } if let Some(events) = events { diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index e44cd0cd7..419b342d8 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -529,7 +529,7 @@ pub async fn run_list_java_sources(root: PathBuf) -> Result, String> fn list_java_sources(root: &Path) -> Result, String> { let root = existing_directory(root)?; - lithe_legacy_java::list_run_java_sources(&root, MAX_JAVA_SOURCES).map_err(|error| error.message) + lithe_legacy_tooling::list_run_java_sources(&root, MAX_JAVA_SOURCES).map_err(|error| error.message) } #[tauri::command] @@ -1419,7 +1419,7 @@ pub(crate) fn language_service_java_runtimes( fn java_selection_candidates( root: Option<&Path>, runtimes: &[JavaRuntime], -) -> Vec { +) -> Vec { // Resolve source identities once, not from the sorting comparator. Windows // path identity is case-insensitive and may be reached through a symlink. let key = |path: &Path| normalize_path(path).to_string_lossy().to_lowercase(); @@ -1442,7 +1442,7 @@ fn java_selection_candidates( } else { 2 }; - lithe_legacy_java::JavaSelectionCandidate { + lithe_legacy_tooling::JavaSelectionCandidate { id: runtime.home_path.clone(), version: runtime.version.clone(), priority, @@ -1454,13 +1454,13 @@ fn java_selection_candidates( fn select_project_java( root: Option<&Path>, runtimes: &[JavaRuntime], -) -> Result { +) -> Result { let candidates = java_selection_candidates(root, runtimes); let fallback_id = candidates .iter() - .min_by(|a, b| lithe_legacy_java::compare_java_candidates(a, b)) + .min_by(|a, b| lithe_legacy_tooling::compare_java_candidates(a, b)) .map(|candidate| candidate.id.clone()); - lithe_legacy_java::select_java(lithe_legacy_java::JavaSelectionRequest { + lithe_legacy_tooling::select_java(lithe_legacy_tooling::JavaSelectionRequest { root: root.map(Path::to_path_buf), requirements: None, candidates, @@ -1487,7 +1487,7 @@ fn probe_java_homes(homes: Vec) -> Vec { .map(|candidate| (candidate.id.clone(), candidate)) .collect::>(); java.sort_by(|left, right| { - lithe_legacy_java::compare_java_candidates( + lithe_legacy_tooling::compare_java_candidates( &candidates[&left.home_path], &candidates[&right.home_path], ) @@ -2937,12 +2937,12 @@ mod tests { #[test] fn skipped_directories_include_build_outputs() { - assert!(lithe_legacy_java::is_run_scan_excluded_directory("target")); - assert!(lithe_legacy_java::is_run_scan_excluded_directory(".git")); - assert!(lithe_legacy_java::is_run_scan_excluded_directory( + assert!(lithe_legacy_tooling::is_run_scan_excluded_directory("target")); + assert!(lithe_legacy_tooling::is_run_scan_excluded_directory(".git")); + assert!(lithe_legacy_tooling::is_run_scan_excluded_directory( "node_modules" )); - assert!(!lithe_legacy_java::is_run_scan_excluded_directory("src")); + assert!(!lithe_legacy_tooling::is_run_scan_excluded_directory("src")); } #[test] diff --git a/windows/tauri/src-tauri/src/run/launch_arguments.rs b/windows/tauri/src-tauri/src/run/launch_arguments.rs index 050c55187..919441ec1 100644 --- a/windows/tauri/src-tauri/src/run/launch_arguments.rs +++ b/windows/tauri/src-tauri/src/run/launch_arguments.rs @@ -36,10 +36,10 @@ pub(super) fn prepare( } for _ in 0..128 { let path = next_temporary_path("argfile"); - let lithe_legacy_java::LaunchCommandPlan::Argfile { + let lithe_legacy_tooling::LaunchCommandPlan::Argfile { arguments: shortened, argfile_contents, - } = lithe_legacy_java::plan_launch_command(executable, arguments, &path, None, version) + } = lithe_legacy_tooling::plan_launch_command(executable, arguments, &path, None, version) else { return Ok((arguments.to_vec(), None)); }; @@ -76,7 +76,7 @@ fn prepare_classpath_jar( working_directory: &str, version: Option, ) -> Result<(Vec, Option), String> { - use lithe_legacy_java::{ + use lithe_legacy_tooling::{ plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, }; let style = if cfg!(windows) { @@ -148,7 +148,7 @@ fn next_temporary_path(extension: &str) -> PathBuf { fn java_feature_version(executable: &str) -> Option { let home = std::path::Path::new(executable).parent()?.parent()?; let contents = fs::read_to_string(home.join("release")).ok()?; - lithe_legacy_java::java_feature_version_from_release(&contents) + lithe_legacy_tooling::java_feature_version_from_release(&contents) } fn encode_argfile(text: &str) -> Result, String> {