дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
278 рядки
12 KiB
Rust
278 рядки
12 KiB
Rust
//! Virtual documents: read-only contents a language server provides for a
|
||
//! URI (library classes from `jdt://`, disk classes from `file://`, or
|
||
//! `workspace/textDocumentContent`).
|
||
|
||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||
|
||
const URI_ESCAPES: &AsciiSet = &CONTROLS
|
||
.add(b' ')
|
||
.add(b'"')
|
||
.add(b'<')
|
||
.add(b'>')
|
||
.add(b'[')
|
||
.add(b']')
|
||
.add(b'\\')
|
||
.add(b'^')
|
||
.add(b'`')
|
||
.add(b'{')
|
||
.add(b'|')
|
||
.add(b'}');
|
||
|
||
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
|
||
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
|
||
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
|
||
match value {
|
||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
|
||
serde_json::Value::Object(fields) => {
|
||
for (key, value) in fields {
|
||
// Opaque payloads must survive resolve/execute round trips unchanged.
|
||
if matches!(key.as_str(), "data" | "arguments") {
|
||
continue;
|
||
}
|
||
if matches!(key.as_str(), "uri" | "targetUri")
|
||
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
|
||
{
|
||
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
|
||
} else {
|
||
normalize_csharp_uris(value);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct CSharpMetadata {
|
||
pub source: String,
|
||
pub assembly_name: Option<String>,
|
||
}
|
||
|
||
/// csharp-ls serves both decompiled and source-generated documents through metadata.
|
||
pub(crate) fn is_csharp_decompiled(uri: &str) -> bool {
|
||
if scheme(uri) != "csharp" {
|
||
return false;
|
||
}
|
||
let segs = segments(uri);
|
||
// The kind follows the project file, not a directory that happens to have this name.
|
||
if let Some(kind) = segs.windows(2).find_map(|s| {
|
||
(s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str())
|
||
}) {
|
||
return kind == "decompiled";
|
||
}
|
||
// Older csharp-ls versions used csharp:/metadata/... for decompiled symbols.
|
||
segs.first().is_some_and(|s| s == "metadata")
|
||
}
|
||
|
||
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
|
||
pub fn is_decompiled(text: &str) -> bool {
|
||
let head: String = text.chars().take(600).collect();
|
||
head.lines()
|
||
.take(12)
|
||
.take_while(|line| {
|
||
let line = line.trim();
|
||
line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*')
|
||
})
|
||
.any(|line| {
|
||
let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase();
|
||
[
|
||
"source code is unavailable, and was generated by the fernflower decompiler.",
|
||
"source code is decompiled from a .class file using fernflower decompiler",
|
||
"decompiled with cfr",
|
||
"decompiled with procyon",
|
||
]
|
||
.iter()
|
||
.any(|marker| line.starts_with(marker))
|
||
})
|
||
}
|
||
|
||
/// `scheme` of a URI (`jdt` for `jdt://…`).
|
||
pub fn scheme(uri: &str) -> &str {
|
||
uri.split_once(':').map(|(s, _)| s).unwrap_or("")
|
||
}
|
||
|
||
/// The URI's path segments (without query / fragment), percent-decoded.
|
||
fn segments(uri: &str) -> Vec<String> {
|
||
let rest = uri.split_once(':').map(|(_, r)| r).unwrap_or(uri);
|
||
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
|
||
let rest = rest.strip_prefix("//").unwrap_or(rest);
|
||
rest.split('/').filter(|s| !s.is_empty()).map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned()).collect()
|
||
}
|
||
|
||
/// Editor tab title: the last path segment; a class file whose real source
|
||
/// was found is shown as `.java` (IDEA: `String.java` vs `String.class`).
|
||
pub fn title(uri: &str, decompiled: bool) -> String {
|
||
let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||
// Recent jdtls versions use a .java path even for decompiled class handles.
|
||
if decompiled
|
||
&& scheme(uri) == "jdt"
|
||
&& name.ends_with(".java")
|
||
&& uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class"))
|
||
{
|
||
name.truncate(name.len() - 5);
|
||
name.push_str(".class");
|
||
}
|
||
match name.strip_suffix(".class") {
|
||
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
|
||
_ => name,
|
||
}
|
||
}
|
||
|
||
/// Where the document comes from, e.g. the jar path of a `jdt://` class.
|
||
pub fn description(uri: &str) -> Option<String> {
|
||
if scheme(uri) == "jar" {
|
||
return crate::library_source::parse(uri).map(|e| e.jar.to_string_lossy().into_owned());
|
||
}
|
||
if scheme(uri) != "jdt" {
|
||
return None;
|
||
}
|
||
let segs = segments(uri);
|
||
// jdt://contents/<jar or module>/<package>/<Class.class>?=<project>/<escaped jar path><package(Class.class
|
||
let container = segs.get(1).cloned();
|
||
let query = uri.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||
let query = percent_decode_str(query).decode_utf8_lossy();
|
||
let jar = query
|
||
.split_once('/')
|
||
.map(|(_, rest)| rest.replace('\\', ""))
|
||
.and_then(|rest| rest.split(['<', '(', '`', '=']).next().map(str::to_owned))
|
||
.filter(|p| p.ends_with(".jar") || p.ends_with(".jmod"));
|
||
let package = segs.get(2).filter(|_| segs.len() >= 4).cloned();
|
||
match (jar, container) {
|
||
(Some(jar), _) if jar.ends_with("jrt-fs.jar") => segs.get(1).cloned(),
|
||
(Some(jar), _) => Some(jar),
|
||
(None, Some(c)) => Some(match package {
|
||
Some(p) => format!("{c} › {p}"),
|
||
None => c,
|
||
}),
|
||
(None, None) => None,
|
||
}
|
||
}
|
||
|
||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||
let segs = segments(uri);
|
||
scheme(uri) == "jdt"
|
||
&& (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package))
|
||
&& segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java"))
|
||
&& description(uri).is_some_and(|p| {
|
||
let path = std::path::Path::new(&p);
|
||
path == jar
|
||
|| dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b)
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
|
||
|
||
#[test]
|
||
fn csharp_metadata_titles_and_kinds() {
|
||
for (uri, expected) in [
|
||
("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
|
||
("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true),
|
||
("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true),
|
||
("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false),
|
||
("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false),
|
||
("csharp:/metadata/App.csproj/generated/Player.g.cs", false),
|
||
("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false),
|
||
("file:///work/App.csproj/decompiled/Player.cs", false),
|
||
] {
|
||
assert_eq!(is_csharp_decompiled(uri), expected, "{uri}");
|
||
}
|
||
assert_eq!(
|
||
title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
|
||
"UnityEngine.MonoBehaviour.cs"
|
||
);
|
||
assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs");
|
||
}
|
||
|
||
#[test]
|
||
fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() {
|
||
let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs";
|
||
let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs";
|
||
let mut response = serde_json::json!([
|
||
{ "uri": uri, "source": uri },
|
||
{ "location": { "targetUri": uri } },
|
||
{ "uri": encoded },
|
||
{ "uri": "file:///D:/project/App.cs" },
|
||
{ "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" },
|
||
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
|
||
]);
|
||
let original = response.clone();
|
||
normalize_csharp_uris(&mut response);
|
||
assert_eq!(response[0]["uri"], encoded);
|
||
assert_eq!(response[0]["source"], uri);
|
||
assert_eq!(response[1]["location"]["targetUri"], encoded);
|
||
assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]);
|
||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn jdt_titles() {
|
||
assert_eq!(title(JDT, true), "SpringApplication.class");
|
||
assert_eq!(title(JDT, false), "SpringApplication.java");
|
||
assert_eq!(title("untitled:foo/bar.txt", false), "bar.txt");
|
||
assert_eq!(scheme(JDT), "jdt");
|
||
}
|
||
|
||
#[test]
|
||
fn jdt_description() {
|
||
assert!(matches_class(
|
||
JDT,
|
||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||
"org.springframework.boot",
|
||
"SpringApplication"
|
||
));
|
||
assert!(!matches_class(
|
||
JDT,
|
||
std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"),
|
||
"org.springframework.boot",
|
||
"SpringApplication"
|
||
));
|
||
assert!(!matches_class(
|
||
JDT,
|
||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||
"other.package",
|
||
"SpringApplication"
|
||
));
|
||
assert!(matches_class(
|
||
"jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class",
|
||
std::path::Path::new("/m2/a.jar"),
|
||
"",
|
||
"C"
|
||
));
|
||
assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar"));
|
||
assert_eq!(
|
||
description(
|
||
"jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"
|
||
)
|
||
.as_deref(),
|
||
Some("java.base")
|
||
);
|
||
assert_eq!(description("jdt://contents/a.jar/p/C.class").as_deref(), Some("a.jar › p"));
|
||
assert_eq!(description("foo://x/y"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn detects_generated_code() {
|
||
assert!(is_decompiled(
|
||
"// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;"
|
||
));
|
||
assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler"));
|
||
assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;"));
|
||
let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class";
|
||
assert_eq!(title(recent, true), "C.class");
|
||
assert_eq!(title(recent, false), "C.java");
|
||
assert!(is_decompiled(
|
||
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;"
|
||
));
|
||
assert!(!is_decompiled(
|
||
"// Failed to get sources. Instead, stub sources have been generated by the disassembler."
|
||
));
|
||
assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;"));
|
||
}
|
||
}
|