Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Test package notices
run: node --test --test-timeout=60000 scripts/package/collect-notices.test.mjs
- name: Test package resources
run: node --test --test-timeout=60000 scripts/package/*.test.mjs
- name: Check formatting
run: cargo fmt --all --check
- name: Lint
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Test package notices
run: node --test --test-timeout=60000 scripts/package/collect-notices.test.mjs
- name: Test package resources
run: node --test --test-timeout=60000 scripts/package/*.test.mjs
- name: Check formatting
run: cargo fmt --all --check
- name: Lint
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Java Support for Lithe

> **Viva 预览版 Java 支持(1.1.3)**:清单已迁到当前契约 0,后端使用 Core
> **Viva 预览版 Java 支持(1.2.0)**:清单已迁到当前契约 0,后端使用 Core
> `65cf82343c5e315aae2e335b2e8f0217b703e89a` 中的 Rust SDK 0.4.0(尚无对应发布标签)。
> 本轮验证含第三方 JAR 依赖的普通 Maven 工程:JDT 补全、Maven 编译、main 运行和断点调试,包含变量读取、继续和退出。
> 需要同时包含 Lithe 客户端的动作注册解码、动作刷新与首次调试加载修复;未修改这些行为的旧 preview 不能直接使用本包。
Expand All @@ -24,10 +24,27 @@
> SDK. Maven and Gradle import settings come from the Maven and Gradle plugins through a function interface that
> Lithe Core relays between plugins.

1.1.3 面向包含主仓库 [#1884](https://github.com/1lck/Lithe-IDEA/pull/1884) 的配套 Viva preview(客户端修订 `607f00967d70a7017d49805183d0d2e6ffdf1c0d` 或更新版本)。API / 能力与传输版本以 `plugin.json`、`sdk.lock.json`
1.2.0 面向包含主仓库 [#1884](https://github.com/1lck/Lithe-IDEA/pull/1884) 的配套 Viva preview(客户端修订 `607f00967d70a7017d49805183d0d2e6ffdf1c0d` 或更新版本)。API / 能力与传输版本以 `plugin.json`、`sdk.lock.json`
和固定 Core 中的[插件资料](https://github.com/Lithe-IDEA/Lithe-core-rust/tree/65cf82343c5e315aae2e335b2e8f0217b703e89a/shared/plugins)为准。
`MIGRATION.md` 保留迁移历史,不能用其中旧宿主、旧 API 数字推断当前兼容性。

## 二进制依赖源码

1.2.0 新增 `jdt:` 只读类内容提供者;配套 Viva 客户端需包含 [Lithe #1906](https://github.com/1lck/Lithe-IDEA/pull/1906) 的虚拟文档语言/URI
同步和过期内容导航取消修复。较旧的兼容 Viva 仍可使用此前的 Maven/Gradle 工作流,
但不保证打开反编译类后继续导航。新能力不增加 API、权限或运行时下载。

类型和方法定义复用 JDT 的源码附加与反编译结果。没有源码的类也可只读打开,并继续
跳到依赖的其他类;方法定位使用 JDT 返回的行号。每份内容最多 1 MiB,插件不持有额外
源码缓存,关闭标签或工作区沿用宿主资源回收。缺失依赖的成员不能保证解析。

针对残缺旧库,构建阶段修正锁定 JDT 1.61.0 对缺失内部类的遍历,避免整个外部类为空;
无法恢复缺失的内部类本身。修改后的 JDT core JAR 不再具有 Eclipse JAR 签名,随包包含
修改的 EPL 源码和完整哈希清单;原生 JVM 签名不变。来源、验证和移除条件见
[补丁说明](third_party/jdtls/patches/README.md)。安装包和下载缓存运行时保持只读。

`.jsp` 仍按 HTML 打开;Java 补全、跳转和 JSP 源码断点不属于此次新增能力。

## 当前 Viva 验证入口

先用 `scripts/build-package.sh --arch arm64` 与 `scripts/archive-package.sh` 构建真实插件包。
Expand Down
267 changes: 267 additions & 0 deletions backend/src/class_content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
//! Read-only binary sources owned by JDT LS. Only the plugin understands the
//! `jdt:` scheme; the host owns tabs, cancellation and subscription cleanup.

use crate::settings_session::{SettingsSession, REQUEST_METHOD, SERVER_ID};
use lithe_plugin_sdk::{
BackendHandle, Context, FunctionError, PluginError, SdkError, Subscription, Value,
WorkspaceContext,
};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, PoisonError};
use url::Url;

pub const CONTENT_FUNCTION: &str = "java.classFileContents";
const REGISTER_METHOD: &str = "lithe/host/documents/registerContentProvider";
const CONTENT_METHOD: &str = "java/classFileContents";
const CONTENT_TIMEOUT_MS: u64 = 25_000;
const MAX_URI_BYTES: usize = 16 * 1024;
const MAX_CONTENT_BYTES: usize = 1024 * 1024;
pub const REGISTRATION_FAILED: &str = "java.content_registration_failed";

struct Registration {
generation: u64,
subscription: Subscription,
}

/// Bindings belong to workspace generations, not individual JDT processes.
/// A stopped process leaves its binding in place; reads fail until JDT restarts.
pub struct ClassContentSession {
handle: BackendHandle,
registrations: Mutex<HashMap<String, Registration>>,
}
impl ClassContentSession {
pub fn new(handle: BackendHandle) -> Self {
Self {
handle,
registrations: Mutex::new(HashMap::new()),
}
}

/// Serialize registration mutations, but never fetch class text while holding
/// this gate. The host records registrations without calling their callback.
pub fn sync(&self, settings: &SettingsSession) -> Result<u64, SdkError> {
// Serialize the snapshot too: a delayed old context list must not
// dispose a binding that a newer synchronization already registered.
let mut bindings = self
.registrations
.lock()
.unwrap_or_else(PoisonError::into_inner);
let host = self.handle.host();
let listed = host.request("lithe/host/workspace/listContexts", json!({}))?;
let contexts = listed
.get("contexts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut seen = HashSet::new();
let mut failures = 0;
for value in contexts {
let context = WorkspaceContext::from_value(&value)?;
let id = context.context_id().to_owned();
seen.insert(id.clone());
if bindings
.get(&id)
.is_some_and(|b| b.generation == context.generation())
{
continue;
}
if !settings.server_is_running(&context) {
continue;
}
if self.bind(&mut bindings, &context).is_err() {
failures += 1;
}
}
let gone: Vec<_> = bindings
.keys()
.filter(|id| !seen.contains(*id))
.cloned()
.collect();
for id in gone {
if let Some(old) = bindings.remove(&id) {
if old.subscription.dispose().is_err() {
failures += 1;
}
}
}
Ok(failures)
}

/// Complete the registration before returning a valid JDT launch plan.
/// This prevents a fast first definition from beating the state callback.
pub fn ensure(&self, context: &WorkspaceContext) -> Result<(), SdkError> {
let mut bindings = self
.registrations
.lock()
.unwrap_or_else(PoisonError::into_inner);
self.bind(&mut bindings, context)
}

fn bind(
&self,
bindings: &mut HashMap<String, Registration>,
context: &WorkspaceContext,
) -> Result<(), SdkError> {
let id = context.context_id();
if bindings
.get(id)
.is_some_and(|b| b.generation == context.generation())
{
return Ok(());
}
if let Some(old) = bindings.remove(id) {
old.subscription.dispose()?;
}
let subscription = self.handle.host().subscribe(
REGISTER_METHOD,
json!({"context":context.to_value(),"scheme":"jdt","function":CONTENT_FUNCTION}),
)?;
bindings.insert(
id.to_owned(),
Registration {
generation: context.generation(),
subscription,
},
);
Ok(())
}

/// One restricted diagnostic per failed synchronization boundary, no paths
/// or upstream responses. Expected context disposal remains a successful no-op.
pub fn sync_reporting(&self, settings: &SettingsSession, report: &dyn Fn(&str, u64)) {
match self.sync(settings) {
Ok(0) => {}
Ok(count) => report(REGISTRATION_FAILED, count),
Err(_) => report(REGISTRATION_FAILED, 1),
}
}
}

fn failure(code: &str, message: &str) -> FunctionError {
PluginError::new(code, message).into()
}
fn validated_uri(params: &Value) -> Result<&str, FunctionError> {
let uri = params
.as_object()
.filter(|p| p.len() == 1)
.and_then(|p| p.get("uri"))
.and_then(Value::as_str)
.ok_or_else(|| {
failure(
"java.class_content.invalid_params",
"Invalid Java class content request",
)
})?;
if uri.len() > MAX_URI_BYTES
|| uri.chars().any(char::is_control)
|| !Url::parse(uri).is_ok_and(|u| {
u.scheme() == "jdt"
&& !u.path().is_empty()
&& u.username().is_empty()
&& u.password().is_none()
})
{
return Err(failure(
"java.class_content.invalid_params",
"Invalid Java class content request",
));
}
Ok(uri)
}
fn content_result(answer: Value) -> Result<Value, FunctionError> {
let content = match answer {
Value::Object(mut object) => object.remove("value"),
_ => None,
};
let Some(Value::String(content)) = content else {
return Err(failure(
"java.class_content.invalid_result",
"Java class source is unavailable",
));
};
if content.trim().is_empty() {
return Err(failure(
"java.class_content.empty_result",
"Java class source is unavailable",
));
}
if content.len() > MAX_CONTENT_BYTES {
return Err(failure(
"java.class_content.too_large",
"Java class source exceeds the content limit",
));
}
Ok(Value::String(content))
}

/// Reuse JDT's own decompiler and source attachment resolution. Keep the exact
/// opaque URI, including its query encoding, and hold no text cache or files.
pub fn read(
settings: &SettingsSession,
params: Value,
context: &Context,
) -> Result<Value, FunctionError> {
context.check_cancelled()?;
let uri = validated_uri(&params)?;
let workspace = context.workspace_context().ok_or_else(|| {
failure(
"java.class_content.context_missing",
"A workspace context is required",
)
})?;
let generation = settings
.running_server_generation(workspace)
.ok_or_else(|| {
failure(
"java.class_content.unavailable",
"The Java language server is unavailable",
)
})?;
let answer=context.host().request(REQUEST_METHOD,json!({"context":workspace.to_value(),"serverID":SERVER_ID,"method":CONTENT_METHOD,"params":{"uri":uri},"timeoutMilliseconds":CONTENT_TIMEOUT_MS}));
context.check_cancelled()?;
let answer = answer.map_err(|_| {
failure(
"java.class_content.unavailable",
"Java class source is unavailable",
)
})?;
if settings.running_server_generation(workspace) != Some(generation) {
return Err(failure(
"java.class_content.unavailable",
"The Java language server changed during the request",
));
}
content_result(answer)
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_identity_is_validated_but_never_reencoded() {
let uri = "jdt://contents/lib.jar/pkg/Type.java?=project/lib%5C/lib.jar%3Cpkg(Type.class";
let input = json!({"uri":uri});
assert_eq!(validated_uri(&input).unwrap(), uri);
for input in [
json!({"uri":"file:///private.java"}),
json!({"uri":"https://example.com"}),
json!({"uri":"jdt://contents/a\n"}),
json!({"uri":uri,"extra":true}),
json!({"uri":"jdt:"}),
json!({"uri":format!("jdt://contents/{}","x".repeat(MAX_URI_BYTES))}),
] {
assert!(validated_uri(&input).is_err());
}
}
#[test]
fn source_result_requires_bounded_text() {
assert_eq!(
content_result(json!({"value":"class Type {}"})).unwrap(),
json!("class Type {}")
);
assert!(content_result(json!({"value":null})).is_err());
assert!(content_result(json!({"value":""})).is_err());
assert!(content_result(json!({"value":"x".repeat(MAX_CONTENT_BYTES+1)})).is_err());
}
}
Loading
Loading